Compare commits
362 Commits
feat/boots
...
v1.1.1
| Author | SHA1 | Date |
|---|---|---|
|
|
00b9107ee3 | |
|
|
21b7aaca42 | |
|
|
b64a9f9238 | |
|
|
184350494d | |
|
|
d65ab71029 | |
|
|
07634c8a1d | |
|
|
e44f6658db | |
|
|
64efe45f0d | |
|
|
bda61ca560 | |
|
|
f05547921d | |
|
|
973fcb3f2d | |
|
|
c2681f2801 | |
|
|
164145463c | |
|
|
833f386e73 | |
|
|
254674f456 | |
|
|
9fc74eedc3 | |
|
|
75cb45bcbc | |
|
|
495d314aa5 | |
|
|
f970dda0bb | |
|
|
92dd6cc2d8 | |
|
|
58835a1051 | |
|
|
278c4b9953 | |
|
|
27292237c3 | |
|
|
9c851b28b4 | |
|
|
055c6228f4 | |
|
|
a5db914696 | |
|
|
e8bd8268fd | |
|
|
20329b176b | |
|
|
913d19d39c | |
|
|
4ae5281c91 | |
|
|
2b2bb439a5 | |
|
|
5e22e16291 | |
|
|
47812cfca9 | |
|
|
54fb6deb03 | |
|
|
6e81d6e93e | |
|
|
5a40107256 | |
|
|
d6ea39b69a | |
|
|
88d9a66f44 | |
|
|
bdb9c0a04a | |
|
|
e519653f66 | |
|
|
26c84e3695 | |
|
|
73dec7f685 | |
|
|
8f8a0ad4f7 | |
|
|
3f318c3f6a | |
|
|
513cadc5ce | |
|
|
f54d00b3e7 | |
|
|
87c49de6e5 | |
|
|
8d1fa52a5a | |
|
|
2a06b5dfb2 | |
|
|
e6793c4dae | |
|
|
2979f008b5 | |
|
|
a6a9c76660 | |
|
|
15c81489d4 | |
|
|
729f57755a | |
|
|
ebe29f8a5d | |
|
|
5d306fce31 | |
|
|
40a6b3269d | |
|
|
74ac406fc0 | |
|
|
f0c3bd9c2e | |
|
|
e8a3b8d5be | |
|
|
3278f063ea | |
|
|
c55a5d2b49 | |
|
|
6fc25051e4 | |
|
|
5de1f45703 | |
|
|
7102d01bcc | |
|
|
99b7393361 | |
|
|
befb67327f | |
|
|
a9c777c79a | |
|
|
67a3e78cb4 | |
|
|
4211f3dfab | |
|
|
a12ab148b3 | |
|
|
3e4d5472a7 | |
|
|
c899d41946 | |
|
|
64a33c675e | |
|
|
961bf62032 | |
|
|
a39670f920 | |
|
|
0be9c61484 | |
|
|
095d8e694a | |
|
|
06b84c5cd5 | |
|
|
c5021c4aa7 | |
|
|
c5252847bb | |
|
|
aaddffc096 | |
|
|
c642090d97 | |
|
|
270ec942d6 | |
|
|
5cbd949bd5 | |
|
|
38c5518f45 | |
|
|
93d409e18f | |
|
|
0b7961ec36 | |
|
|
576cfcafac | |
|
|
743ba96d13 | |
|
|
edaceb9597 | |
|
|
ce970a5fac | |
|
|
96dac236e8 | |
|
|
9b8d5dfd1e | |
|
|
bd779f00e5 | |
|
|
186c2e46d1 | |
|
|
4daf37a10b | |
|
|
435a202fdd | |
|
|
85fcc154b8 | |
|
|
ee5e92c0ed | |
|
|
c2a56382e1 | |
|
|
676c38643c | |
|
|
21d8dc310a | |
|
|
6376f5e9fa | |
|
|
be082b7bf9 | |
|
|
b85c12e8b9 | |
|
|
3699c7cdb9 | |
|
|
2bb1d7cc3b | |
|
|
c4649571df | |
|
|
d2d79b4f5f | |
|
|
32ae64d5fd | |
|
|
457eeeeaef | |
|
|
99715989aa | |
|
|
a58faf3f85 | |
|
|
f6b9181ed8 | |
|
|
6082164f8b | |
|
|
935c9ae6ac | |
|
|
bc8bbc56a5 | |
|
|
233fc73430 | |
|
|
492b1925fb | |
|
|
de6821b53e | |
|
|
276f926d57 | |
|
|
b860ce2e9b | |
|
|
30a80b6c15 | |
|
|
b844ff377d | |
|
|
0066b1c6a0 | |
|
|
9ccd4f59d8 | |
|
|
5c2e481985 | |
|
|
5d64da1cb3 | |
|
|
875fd11982 | |
|
|
9352935b88 | |
|
|
35307e64cc | |
|
|
e9240c1324 | |
|
|
c119e8d96e | |
|
|
4f3e11ae5e | |
|
|
8c3a79e258 | |
|
|
0560ae743d | |
|
|
7582df3de6 | |
|
|
f6ddf45dc0 | |
|
|
6387c747d0 | |
|
|
52b41bb0d5 | |
|
|
0dde76ad55 | |
|
|
6119044669 | |
|
|
35324b986a | |
|
|
52b1acbb80 | |
|
|
460fac01b1 | |
|
|
ac250071cb | |
|
|
c36ea17b39 | |
|
|
5aef5e693c | |
|
|
42ceafb57f | |
|
|
76510a59a3 | |
|
|
ac3f429958 | |
|
|
9fab251fb3 | |
|
|
a87a4f512d | |
|
|
55b1a1468f | |
|
|
d40113be8e | |
|
|
aae0457e19 | |
|
|
d26200c74b | |
|
|
a6d7594e5c | |
|
|
819872bb79 | |
|
|
7af3e2957b | |
|
|
2c0884357c | |
|
|
b55a127a00 | |
|
|
7cd3ff33bd | |
|
|
6367f4a9fd | |
|
|
53d305340f | |
|
|
4f9ca27732 | |
|
|
556a560506 | |
|
|
e5aea84539 | |
|
|
419a1a8552 | |
|
|
8117630a65 | |
|
|
ac86efd9c3 | |
|
|
062d337dd0 | |
|
|
3fad319053 | |
|
|
7b57ab21cb | |
|
|
952e8e5d2f | |
|
|
e96f4c1c1a | |
|
|
69043f9ffc | |
|
|
9f8659da32 | |
|
|
edf24257b5 | |
|
|
5db2f7fda7 | |
|
|
7941935f57 | |
|
|
2415dde288 | |
|
|
0135f114d3 | |
|
|
25c4c5df5a | |
|
|
2b70c9226a | |
|
|
c1e81808a7 | |
|
|
4681b135db | |
|
|
0219e3c987 | |
|
|
773ef4bd5f | |
|
|
834abcec40 | |
|
|
e2b4cdbac4 | |
|
|
7f60f88542 | |
|
|
7ef1b3e5c8 | |
|
|
17d7dc2ad9 | |
|
|
4ffdafc614 | |
|
|
e5f19f8db3 | |
|
|
02750a7c9a | |
|
|
3bd3e64a3d | |
|
|
c2b18ecfbc | |
|
|
7e17bc74b2 | |
|
|
04ba9dd2a9 | |
|
|
db7b8a6ee3 | |
|
|
371d517261 | |
|
|
1112b4cdec | |
|
|
0d9b62eb50 | |
|
|
cad245c5df | |
|
|
d1302e17bc | |
|
|
c54b718566 | |
|
|
28e681b9df | |
|
|
8eb9d7ad30 | |
|
|
9137eae6df | |
|
|
1b193ba4ea | |
|
|
300ea60f19 | |
|
|
08670ecbee | |
|
|
71ca0e1394 | |
|
|
a5a261fa37 | |
|
|
3e82b51f89 | |
|
|
8edb2a69e5 | |
|
|
7247edf9d8 | |
|
|
ba861ad579 | |
|
|
f5f4d2a8cd | |
|
|
1f42c05648 | |
|
|
db3ef1642b | |
|
|
46d821a047 | |
|
|
d1db23398d | |
|
|
353df9f054 | |
|
|
82916167cf | |
|
|
5301ce4309 | |
|
|
f06f00c428 | |
|
|
32fa1028cc | |
|
|
d05932105d | |
|
|
ac3b3a4e11 | |
|
|
a321d963d7 | |
|
|
0b2d762b88 | |
|
|
35a86c413c | |
|
|
406f753311 | |
|
|
22cbe9ac39 | |
|
|
6ca1e3fba5 | |
|
|
39a1f708a1 | |
|
|
507ab485a0 | |
|
|
3a794b05dd | |
|
|
fd7cba7403 | |
|
|
2b73a895c9 | |
|
|
8304f2e7dc | |
|
|
88957f8f57 | |
|
|
be99f413f7 | |
|
|
b50f97568f | |
|
|
87a81f5f40 | |
|
|
e09965f918 | |
|
|
28221549d1 | |
|
|
0cc5be5479 | |
|
|
5b85806605 | |
|
|
72731112f4 | |
|
|
8bb93137a3 | |
|
|
ae1f78f534 | |
|
|
718e8568c1 | |
|
|
62b4d85ef7 | |
|
|
ab5ba2b169 | |
|
|
493b81aadf | |
|
|
52fa7a34a2 | |
|
|
6593dae946 | |
|
|
bc2e95ba18 | |
|
|
e5c74c6bdd | |
|
|
d68d8e1f25 | |
|
|
eff8c08258 | |
|
|
c5d60340b7 | |
|
|
4336c3bb3f | |
|
|
08f61b43f5 | |
|
|
99081284a4 | |
|
|
f6efa4f200 | |
|
|
8bcb5ec268 | |
|
|
249efa0553 | |
|
|
01383e7e5e | |
|
|
3bf0dfb548 | |
|
|
cbbb523f9c | |
|
|
c011a11b48 | |
|
|
074b0c041b | |
|
|
3b288486cd | |
|
|
df10448e5d | |
|
|
ef110b06db | |
|
|
d3d686e575 | |
|
|
b44d25404d | |
|
|
54d9a05235 | |
|
|
e3ab3cb501 | |
|
|
a3423a2cee | |
|
|
80348d6a3f | |
|
|
b44e224bee | |
|
|
4e86e135a6 | |
|
|
879697c6ea | |
|
|
515f26234e | |
|
|
eabcb98f91 | |
|
|
7c1a153446 | |
|
|
bc278c8fa1 | |
|
|
5fb4e637fc | |
|
|
d7c2edb1df | |
|
|
96aceeeaa1 | |
|
|
66e8a4bbf6 | |
|
|
9162446b1f | |
|
|
491800e09c | |
|
|
157496f0c5 | |
|
|
483a79a822 | |
|
|
03c4b6508f | |
|
|
8825f9abfe | |
|
|
778bb7f117 | |
|
|
661b52dbe0 | |
|
|
3c5a18b5d7 | |
|
|
5cf1964d7d | |
|
|
c79874ccfe | |
|
|
b4cf94ee1a | |
|
|
64e3ca421d | |
|
|
e0e4c4e18d | |
|
|
f43ade79b0 | |
|
|
e2b8f25e78 | |
|
|
c56aa14f9d | |
|
|
fffcecb152 | |
|
|
e6bd6c9354 | |
|
|
42fa92cf20 | |
|
|
3e19778046 | |
|
|
a9fbb395f9 | |
|
|
65ba3d6588 | |
|
|
446da58061 | |
|
|
e5aeb3989e | |
|
|
770a6cf7cd | |
|
|
406251dfde | |
|
|
b9742f61ee | |
|
|
5e6d6b11b6 | |
|
|
c7fb1ce56d | |
|
|
294c9aef2f | |
|
|
9e664654a2 | |
|
|
a6a41719bb | |
|
|
5979470dda | |
|
|
93191f5de9 | |
|
|
00b8897b9d | |
|
|
8a392bfd06 | |
|
|
b65fe69088 | |
|
|
91e308efb0 | |
|
|
48ed5e7d34 | |
|
|
f240401a42 | |
|
|
18d1ed2424 | |
|
|
ced7e6103d | |
|
|
fbe87d99d5 | |
|
|
8a2a2ee695 | |
|
|
62c4412623 | |
|
|
65717bd3bd | |
|
|
36a564d5c8 | |
|
|
8d12a40a42 | |
|
|
ac3d17cd6a | |
|
|
c06ca0ae5d | |
|
|
4f724d57c9 | |
|
|
11cdcdd4da | |
|
|
7ae81d8127 | |
|
|
772e9d35ff | |
|
|
bf5813057b | |
|
|
49b5b7a523 | |
|
|
b032d8808b | |
|
|
1aa7535fc4 | |
|
|
c4cff8f67c | |
|
|
499adaff00 | |
|
|
2b08b072fe | |
|
|
254a7d46a0 | |
|
|
53c2a12d6d |
158
.env.example
158
.env.example
|
|
@ -63,23 +63,171 @@ REVERB_SERVER_PORT=8080
|
|||
# browser-side (baked into built assets): the address a browser can reach
|
||||
VITE_REVERB_APP_KEY=change-me-app-key
|
||||
VITE_REVERB_HOST=localhost
|
||||
VITE_REVERB_PORT=8080
|
||||
VITE_REVERB_SCHEME=http
|
||||
VITE_REVERB_PORT=443
|
||||
VITE_REVERB_SCHEME=https
|
||||
VITE_APP_NAME=CluPilot
|
||||
|
||||
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
|
||||
# when developing against a remote host; localhost for local-only.
|
||||
VITE_HMR_HOST=localhost
|
||||
|
||||
# ── Mail (dev → log) ─────────────────────────────────────────────────────
|
||||
# ── Mail ─────────────────────────────────────────────────────────────────
|
||||
# DEV: `log` writes mails to storage/logs (nothing is sent).
|
||||
# PROD: switch to smtp and fill in the credentials below.
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS=hello@clupilot.local
|
||||
MAIL_FROM_NAME=CluPilot
|
||||
# MAIL_MAILER=smtp
|
||||
# MAIL_HOST=smtp.your-provider.tld
|
||||
# MAIL_PORT=587
|
||||
# MAIL_USERNAME=
|
||||
# MAIL_PASSWORD=
|
||||
# MAIL_SCHEME=smtp # smtp | smtps (465)
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# CluPilot operations — fill these in to run real provisioning.
|
||||
# Everything below is OPTIONAL for local dev: left blank, the services are
|
||||
# no-ops/fakes and the app + test suite still run. Only real infrastructure
|
||||
# work (host onboarding, customer provisioning) needs them.
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Stripe (payments) ────────────────────────────────────────────────────
|
||||
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
|
||||
# Outside local/testing a missing secret makes the webhook fail closed.
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
# Not read yet — needed once the Checkout flow is built (currently the portal
|
||||
# only records a purchase intent):
|
||||
STRIPE_KEY=
|
||||
STRIPE_SECRET=
|
||||
|
||||
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
|
||||
# Creates <subdomain>.<zone> A-records for each customer instance.
|
||||
HETZNER_DNS_TOKEN=
|
||||
CLUPILOT_DNS_ZONE=clupilot.com
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── Admin-Konsole: erlaubte Hostnamen ────────────────────────────────────
|
||||
# Die Proxmox-Hosts UND das Admin-Dashboard dürfen nie öffentlich erreichbar
|
||||
# sein. Primär regelt das dein Reverse Proxy (nur www/app/ws/api öffentlich);
|
||||
# dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host
|
||||
# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt).
|
||||
# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand).
|
||||
# Beispiel: # Schluessel fuer gespeicherte VPN-Konfigurationen (32 Byte, base64).
|
||||
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
|
||||
# Erzeugen: head -c 32 /dev/urandom | base64
|
||||
VPN_CONFIG_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.
|
||||
ADMIN_HOSTS=admin.dev.clupilot.com
|
||||
ADMIN_HOST_EXCLUSIVE=true
|
||||
|
||||
# ── Nextcloud blueprint template ─────────────────────────────────────────
|
||||
# NOT an env var: set the Proxmox template VMID per plan in
|
||||
# config/provisioning.php → plans.*.template_vmid (default 9000).
|
||||
|
||||
# ── docker-compose knobs (host-side) ─────────────────────────────────────
|
||||
HOST_UID=1000
|
||||
HOST_GID=1000
|
||||
APP_PORT=80
|
||||
# Behind a reverse proxy — which is every production install — these must stay
|
||||
# on loopback: Docker publishes ports ahead of UFW, so anything on 0.0.0.0 is
|
||||
# reachable from the internet regardless of the firewall, and reaching a backend
|
||||
# directly skips every hostname and address rule the proxy enforces.
|
||||
# For local development without a proxy, set APP_PORT=80.
|
||||
APP_PORT=127.0.0.1:8080
|
||||
# Vite dev server (HMR). Off by default: over the HTTPS domains the
|
||||
# browser cannot load assets from http://<ip>:5173. Set true only when
|
||||
# working over http://10.10.90.185, then run: docker compose up -d app
|
||||
VITE_AUTOSTART=false
|
||||
VITE_PORT=5173
|
||||
REVERB_HOST_PORT=8080
|
||||
REVERB_HOST_PORT=127.0.0.1:8081
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
# 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}"
|
||||
|
|
@ -28,3 +28,7 @@ Thumbs.db
|
|||
|
||||
# Gitea push token (parent home dir; never track)
|
||||
.env.gitea
|
||||
clupilot.env
|
||||
|
||||
# SDD-Arbeitsverzeichnis (Ledger, Briefs, Review-Pakete) — Kladde, nicht Quelltext.
|
||||
.superpowers/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Models\SubscriptionRecord;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Books a module onto a contract at today's price, and freezes it there.
|
||||
*
|
||||
* Booking is the moment the module's price stops moving for this customer, for
|
||||
* exactly the reason the plan's price does: they agreed to a figure. What they
|
||||
* have NOT booked stays on the live catalogue — that is a sale still to be
|
||||
* made, at whatever it costs now.
|
||||
*/
|
||||
class BookAddon
|
||||
{
|
||||
public function __construct(private RecordCommercialEvent $record) {}
|
||||
|
||||
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): SubscriptionAddon
|
||||
{
|
||||
$price = app(AddonCatalogue::class)->priceCents($addonKey);
|
||||
|
||||
if ($price === null) {
|
||||
throw new RuntimeException("Unknown add-on: {$addonKey}");
|
||||
}
|
||||
|
||||
if ($quantity < 1) {
|
||||
throw new RuntimeException('An add-on is booked at least once.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->book($subscription, $addonKey, $quantity, $order, $price);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// A concurrent retry won. The unique index on (order_id, addon_key)
|
||||
// is what actually enforces "one order books one module" — the
|
||||
// lookup below is only the fast path, and two transactions can both
|
||||
// pass it.
|
||||
return SubscriptionAddon::query()
|
||||
->where('order_id', $order?->id)
|
||||
->where('addon_key', $addonKey)
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): SubscriptionAddon
|
||||
{
|
||||
// The booking and its entry in the register commit together: if the
|
||||
// record could fail afterwards, retrying the same order would find the
|
||||
// add-on already there and skip the event for good.
|
||||
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price) {
|
||||
// Idempotent against a retried webhook: one order books one module.
|
||||
if ($order !== null) {
|
||||
$existing = SubscriptionAddon::query()
|
||||
->where('order_id', $order->id)
|
||||
->where('addon_key', $addonKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$addon = SubscriptionAddon::create([
|
||||
'subscription_id' => $subscription->id,
|
||||
'order_id' => $order?->id,
|
||||
'addon_key' => $addonKey,
|
||||
'price_cents' => $price,
|
||||
'currency' => Subscription::catalogueCurrency(),
|
||||
'quantity' => $quantity,
|
||||
'booked_at' => now(),
|
||||
]);
|
||||
|
||||
($this->record)(
|
||||
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
|
||||
subscription: $subscription,
|
||||
netCents: $addon->monthlyCents(),
|
||||
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]],
|
||||
order: $order,
|
||||
stripe: ['event' => $order?->stripe_event_id],
|
||||
// What was actually charged for the module, on the same terms
|
||||
// as a plan purchase: a discount or a free booking has to be
|
||||
// reconcilable, not reconstructed from the catalogue.
|
||||
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
|
||||
);
|
||||
|
||||
return $addon;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop charging for a module, without losing what it cost.
|
||||
*
|
||||
* Cancelled, not deleted: what a customer was paying, and until when, is
|
||||
* part of the same record as what they bought.
|
||||
*/
|
||||
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
|
||||
{
|
||||
return DB::transaction(function () use ($addon) {
|
||||
// Claim the cancellation conditionally, so two requests arriving
|
||||
// together write one event between them. Checking `isActive()` on
|
||||
// separate instances and updating afterwards lets both through, and
|
||||
// the register would show a module cancelled twice.
|
||||
$claimed = SubscriptionAddon::query()
|
||||
->whereKey($addon->getKey())
|
||||
->whereNull('cancelled_at')
|
||||
->update(['cancelled_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return $addon->refresh();
|
||||
}
|
||||
|
||||
$addon->refresh();
|
||||
|
||||
($this->record)(
|
||||
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
|
||||
subscription: $addon->subscription,
|
||||
netCents: -$addon->monthlyCents(),
|
||||
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
|
||||
order: $addon->order,
|
||||
);
|
||||
|
||||
return $addon;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionRecord;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Opens the contract a paid order bought.
|
||||
*
|
||||
* This is the moment the catalogue stops applying. Everything the customer is
|
||||
* owed — price, quotas, seats, the hardware behind the plan — is copied onto
|
||||
* the subscription here and never read from the catalogue again. Provisioning
|
||||
* sizes the machine from this row, so an operator editing a plan afterwards
|
||||
* cannot reach a customer who has already paid.
|
||||
*
|
||||
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
|
||||
* against — deliberately not `Order::amount_cents`, which holds the GROSS total
|
||||
* Stripe actually charged. The two can legitimately differ (VAT, and later
|
||||
* coupons), and reconciling them is not this action's job: the proof register
|
||||
* records what was charged per event, and Stripe's invoice is the authority for
|
||||
* the amount. Copying a gross total into this net field would silently corrupt
|
||||
* every pro-rata calculation that reads it.
|
||||
*/
|
||||
class OpenSubscription
|
||||
{
|
||||
public function __construct(private RecordCommercialEvent $record) {}
|
||||
|
||||
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
|
||||
{
|
||||
// A retried webhook must not open a second contract for one purchase.
|
||||
$existing = Subscription::query()->where('order_id', $order->id)->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$start = now();
|
||||
|
||||
// The contract and its entry in the register commit together. If the
|
||||
// record could fail after the subscription is in, the next webhook
|
||||
// retry would find the contract, return early, and leave a paid sale
|
||||
// permanently missing from the evidence.
|
||||
return DB::transaction(fn () => $this->open($order, $term, $start));
|
||||
}
|
||||
|
||||
private function open(Order $order, string $term, Carbon $start): Subscription
|
||||
{
|
||||
$subscription = Subscription::create(array_merge(
|
||||
// The version the order carries, when the checkout recorded one:
|
||||
// what the customer saw beats what happens to be on sale by the
|
||||
// time their payment reaches us.
|
||||
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
|
||||
[
|
||||
'customer_id' => $order->customer_id,
|
||||
'order_id' => $order->id,
|
||||
// Carried from the checkout: from here on, Stripe's events
|
||||
// name this and nothing else.
|
||||
'stripe_subscription_id' => $order->stripe_subscription_id,
|
||||
'started_at' => $start,
|
||||
'current_period_start' => $start,
|
||||
'current_period_end' => $term === Subscription::TERM_YEARLY
|
||||
? $start->copy()->addYear()
|
||||
: $start->copy()->addMonth(),
|
||||
'status' => 'active',
|
||||
],
|
||||
));
|
||||
|
||||
// The sale, entered in the register the moment it happens. Written from
|
||||
// the contract that was just frozen, so the evidence and the contract
|
||||
// cannot describe different purchases.
|
||||
($this->record)(
|
||||
event: SubscriptionRecord::EVENT_PURCHASE,
|
||||
subscription: $subscription,
|
||||
netCents: $subscription->price_cents,
|
||||
at: $start,
|
||||
stripe: ['event' => $order->stripe_event_id],
|
||||
// The order carries what Stripe actually took — gross, including
|
||||
// whatever tax or discount applied on the day. The contract price
|
||||
// is what was agreed; this is what was paid, and the register has
|
||||
// to be able to state both.
|
||||
//
|
||||
// Keyed on the Stripe id, not on the amount being non-zero: a fully
|
||||
// discounted checkout charges zero, and reading that as "no amount
|
||||
// recorded" would file a free sale as though it had been paid for
|
||||
// in full. An order without a Stripe id was never charged through
|
||||
// a checkout at all, and has nothing to report.
|
||||
chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null,
|
||||
);
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
<?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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
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 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
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?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,7 +2,9 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The public price sheet.
|
||||
*
|
||||
* Prices, storage and seat counts are read from the catalogue rather than
|
||||
* written into the page. They were hard-coded here once, and the page and the
|
||||
* catalogue had already drifted apart on three of four plans — a visitor was
|
||||
* quoted 249 € for a plan that charged 399 € at checkout. The marketing site is
|
||||
* a reader of the catalogue like every other caller.
|
||||
*
|
||||
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
|
||||
* outage for commerce, not something to paper over. But a public website is not
|
||||
* commerce — a mistyped availability window must not take the company's front
|
||||
* page down with it. So the failure is caught HERE and only here: the page
|
||||
* still renders, the price sheet is replaced by "on request", and nobody is
|
||||
* shown a number the checkout would not honour.
|
||||
*/
|
||||
class LandingController extends Controller
|
||||
{
|
||||
/** Presentation that belongs to the marketing page, not to the catalogue. */
|
||||
private const COPY = [
|
||||
'start' => ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'],
|
||||
'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'],
|
||||
'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'],
|
||||
'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'],
|
||||
];
|
||||
|
||||
/** Catalogue feature keys, in the words a customer uses. */
|
||||
private const FEATURES = [
|
||||
'managed_updates' => 'Updates & Wartung',
|
||||
'daily_backups' => 'Tägliche Sicherung',
|
||||
'monitoring' => 'Überwachung rund um die Uhr',
|
||||
'subdomain' => 'Adresse auf clupilot.cloud',
|
||||
'custom_domain' => 'Eigene Domain',
|
||||
'office' => 'Office im Browser',
|
||||
'branding' => 'Ihr Logo & Ihre Farben',
|
||||
'priority_support' => 'Bevorzugter Support',
|
||||
'premium_sla' => 'Vereinbarte Reaktionszeiten',
|
||||
'extended_retention' => 'Verlängerte Aufbewahrung',
|
||||
'audit_log' => 'Protokollierung der Zugriffe',
|
||||
'onboarding' => 'Begleitete Einführung',
|
||||
];
|
||||
|
||||
/** The plan carrying the recommendation mark. */
|
||||
private const RECOMMENDED = 'team';
|
||||
|
||||
public function __invoke(): View
|
||||
{
|
||||
$plans = $this->plans();
|
||||
|
||||
return view('landing', [
|
||||
'plans' => $plans,
|
||||
'featureRows' => $this->featureRows($plans),
|
||||
'recommended' => self::RECOMMENDED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every feature any sellable plan carries, in the catalogue's narrative
|
||||
* order — the row headings of the price sheet.
|
||||
*
|
||||
* Built from what is on sale rather than from the full list, so a feature
|
||||
* no current plan offers does not appear as an empty row.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $plans
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function featureRows(array $plans): array
|
||||
{
|
||||
$offered = array_merge(...array_column($plans, 'features')) ?: [];
|
||||
|
||||
return array_values(array_filter(
|
||||
self::FEATURES,
|
||||
fn (string $label) => in_array($label, $offered, true),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
|
||||
*/
|
||||
private function plans(): array
|
||||
{
|
||||
try {
|
||||
$sellable = app(PlanCatalogue::class)->sellable();
|
||||
} catch (Throwable $e) {
|
||||
// Deliberately swallowed: see the class docblock. Logged at error
|
||||
// level because a shop that cannot list its plans is not selling.
|
||||
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$plans = [];
|
||||
|
||||
foreach ($sellable as $key => $plan) {
|
||||
$plans[] = [
|
||||
'key' => $key,
|
||||
'name' => $plan['name'],
|
||||
'audience' => self::COPY[$key]['audience'] ?? '',
|
||||
'note' => self::COPY[$key]['note'] ?? '',
|
||||
'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
|
||||
'storage' => $this->storage((int) $plan['quota_gb']),
|
||||
'traffic' => $this->storage((int) $plan['traffic_gb']),
|
||||
'seats' => (int) $plan['seats'],
|
||||
'features' => $this->features($plan['features'] ?? []),
|
||||
'recommended' => $key === self::RECOMMENDED,
|
||||
];
|
||||
}
|
||||
|
||||
return $plans;
|
||||
}
|
||||
|
||||
/** Currencies we have a symbol for; anything else prints its ISO code. */
|
||||
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
|
||||
|
||||
/**
|
||||
* A price as the sheet prints it, currency included.
|
||||
*
|
||||
* The symbol comes from the catalogue rather than from the template: the
|
||||
* currency is configurable (CLUPILOT_CURRENCY), and a page that says "€"
|
||||
* while the checkout charges francs is the same two-sources-of-truth
|
||||
* mistake as a hard-coded amount, only harder to notice.
|
||||
*
|
||||
* Whole units when the amount is whole — a price sheet reads "179", not
|
||||
* "179,00".
|
||||
*/
|
||||
private function money(int $cents, string $currency): string
|
||||
{
|
||||
$amount = $cents % 100 === 0
|
||||
? number_format($cents / 100, 0, ',', '.')
|
||||
: number_format($cents / 100, 2, ',', '.');
|
||||
|
||||
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
|
||||
// currency ends up on a line of its own under the number.
|
||||
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
|
||||
}
|
||||
|
||||
private function storage(int $gb): string
|
||||
{
|
||||
return $gb >= 1000 && $gb % 1000 === 0
|
||||
? ($gb / 1000).' TB'
|
||||
: $gb.' GB';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keys
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function features(array $keys): array
|
||||
{
|
||||
// Unknown keys are dropped rather than printed raw: a feature added to
|
||||
// the catalogue without a translation would otherwise appear on the
|
||||
// public page as "premium_sla".
|
||||
return array_values(array_filter(array_map(
|
||||
fn (string $key) => self::FEATURES[$key] ?? null,
|
||||
$keys,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Models\MonitoringTarget;
|
||||
use App\Models\ProvisioningRun;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* The public service status page.
|
||||
*
|
||||
* It lived at /legal/status, next to the imprint and the terms, which is a
|
||||
* filing mistake: nothing about the current health of the platform is a legal
|
||||
* document. It has its own address now.
|
||||
*
|
||||
* Every line on it is derived from a record. Where there is no signal the
|
||||
* component says "not monitored" rather than "operational" — a status page that
|
||||
* reports green because it has nothing to look at is worse than no status page,
|
||||
* because someone will believe it.
|
||||
*
|
||||
* Aggregate only: counts, never customer names, instance addresses or host
|
||||
* names. This page is world-readable and the estate is not public information.
|
||||
*/
|
||||
class StatusController extends Controller
|
||||
{
|
||||
/** A backup older than this stops counting as current. */
|
||||
private const BACKUP_STALE_AFTER_HOURS = 48;
|
||||
|
||||
/**
|
||||
* How old a monitoring verdict may be before it stops counting.
|
||||
*
|
||||
* The sync job runs every five minutes; four missed runs is a monitoring
|
||||
* pipeline that has stopped, and a verdict from then says nothing about now.
|
||||
*/
|
||||
private const MONITORING_STALE_AFTER_MINUTES = 20;
|
||||
|
||||
/** How far back a provisioning failure still says something about now. */
|
||||
private const PROVISIONING_WINDOW_HOURS = 24;
|
||||
|
||||
public function __invoke(): View
|
||||
{
|
||||
$components = [
|
||||
$this->portal(),
|
||||
$this->instances(),
|
||||
$this->provisioning(),
|
||||
$this->backups(),
|
||||
];
|
||||
|
||||
// The worst individual state is the state of the whole thing. Averaging
|
||||
// it would let one outage disappear behind three healthy components.
|
||||
$overall = match (true) {
|
||||
in_array('down', array_column($components, 'state'), true) => 'down',
|
||||
in_array('degraded', array_column($components, 'state'), true) => 'degraded',
|
||||
in_array('unknown', array_column($components, 'state'), true) => 'unknown',
|
||||
default => 'operational',
|
||||
};
|
||||
|
||||
return view('status', [
|
||||
'components' => $components,
|
||||
'overall' => $overall,
|
||||
'checkedAt' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal and the website.
|
||||
*
|
||||
* Answering this request is the measurement. There is no honest way for a
|
||||
* page to report that the server serving it is down.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function portal(): array
|
||||
{
|
||||
return [
|
||||
'key' => 'portal',
|
||||
'state' => 'operational',
|
||||
'detail' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer instances, from what monitoring last saw.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function instances(): array
|
||||
{
|
||||
$total = Instance::query()->where('status', 'active')->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
|
||||
}
|
||||
|
||||
// Only targets belonging to instances that are actually in service. A
|
||||
// healthy check on a decommissioned instance says nothing about a live
|
||||
// one, and counting it would let coverage look complete when it is not.
|
||||
$onActive = fn ($query) => $query->whereHas(
|
||||
'instance',
|
||||
fn ($instance) => $instance->where('status', 'active'),
|
||||
);
|
||||
|
||||
// Only verdicts the sync job has actually refreshed. A row that has
|
||||
// never been checked, or was last checked long enough ago that the
|
||||
// answer means nothing, is not evidence of health — and this column was
|
||||
// for a long time exactly that: written 'up' at provisioning and never
|
||||
// touched again.
|
||||
$fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
|
||||
$checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);
|
||||
|
||||
$watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
|
||||
$down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');
|
||||
|
||||
// Down first, then coverage. An instance nobody is watching is not a
|
||||
// healthy instance — it is an unanswered question, and reporting the
|
||||
// absence of a check as the absence of a problem is the one thing this
|
||||
// page must never do.
|
||||
return [
|
||||
'key' => 'instances',
|
||||
'state' => match (true) {
|
||||
$down > 0 && $down >= $watched => 'down',
|
||||
$down > 0 => 'degraded',
|
||||
$watched < $total => 'unknown',
|
||||
default => 'operational',
|
||||
},
|
||||
'detail' => match (true) {
|
||||
$down > 0 => ['down' => $down, 'total' => $watched],
|
||||
$watched < $total => ['down' => $total - $watched, 'total' => $total],
|
||||
default => null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether new instances are being delivered.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function provisioning(): array
|
||||
{
|
||||
$since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);
|
||||
|
||||
$failed = ProvisioningRun::query()
|
||||
->where('status', ProvisioningRun::STATUS_FAILED)
|
||||
->where('updated_at', '>=', $since)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => 'provisioning',
|
||||
'state' => $failed === 0 ? 'operational' : 'degraded',
|
||||
'detail' => $failed > 0 ? ['failed' => $failed] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the last backup of every instance is recent.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function backups(): array
|
||||
{
|
||||
// Counted from the INSTANCES that need protecting, not from the backup
|
||||
// rows that happen to exist. An active instance with no schedule at all
|
||||
// has no row — so counting rows would leave it out of the arithmetic
|
||||
// entirely and report the estate as protected because the backups that
|
||||
// do exist are fine.
|
||||
$total = Instance::query()->where('status', 'active')->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
|
||||
}
|
||||
|
||||
$fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);
|
||||
|
||||
$protected = Instance::query()
|
||||
->where('status', 'active')
|
||||
->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
|
||||
->count();
|
||||
|
||||
$unprotected = $total - $protected;
|
||||
|
||||
return [
|
||||
'key' => 'backups',
|
||||
'state' => match (true) {
|
||||
$unprotected === 0 => 'operational',
|
||||
$unprotected >= $total => 'down',
|
||||
default => 'degraded',
|
||||
},
|
||||
'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<?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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\PlanFamily;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.admin')]
|
||||
class Customers extends Component
|
||||
{
|
||||
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
|
||||
public function toggleSuspend(string $uuid): void
|
||||
{
|
||||
$this->authorize('customers.manage');
|
||||
$customer = Customer::query()->where('uuid', $uuid)->first();
|
||||
if ($customer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A closed account is terminal — the suspend/reactivate toggle must not
|
||||
// resurrect it to active while closed_at is still set.
|
||||
if ($customer->closed_at !== null || $customer->status === 'closed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$customer->update([
|
||||
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$plans = app(PlanCatalogue::class)->sellable();
|
||||
|
||||
$customers = Customer::query()
|
||||
->with(['instances.subscription'])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
|
||||
$instance = $c->instances->sortByDesc('id')->first();
|
||||
$planKey = $instance->plan ?? null;
|
||||
// What this customer actually pays, off their contract — not what
|
||||
// the plan costs today. Otherwise a price rise inflates reported
|
||||
// revenue for every grandfathered customer overnight.
|
||||
//
|
||||
// Per MONTH, whatever the term: a yearly contract stores the whole
|
||||
// year, and adding that to a monthly column would report twelve
|
||||
// times the revenue for anyone who paid up front.
|
||||
$contract = $instance?->subscription;
|
||||
$priceCents = (int) ($contract?->monthlyPriceCents()
|
||||
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
|
||||
|
||||
return [
|
||||
'uuid' => $c->uuid,
|
||||
'name' => $c->name,
|
||||
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
|
||||
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
|
||||
'instance' => $instance->subdomain ?? '—',
|
||||
// Only an instance that exists can hand out an admin login.
|
||||
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
|
||||
'closed' => $c->closed_at !== null || $c->status === 'closed',
|
||||
'suspended' => $c->status === 'suspended',
|
||||
'status' => match (true) {
|
||||
$c->closed_at !== null || $c->status === 'closed' => 'closed',
|
||||
$c->status === 'suspended' => 'suspended',
|
||||
default => $instance->status ?? $c->status ?? 'provisioning',
|
||||
},
|
||||
];
|
||||
})->all();
|
||||
|
||||
// Plan distribution for the doughnut — derived from live instances, and
|
||||
// keyed by what customers are actually ON, not by what is on sale. A
|
||||
// withdrawn plan still has customers, and dropping them from the chart
|
||||
// would quietly understate the estate.
|
||||
$labels = [];
|
||||
$counts = [];
|
||||
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
|
||||
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
|
||||
if ($n > 0) {
|
||||
$labels[] = __('billing.plan.'.$planKey);
|
||||
$counts[] = $n;
|
||||
}
|
||||
}
|
||||
|
||||
return view('livewire.admin.customers', [
|
||||
'rows' => $rows,
|
||||
'plansChart' => [
|
||||
'type' => 'doughnut',
|
||||
'data' => [
|
||||
'labels' => $labels,
|
||||
'datasets' => [[
|
||||
'data' => $counts,
|
||||
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
|
||||
'borderWidth' => 0,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'cutout' => '62%',
|
||||
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?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')
|
||||
->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
<?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\Secrets uses 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\PlanFamily;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* The plan lines we sell, and whether each is currently on sale.
|
||||
*
|
||||
* Deliberately shallow: a family is a name and a rank, and everything that can
|
||||
* be got wrong — capabilities, prices, windows — lives one level down on the
|
||||
* versions, where publishing makes it permanent.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Plans extends Component
|
||||
{
|
||||
#[Validate(['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'])]
|
||||
public string $key = '';
|
||||
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
||||
#[Validate('required|integer|min:0|max:255')]
|
||||
public int $tier = 1;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// The page itself, not only its buttons. Hiding the nav entry and
|
||||
// guarding the mutations still leaves the whole catalogue — prices,
|
||||
// drafts, unreleased plans — readable to any operator who types the URL.
|
||||
$this->authorize('plans.manage');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->authorize('plans.manage');
|
||||
|
||||
$this->key = strtolower(trim($this->key));
|
||||
|
||||
$data = $this->validate([
|
||||
// The key is permanent once created — orders, instances and every
|
||||
// contract snapshot store it as a string — so it has to be a plain
|
||||
// identifier, not something anyone will want to prettify later.
|
||||
'key' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'],
|
||||
'name' => 'required|string|max:255',
|
||||
'tier' => 'required|integer|min:0|max:255',
|
||||
]);
|
||||
|
||||
PlanFamily::create([
|
||||
'key' => $data['key'],
|
||||
'name' => $data['name'],
|
||||
'tier' => $data['tier'],
|
||||
// Nothing to sell until a version is published, so it starts able
|
||||
// to sell and simply has nothing available.
|
||||
'sales_enabled' => true,
|
||||
]);
|
||||
|
||||
$this->reset('key', 'name', 'tier');
|
||||
$this->tier = 1;
|
||||
$this->dispatch('notify', message: __('plans.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The kill switch: stop selling this plan now, without touching a window.
|
||||
*
|
||||
* Existing customers keep their contract and their machine — this only
|
||||
* decides whether anyone new can buy it.
|
||||
*/
|
||||
public function toggleSales(string $uuid): void
|
||||
{
|
||||
$this->authorize('plans.manage');
|
||||
|
||||
$family = PlanFamily::query()->where('uuid', $uuid)->first();
|
||||
$family?->update(['sales_enabled' => ! $family->sales_enabled]);
|
||||
|
||||
$this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$families = PlanFamily::query()
|
||||
->with(['versions.prices'])
|
||||
->withCount('versions')
|
||||
->orderBy('tier')
|
||||
->get()
|
||||
->map(fn (PlanFamily $family) => [
|
||||
'uuid' => $family->uuid,
|
||||
'key' => $family->key,
|
||||
'name' => $family->name,
|
||||
'tier' => $family->tier,
|
||||
'sales_enabled' => $family->sales_enabled,
|
||||
'versions_count' => $family->versions_count,
|
||||
'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)),
|
||||
// What is queued up next, so a scheduled launch is visible
|
||||
// before a customer discovers it.
|
||||
'next' => $family->versions
|
||||
->filter(fn ($v) => $v->isPublished() && $v->available_from->greaterThan($now))
|
||||
->sortBy('available_from')
|
||||
->first(),
|
||||
'drafts' => $family->versions->filter(fn ($v) => ! $v->isPublished())->count(),
|
||||
]);
|
||||
|
||||
return view('livewire.admin.plans', [
|
||||
'families' => $families,
|
||||
// The shop's own answer, so this page cannot disagree with what a
|
||||
// customer actually sees.
|
||||
'sellable' => array_keys(app(PlanCatalogue::class)->sellable()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionRecord;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Revenue, from the contracts and the commercial register.
|
||||
*
|
||||
* What was here before was a story: €7,842 MRR, 1.8 % churn, a twelve-month
|
||||
* curve, four payments from customers who do not exist. All of it is gone.
|
||||
*
|
||||
* Two figures that used to be shown are not shown any more, because there is
|
||||
* nothing to compute them from and an estimate would be indistinguishable from
|
||||
* a measurement:
|
||||
*
|
||||
* - the MRR trend, which needs a monthly revenue history nobody records;
|
||||
* - churn, which needs cancellations over a period, and cancelled_at alone
|
||||
* does not say what the base was.
|
||||
*
|
||||
* ARR is shown, but only as what it is: this month's recurring revenue times
|
||||
* twelve, labelled as a projection rather than as measured turnover.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Revenue extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$totals = $this->recurringTotals();
|
||||
|
||||
return view('livewire.admin.revenue', [
|
||||
'kpis' => [
|
||||
[
|
||||
'label' => __('admin.rev.mrr'),
|
||||
'value' => $this->format($totals, fn (array $t) => $t['cents'] / 100, $locale),
|
||||
'sub' => __('admin.rev.mrr_sub'),
|
||||
],
|
||||
[
|
||||
'label' => __('admin.rev.arr'),
|
||||
'value' => $this->format($totals, fn (array $t) => $t['cents'] * 12 / 100, $locale),
|
||||
'sub' => __('admin.rev.arr_sub'),
|
||||
],
|
||||
[
|
||||
'label' => __('admin.rev.arpu'),
|
||||
'value' => $this->format(
|
||||
$totals,
|
||||
fn (array $t) => count($t['customers']) > 0 ? $t['cents'] / count($t['customers']) / 100 : 0,
|
||||
$locale,
|
||||
),
|
||||
'sub' => __('admin.rev.arpu_sub'),
|
||||
],
|
||||
[
|
||||
'label' => __('admin.rev.contracts'),
|
||||
'value' => (string) array_sum(array_column($totals, 'contracts')),
|
||||
'sub' => __('admin.rev.contracts_sub'),
|
||||
],
|
||||
],
|
||||
'planCharts' => $this->planCharts(),
|
||||
'payments' => $this->recentPayments($locale),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurring revenue per currency, off the contracts.
|
||||
*
|
||||
* Never summed across currencies. Frozen contract prices, not the
|
||||
* catalogue, so a price rise does not retroactively inflate what
|
||||
* grandfathered customers are reported to pay. Add-ons included and yearly
|
||||
* terms divided — totalMonthlyCents() owns both of those rules.
|
||||
*
|
||||
* @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
|
||||
*/
|
||||
private function recurringTotals(): array
|
||||
{
|
||||
$totals = [];
|
||||
|
||||
Subscription::query()
|
||||
->where('status', 'active')
|
||||
->with('addons')
|
||||
->chunkById(200, function ($subscriptions) use (&$totals) {
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$currency = strtoupper((string) $subscription->currency);
|
||||
$totals[$currency] ??= ['cents' => 0, 'customers' => [], 'contracts' => 0];
|
||||
$totals[$currency]['cents'] += $subscription->totalMonthlyCents();
|
||||
$totals[$currency]['contracts']++;
|
||||
// A customer with two contracts is one customer — ARPU
|
||||
// divided by contracts would understate it.
|
||||
$totals[$currency]['customers'][(int) $subscription->customer_id] = true;
|
||||
}
|
||||
});
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{cents:int, customers:array<int,bool>, contracts:int}> $totals
|
||||
* @param callable(array{cents:int, customers:array<int,bool>, contracts:int}): float $amount
|
||||
*/
|
||||
private function format(array $totals, callable $amount, string $locale): string
|
||||
{
|
||||
if ($totals === []) {
|
||||
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
|
||||
}
|
||||
|
||||
return collect($totals)
|
||||
->map(fn (array $t, string $currency) => Number::currency($amount($t), in: $currency, locale: $locale))
|
||||
->implode(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurring revenue split by plan — one chart per currency.
|
||||
*
|
||||
* By what customers are ON, not by what is on sale: a withdrawn plan still
|
||||
* bills, and leaving it out would understate the total the chart sits next
|
||||
* to.
|
||||
*
|
||||
* Per currency, because a doughnut adds its slices together. Two currencies
|
||||
* in one ring produces a total that is not an amount of anything, and it
|
||||
* looks exactly as convincing as a correct one.
|
||||
*
|
||||
* @return array<int, array{currency:string, config:array<string,mixed>}>
|
||||
*/
|
||||
private function planCharts(): array
|
||||
{
|
||||
$byCurrency = [];
|
||||
|
||||
Subscription::query()
|
||||
->where('status', 'active')
|
||||
->with('addons')
|
||||
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$currency = strtoupper((string) $subscription->currency);
|
||||
$plan = (string) $subscription->plan;
|
||||
$byCurrency[$currency][$plan] = ($byCurrency[$currency][$plan] ?? 0)
|
||||
+ $subscription->totalMonthlyCents();
|
||||
}
|
||||
});
|
||||
|
||||
$charts = [];
|
||||
|
||||
foreach ($byCurrency as $currency => $byPlan) {
|
||||
arsort($byPlan);
|
||||
|
||||
$charts[] = [
|
||||
'currency' => $currency,
|
||||
'config' => [
|
||||
'type' => 'doughnut',
|
||||
'data' => [
|
||||
'labels' => array_map(fn (string $key) => __('billing.plan.'.$key), array_keys($byPlan)),
|
||||
'datasets' => [[
|
||||
'data' => array_map(fn (int $cents) => round($cents / 100, 2), array_values($byPlan)),
|
||||
'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:warning'],
|
||||
'borderWidth' => 0,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'cutout' => '62%',
|
||||
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $charts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last payments actually recorded.
|
||||
*
|
||||
* Both kinds count: an ordinary billing cycle is written as `renewal`, and
|
||||
* `invoice_paid` is reserved for everything else Stripe charges for — a
|
||||
* proration, a manual invoice. Listing only the second would show a
|
||||
* payments panel with no ordinary payments in it.
|
||||
*
|
||||
* Gross, because that is what left the customer's account. The register
|
||||
* holds net and tax separately for the books.
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function recentPayments(string $locale): array
|
||||
{
|
||||
return SubscriptionRecord::query()
|
||||
->whereIn('event', [SubscriptionRecord::EVENT_RENEWAL, SubscriptionRecord::EVENT_INVOICE_PAID])
|
||||
->orderByDesc('occurred_at')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn (SubscriptionRecord $r) => [
|
||||
'customer' => $r->customer_name ?: '—',
|
||||
'amount' => Number::currency(
|
||||
(int) ($r->gross_cents ?: $r->net_cents) / 100,
|
||||
in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())),
|
||||
locale: $locale,
|
||||
),
|
||||
'when' => $r->occurred_at?->local()->translatedFormat('d. MMM') ?? '—',
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Livewire\Concerns\ConfirmsPassword;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Stripe\StripeCheck;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Credentials, changeable from the console instead of over SSH.
|
||||
*
|
||||
* Two gates, not one. The capability decides who may open the page at all; the
|
||||
* password decides whether this SESSION may see or change anything. The second
|
||||
* exists because the realistic threat is not a stranger — it is an unlocked
|
||||
* machine, and a signed-in session is exactly what that gives away.
|
||||
*
|
||||
* Both are enforced on every action, server-side. A Livewire action is
|
||||
* reachable by anyone who can post to /livewire/update, and the buttons not
|
||||
* being on screen has never stopped anybody.
|
||||
*
|
||||
* The value being entered lives in a public property only while it is being
|
||||
* typed, and is cleared the moment it is stored — a Livewire property travels
|
||||
* to the browser and back in the component snapshot.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Secrets extends Component
|
||||
{
|
||||
use ConfirmsPassword;
|
||||
|
||||
/**
|
||||
* The new value being entered, keyed by a DOTLESS form key.
|
||||
*
|
||||
* Livewire reads a dot in a property path as nesting, so binding to
|
||||
* `entered.stripe.secret` writes `entered['stripe']['secret']` and the
|
||||
* value never arrives where the save looks for it. The registry keys keep
|
||||
* their dots; the form does not.
|
||||
*/
|
||||
public array $entered = [];
|
||||
|
||||
/** Result of the last connection test, for display only. */
|
||||
public ?array $check = null;
|
||||
|
||||
protected function confirmationGuard(): string
|
||||
{
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
}
|
||||
|
||||
public function save(string $key): void
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
// Out of the component state as soon as it is stored.
|
||||
$this->entered[$field] = '';
|
||||
$this->check = null;
|
||||
$this->dispatch('notify', message: __('secrets.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The save button opens ConfirmSaveSecret instead of calling save()
|
||||
* directly (R23) — that modal cannot see `entered` itself, so its confirm
|
||||
* button dispatches back here rather than mutating anything on its own.
|
||||
*/
|
||||
#[On('secret-save-confirmed')]
|
||||
public function onSaveConfirmed(string $key): void
|
||||
{
|
||||
$this->save($key);
|
||||
}
|
||||
|
||||
public function forget(string $key): void
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
app(SecretVault::class)->forget($key);
|
||||
$this->check = null;
|
||||
$this->dispatch('notify', message: __('secrets.removed'));
|
||||
}
|
||||
|
||||
/** See onSaveConfirmed() — same reasoning, for ConfirmForgetSecret. */
|
||||
#[On('secret-forget-confirmed')]
|
||||
public function onForgetConfirmed(string $key): void
|
||||
{
|
||||
$this->forget($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try the key that is in force — or the one being typed, before storing it.
|
||||
*
|
||||
* Checking the candidate first is the point: a key that is saved and wrong
|
||||
* fails later, somewhere else, usually in front of a customer.
|
||||
*/
|
||||
public function test(string $key): void
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
// The checker named by THIS entry, not a fixed one. When the area held a
|
||||
// single Stripe key a hard-coded StripeCheck was the same thing; with
|
||||
// four entries it would have reported on Stripe while the operator was
|
||||
// looking at the DNS token.
|
||||
$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. */
|
||||
public static function field(string $key): string
|
||||
{
|
||||
return str_replace('.', '_', $key);
|
||||
}
|
||||
|
||||
/** Capability AND a recent password, on every single action. */
|
||||
private function guard(): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$vault = app(SecretVault::class);
|
||||
$unlocked = $this->passwordRecentlyConfirmed();
|
||||
|
||||
return view('livewire.admin.secrets', [
|
||||
'unlocked' => $unlocked,
|
||||
'usable' => $vault->isUsable(),
|
||||
'entries' => collect(SecretVault::REGISTRY)
|
||||
->map(fn (array $meta, string $key) => [
|
||||
'key' => $key,
|
||||
'field' => self::field($key),
|
||||
'label' => __($meta['label']),
|
||||
// Only where a checker exists. A test button that cannot
|
||||
// actually test is a promise the page does not keep.
|
||||
'testable' => isset($meta['check']),
|
||||
'source' => $vault->source($key),
|
||||
// Only ever an outline, and only once unlocked.
|
||||
'outline' => $unlocked ? $vault->outline($key) : null,
|
||||
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Http\Middleware\RestrictConsoleNetwork;
|
||||
use App\Livewire\Concerns\ChangesOwnPassword;
|
||||
use App\Livewire\Concerns\ResolvesOperator;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Operator;
|
||||
use App\Models\VpnPeer;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use App\Support\Settings as AppSettings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Operator settings: the signed-in operator's own account plus (Owner-only)
|
||||
* staff management. All staff mutations are capability-gated server-side and
|
||||
* protect the last-Owner and self-role invariants transactionally.
|
||||
*
|
||||
* Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup — see
|
||||
* RequireOperatorTwoFactor for why. Only the POLICY switch
|
||||
* (saveTwoFactorPolicy(), below) stays here: only someone who already
|
||||
* satisfies the policy should be the one changing it.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Settings extends Component
|
||||
{
|
||||
use ChangesOwnPassword;
|
||||
use ResolvesOperator;
|
||||
|
||||
/** This page changes the signed-in OPERATOR's password, never a portal one. */
|
||||
protected function passwordAccountGuard(): string
|
||||
{
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
// My account
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
||||
#[Validate('required|email|max:255')]
|
||||
public string $email = '';
|
||||
|
||||
// Invite staff
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $staffName = '';
|
||||
|
||||
#[Validate('required|email|max:255')]
|
||||
public string $staffEmail = '';
|
||||
|
||||
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
|
||||
public string $staffRole = 'Support';
|
||||
|
||||
/** Shown once after inviting, since email delivery is still mocked. */
|
||||
public ?string $invitedEmail = null;
|
||||
|
||||
public ?string $invitedPassword = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->name = $operator->name;
|
||||
$this->email = $operator->email;
|
||||
$this->requireTwoFactor = AppSettings::bool('console.require_2fa', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the marketing site and the customer portal offline, or back online.
|
||||
* The console keeps working either way — otherwise this switch could only
|
||||
* ever be flipped once.
|
||||
*/
|
||||
public function toggleSiteVisibility(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
$public = ! AppSettings::bool('site.public', true);
|
||||
AppSettings::set('site.public', $public);
|
||||
|
||||
$this->dispatch('notify', message: $public
|
||||
? __('admin_settings.site_now_public')
|
||||
: __('admin_settings.site_now_hidden'));
|
||||
}
|
||||
|
||||
public function saveAccount(): void
|
||||
{
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $this->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255|unique:operators,email,'.$operator->id,
|
||||
]);
|
||||
|
||||
// An operator email must never collide with a customer's — that would
|
||||
// block the customer from ever obtaining a portal login (ensureUser).
|
||||
// Checked directly against `users`, not only `customers`: see
|
||||
// Customer::emailTaken().
|
||||
if (Customer::emailTaken($data['email'])) {
|
||||
$this->addError('email', __('admin_settings.is_customer'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operator->update($data);
|
||||
$this->dispatch('notify', message: __('admin_settings.account_saved'));
|
||||
}
|
||||
|
||||
public function inviteStaff(): void
|
||||
{
|
||||
$this->authorize('staff.manage');
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $this->validate([
|
||||
'staffName' => 'required|string|max:255',
|
||||
'staffEmail' => 'required|email|max:255|unique:operators,email',
|
||||
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
|
||||
]);
|
||||
|
||||
// Only an Owner may create another Owner.
|
||||
if ($data['staffRole'] === 'Owner' && ! $operator->hasRole('Owner')) {
|
||||
$this->addError('staffRole', __('admin_settings.owner_only'));
|
||||
|
||||
return;
|
||||
}
|
||||
// Never turn a customer's portal login into an operator.
|
||||
if (Customer::emailTaken($data['staffEmail'])) {
|
||||
$this->addError('staffEmail', __('admin_settings.is_customer'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Email/password-setup delivery is still mocked, so generate a temporary
|
||||
// password and surface it once to the Owner to share securely — the
|
||||
// account is usable immediately (a proper invite link follows with mail).
|
||||
$temp = Str::password(14);
|
||||
$newStaff = Operator::create([
|
||||
'name' => $data['staffName'],
|
||||
'email' => $data['staffEmail'],
|
||||
'password' => Hash::make($temp),
|
||||
]);
|
||||
$newStaff->assignRole($data['staffRole']);
|
||||
|
||||
$this->invitedEmail = $data['staffEmail'];
|
||||
$this->invitedPassword = $temp;
|
||||
$this->reset('staffName', 'staffEmail');
|
||||
$this->staffRole = 'Support';
|
||||
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
|
||||
}
|
||||
|
||||
public function setStaffRole(int $id, string $role): void
|
||||
{
|
||||
$this->authorize('staff.manage');
|
||||
if (! in_array($role, Operator::OPERATOR_ROLES, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($id, $role, $operator) {
|
||||
// Serialize on the Owner role so the global owner count can't be
|
||||
// raced to zero by concurrent demotions of different owners.
|
||||
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
|
||||
|
||||
$target = Operator::query()->whereKey($id)->lockForUpdate()->first();
|
||||
if ($target === null) {
|
||||
return 'gone';
|
||||
}
|
||||
if ($target->id === $operator->id) {
|
||||
return 'self';
|
||||
}
|
||||
// Only existing operators may be re-roled. The Customer check below
|
||||
// can no longer fire in practice — operators and customers are
|
||||
// different tables now, so a tampered id cannot resolve to one from
|
||||
// the other — but it costs nothing to leave as a second line.
|
||||
if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) {
|
||||
return 'not_staff';
|
||||
}
|
||||
// Granting or revoking Owner is Owner-only.
|
||||
if (($role === 'Owner' || $target->hasRole('Owner')) && ! $operator->hasRole('Owner')) {
|
||||
return 'owner_only';
|
||||
}
|
||||
// Never demote the last Owner.
|
||||
if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) {
|
||||
return 'last_owner';
|
||||
}
|
||||
$target->syncRoles([$role]);
|
||||
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
$this->flash($result);
|
||||
}
|
||||
|
||||
public function revokeStaff(int $id): void
|
||||
{
|
||||
$this->authorize('staff.manage');
|
||||
|
||||
$revokedPeers = [];
|
||||
|
||||
$result = DB::transaction(function () use ($id, &$revokedPeers) {
|
||||
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
|
||||
|
||||
$target = Operator::query()->whereKey($id)->lockForUpdate()->first();
|
||||
if ($target === null || ! $target->isOperator()) {
|
||||
return 'gone';
|
||||
}
|
||||
if ($target->id === Auth::guard('operator')->id()) {
|
||||
return 'self';
|
||||
}
|
||||
if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
|
||||
return 'last_owner';
|
||||
}
|
||||
$target->syncRoles([]);
|
||||
|
||||
// Taking away the console but leaving the tunnel would be the worst
|
||||
// of both: no access to the panel, still a route into the
|
||||
// management network. Same revocation path as the VPN page uses.
|
||||
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
|
||||
$peer->purgeSecret();
|
||||
$peer->delete();
|
||||
$revokedPeers[] = $peer->public_key;
|
||||
}
|
||||
|
||||
return 'revoked';
|
||||
});
|
||||
|
||||
// Dispatched only once the rows are committed: a worker picking the job
|
||||
// up mid-transaction would still see the peer as active, leave it on the
|
||||
// hub, and never be asked again — a revoked colleague would keep their
|
||||
// tunnel until the next reconciliation happened to notice.
|
||||
foreach ($revokedPeers as $publicKey) {
|
||||
ApplyVpnPeer::dispatch($publicKey, null, false);
|
||||
}
|
||||
|
||||
$this->flash($result);
|
||||
}
|
||||
|
||||
private function flash(string $result): void
|
||||
{
|
||||
$msg = match ($result) {
|
||||
'ok' => __('admin_settings.role_updated'),
|
||||
'revoked' => __('admin_settings.staff_revoked'),
|
||||
'self' => __('admin_settings.not_self'),
|
||||
'owner_only' => __('admin_settings.owner_only'),
|
||||
'last_owner' => __('admin_settings.last_owner'),
|
||||
'not_staff' => __('admin_settings.not_staff'),
|
||||
default => null,
|
||||
};
|
||||
if ($msg !== null) {
|
||||
$this->dispatch('notify', message: $msg);
|
||||
}
|
||||
}
|
||||
|
||||
private function ownerCount(): int
|
||||
{
|
||||
return Operator::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
|
||||
}
|
||||
|
||||
/** A new entry for the console allowlist: a single address or a CIDR range. */
|
||||
public string $consoleIp = '';
|
||||
|
||||
/**
|
||||
* Add a network that may reach the console without the VPN.
|
||||
*
|
||||
* Validated as an address or CIDR before it is stored: a typo that silently
|
||||
* matches nothing is how someone locks themselves out while believing they
|
||||
* have not.
|
||||
*/
|
||||
public function addConsoleIp(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
$value = trim($this->consoleIp);
|
||||
|
||||
if (! RestrictConsoleNetwork::isNetwork($value)) {
|
||||
$this->addError('consoleIp', __('admin_settings.console_ip_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$list = (array) AppSettings::get('console.allowed_ips', []);
|
||||
|
||||
if (! in_array($value, $list, true)) {
|
||||
$list[] = $value;
|
||||
AppSettings::set('console.allowed_ips', array_values($list));
|
||||
}
|
||||
|
||||
$this->consoleIp = '';
|
||||
$this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one — unless it is the reason you can see this page.
|
||||
*
|
||||
* Refusing here rather than warning afterwards: by the time the page
|
||||
* reloads, the request that would show the warning has already been
|
||||
* rejected. The VPN is never in this list, so an operator on the VPN can
|
||||
* always clear it out.
|
||||
*/
|
||||
public function removeConsoleIp(string $value): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
$list = array_values(array_filter(
|
||||
(array) AppSettings::get('console.allowed_ips', []),
|
||||
fn ($entry) => $entry !== $value,
|
||||
));
|
||||
|
||||
$ip = (string) request()->ip();
|
||||
|
||||
if (RestrictConsoleNetwork::isRestricted()
|
||||
&& ! RestrictConsoleNetwork::wouldStillAllow($ip, $list)) {
|
||||
$this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
AppSettings::set('console.allowed_ips', $list);
|
||||
$this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the restriction on or off.
|
||||
*
|
||||
* Turning it ON is refused unless the address doing the switching is
|
||||
* already covered — otherwise the click that secures the console is also
|
||||
* the click that locks its owner out of it.
|
||||
*/
|
||||
public function toggleConsoleRestriction(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
$on = ! RestrictConsoleNetwork::isRestricted();
|
||||
|
||||
if ($on && ! RestrictConsoleNetwork::allows((string) request()->ip())) {
|
||||
$this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
AppSettings::set('console.network_restricted', $on);
|
||||
$this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked'));
|
||||
}
|
||||
|
||||
/** Whether two-factor is compulsory for every operator on the console. */
|
||||
public bool $requireTwoFactor = false;
|
||||
|
||||
/**
|
||||
* Make two-factor compulsory for every operator, or make it voluntary again.
|
||||
*
|
||||
* The lock-out guard, same shape as console.allowed_ips: the page that
|
||||
* could switch this back off sits behind the switch.
|
||||
*/
|
||||
public function saveTwoFactorPolicy(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) {
|
||||
$this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
AppSettings::set('console.require_2fa', $this->requireTwoFactor);
|
||||
$this->dispatch('notify', message: __('admin_settings.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host-side agent to update this installation — a real run.
|
||||
*
|
||||
* Deliberately a request rather than an action: see UpdateChannel. The
|
||||
* button cannot report success, because success means this container is
|
||||
* restarted out from under the response — so it reports that the update was
|
||||
* asked for, and the page shows the outcome once the agent has written it.
|
||||
*
|
||||
* The sibling of requestCheck() below: this one applies, downtime and all —
|
||||
* but only a released version above the one installed. There is no longer
|
||||
* such a thing as "update to whatever is on the branch".
|
||||
*/
|
||||
public function requestUpdate(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Checked here as well as in the markup. The button is disabled when
|
||||
// there is nothing released to install, and the agent refuses such a
|
||||
// request too — but a Livewire action is a public endpoint, and the one
|
||||
// place a rule may not live is only in the disabled attribute of a
|
||||
// button. Queueing a run for nothing costs a maintenance window.
|
||||
if (! app(UpdateChannel::class)->state()['available']) {
|
||||
$this->dispatch('notify', message: __('admin_settings.update_nothing_released'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$accepted = app(UpdateChannel::class)->request($operator->email);
|
||||
|
||||
$this->dispatch('notify', message: __($accepted
|
||||
? 'admin_settings.update_requested'
|
||||
: 'admin_settings.update_already_requested'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host-side agent to look for updates — never to apply one.
|
||||
*
|
||||
* For the operator who only wants an answer: no maintenance window, no
|
||||
* restart, just the next tick's reading reported back.
|
||||
*/
|
||||
public function requestCheck(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$accepted = app(UpdateChannel::class)->requestCheck($operator->email);
|
||||
|
||||
$this->dispatch('notify', message: __($accepted
|
||||
? 'admin_settings.update_check_requested'
|
||||
: 'admin_settings.update_already_requested'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$operator = $this->currentOperator();
|
||||
|
||||
$staff = Operator::query()
|
||||
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
|
||||
->with('roles')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Operator $u) => [
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'email' => $u->email,
|
||||
'role' => $u->roles->first()?->name ?? '—',
|
||||
'self' => $u->id === Auth::guard('operator')->id(),
|
||||
]);
|
||||
|
||||
return view('livewire.admin.settings', [
|
||||
'update' => app(UpdateChannel::class)->state(),
|
||||
'updateLog' => app(UpdateChannel::class)->lastLog(),
|
||||
'sitePublic' => AppSettings::bool('site.public', true),
|
||||
'canManageSite' => $operator?->can('site.manage') ?? false,
|
||||
// Shown so nobody has to guess why they still see the real site.
|
||||
'viewerOnVpn' => IpUtils::checkIp(
|
||||
(string) request()->ip(),
|
||||
(array) config('admin_access.trusted_ranges', []),
|
||||
),
|
||||
// Who may reach the console, and from where the viewer is asking —
|
||||
// shown so the consequence of a change is visible before it is made.
|
||||
'consoleRestricted' => RestrictConsoleNetwork::isRestricted(),
|
||||
'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
|
||||
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
|
||||
'viewerIp' => (string) request()->ip(),
|
||||
'staff' => $staff,
|
||||
'roles' => Operator::OPERATOR_ROLES,
|
||||
'canManageStaff' => $operator?->can('staff.manage') ?? false,
|
||||
'isOwner' => $operator?->hasRole('Owner') ?? false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
<?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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?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]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Billing extends Component
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
/**
|
||||
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
|
||||
* mocked for now — the order is created with status 'pending'.
|
||||
*/
|
||||
public function purchase(string $type, ?string $key = null): void
|
||||
{
|
||||
$customer = $this->requireCustomer();
|
||||
if ($customer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$instance = $customer->instances()->latest('id')->first();
|
||||
$currentPlan = $instance?->plan ?? 'start';
|
||||
$plans = app(PlanCatalogue::class)->sellable();
|
||||
$addons = (array) config('provisioning.addons');
|
||||
|
||||
[$plan, $amount, $addonKey] = match ($type) {
|
||||
'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
|
||||
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
|
||||
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
|
||||
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
|
||||
default => [null, 0, null],
|
||||
};
|
||||
|
||||
// Guard against invalid keys / non-upgrades.
|
||||
$valid = match ($type) {
|
||||
// By rank, matching PlanChange and the cards on the page. Comparing
|
||||
// prices would call a grandfathered plan an upgrade to a smaller
|
||||
// one and charge for the privilege.
|
||||
'upgrade' => isset($plans[$key])
|
||||
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
|
||||
// Re-checked here and not only in the view: the button can be
|
||||
// hidden and the action still called — a stale tab, a second
|
||||
// window, anyone with the component name. A limit enforced only in
|
||||
// markup is not enforced.
|
||||
'downgrade' => isset($plans[$key])
|
||||
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
|
||||
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
|
||||
'storage' => true,
|
||||
// Always available: running out of traffic is exactly when someone
|
||||
// needs to be able to buy more, whatever plan they are on.
|
||||
'traffic' => true,
|
||||
'addon' => isset($addons[$key]),
|
||||
default => false,
|
||||
};
|
||||
if (! $valid || $plan === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$datacenter = $instance?->host?->datacenter
|
||||
?? $customer->orders()->latest('id')->value('datacenter')
|
||||
?? 'fsn';
|
||||
|
||||
// A cart cannot hold two plan changes: they contradict each other and no
|
||||
// checkout could resolve which one the customer meant. Choosing another
|
||||
// replaces the pending one — add-ons still stack.
|
||||
//
|
||||
// Replacement and insert run together with the customer row locked:
|
||||
// two clicks in flight could otherwise both delete and then both
|
||||
// insert, leaving exactly the two upgrades this rule exists to prevent.
|
||||
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
|
||||
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
|
||||
|
||||
// Up and down are the same kind of change and contradict each
|
||||
// other just as much, so either replaces a pending one of both.
|
||||
$replaced = in_array($type, ['upgrade', 'downgrade'], true)
|
||||
? Order::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->where('status', 'pending')
|
||||
->whereIn('type', ['upgrade', 'downgrade'])
|
||||
->delete()
|
||||
: 0;
|
||||
|
||||
Order::create([
|
||||
'customer_id' => $customer->id,
|
||||
'plan' => $plan,
|
||||
'type' => $type,
|
||||
'addon_key' => $addonKey,
|
||||
'amount_cents' => $amount,
|
||||
'currency' => 'EUR',
|
||||
'datacenter' => $datacenter,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return $replaced;
|
||||
});
|
||||
|
||||
if ($replaced > 0) {
|
||||
$this->dispatch('notify', message: __('billing.cart.plan_replaced'));
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('billing.purchased'));
|
||||
}
|
||||
|
||||
#[\Livewire\Attributes\On('order-removed')]
|
||||
public function orderRemoved(): void
|
||||
{
|
||||
$this->dispatch('notify', message: __('billing.cart.removed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The terms to show as "your plan": the contract if there is one, and only
|
||||
* otherwise the catalogue — someone browsing before they have bought.
|
||||
*
|
||||
* @param array<string, mixed> $catalogue
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function currentTerms(?Subscription $subscription, array $catalogue): array
|
||||
{
|
||||
if ($subscription === null) {
|
||||
return $catalogue;
|
||||
}
|
||||
|
||||
return [
|
||||
'tier' => $subscription->tier,
|
||||
// Per month: the card says "/ month", and a yearly contract stores
|
||||
// the whole year.
|
||||
'price_cents' => $subscription->monthlyPriceCents(),
|
||||
'currency' => $subscription->currency,
|
||||
'quota_gb' => $subscription->quota_gb,
|
||||
'traffic_gb' => $subscription->traffic_gb,
|
||||
'seats' => $subscription->seats,
|
||||
'performance' => $subscription->performance,
|
||||
'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$instance = $customer?->instances()->latest('id')->first();
|
||||
$plans = app(PlanCatalogue::class)->sellable();
|
||||
$currentKey = $instance?->plan ?? 'start';
|
||||
|
||||
// Rank, not price: a grandfathered plan can cost less than a smaller
|
||||
// one does today, and offering that as an "upgrade" would charge a
|
||||
// customer immediately for losing resources.
|
||||
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
|
||||
|
||||
// Smaller plans, each with the reason it cannot be taken today. Shown
|
||||
// WITH the reason rather than hidden: a customer who cannot downgrade
|
||||
// needs to know what to delete, and a plan that silently disappears
|
||||
// reads as "not offered any more".
|
||||
$downgrades = collect($plans)
|
||||
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
|
||||
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
|
||||
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
|
||||
->all();
|
||||
|
||||
$upgrades = collect($plans)
|
||||
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
|
||||
->keys()->all();
|
||||
|
||||
return view('livewire.billing', [
|
||||
'currentKey' => $currentKey,
|
||||
// What they HAVE comes from their contract; only what they could
|
||||
// BUY comes from the shop. Reading this off the catalogue showed a
|
||||
// customer today's price as though it were theirs, and dropped the
|
||||
// card entirely once their plan stopped being sold.
|
||||
'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
|
||||
'instance' => $instance,
|
||||
'plans' => $plans,
|
||||
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
|
||||
'upgrades' => $upgrades,
|
||||
'downgrades' => $downgrades,
|
||||
'storage' => (array) config('provisioning.storage_addon'),
|
||||
'trafficAddon' => (array) config('provisioning.traffic.addon'),
|
||||
// Resolved once: the cart and the plan cards must not state
|
||||
// different tax treatments on the same page.
|
||||
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
|
||||
'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
|
||||
// Booked modules at the price they were booked at; everything else
|
||||
// at today's. Reading them all off the catalogue would re-price
|
||||
// half a customer's bill behind their back.
|
||||
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
|
||||
->except(AddonCatalogue::STORAGE)
|
||||
->all(),
|
||||
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
|
||||
'pending' => $customer
|
||||
? $customer->orders()->where('status', 'pending')->latest('id')->get()
|
||||
: collect(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Cloud extends Component
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public function render()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$growth = [180, 188, 195, 199, 204, 210, 214, 219, 223, 228, 231, 235];
|
||||
|
||||
// The card is built from the customer's real service instance, so the
|
||||
// maintenance badge below can be scoped to exactly that instance's host.
|
||||
$customer = $this->customer();
|
||||
$shown = $customer?->instances()
|
||||
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
|
||||
->latest('id')->first();
|
||||
$maintenance = MaintenanceWindow::forInstance($shown)->first();
|
||||
|
||||
// The instance carries what was actually provisioned, and the contract
|
||||
// what was bought. Neither is the catalogue, which only describes what
|
||||
// we would sell someone new — never what this customer already has.
|
||||
$contract = $shown?->subscription;
|
||||
$planKey = $shown?->plan ?? 'team';
|
||||
$quota = (int) ($shown?->quota_gb ?? $contract?->quota_gb ?? 500);
|
||||
// Usage metering is not wired yet — scale the illustrative curve into the
|
||||
// instance's quota so the chart and the "x / y GB" label never disagree.
|
||||
$curveMax = max($growth);
|
||||
if ($curveMax > $quota) {
|
||||
$growth = array_map(fn ($v) => (int) round($v / $curveMax * $quota), $growth);
|
||||
}
|
||||
$used = $growth[count($growth) - 1];
|
||||
$domain = $shown?->custom_domain
|
||||
?: ($shown?->subdomain ? $shown->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') : 'cloud.example.com');
|
||||
|
||||
return view('livewire.cloud', [
|
||||
'instance' => [
|
||||
'name' => $customer ? __('cloud.instance_name', ['name' => $customer->name]) : __('cloud.title'),
|
||||
'domain' => $domain,
|
||||
'status' => $shown->status ?? 'active',
|
||||
'plan' => __('cloud.plan_line', [
|
||||
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
|
||||
// The line ends in "/mo", so a yearly contract has to be
|
||||
// divided down before it goes in.
|
||||
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
|
||||
]),
|
||||
'location' => __('cloud.datacenter'),
|
||||
'storageUsed' => $used,
|
||||
'storageQuota' => $quota,
|
||||
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
|
||||
'performance' => __('billing.perf.'.($contract?->performance ?? 'standard')),
|
||||
],
|
||||
'storageChart' => [
|
||||
'type' => 'line',
|
||||
'data' => [
|
||||
'labels' => ['', '', '', '', '', '', '', '', '', '', '', __('cloud.now')],
|
||||
'datasets' => [[
|
||||
'label' => 'GB',
|
||||
'data' => $growth,
|
||||
'borderColor' => 'token:accent',
|
||||
'backgroundColor' => 'token:accent/0.10',
|
||||
'fill' => true,
|
||||
'tension' => 0.35,
|
||||
'pointRadius' => 0,
|
||||
'borderWidth' => 2,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'scales' => [
|
||||
'x' => ['grid' => ['display' => false]],
|
||||
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
|
||||
],
|
||||
'plugins' => ['legend' => ['display' => false]],
|
||||
],
|
||||
],
|
||||
'storageLabel' => Number::format($used, locale: $locale).' / '.Number::format($quota, locale: $locale).' GB',
|
||||
'maintenance' => $maintenance,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?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 ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\BuildsRunSteps;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Order;
|
||||
use App\Models\ProvisioningRun;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Embedded live provisioning progress for the logged-in customer's order.
|
||||
* Polls only itself so the dashboard's charts don't churn. Renders nothing once
|
||||
* provisioning has completed.
|
||||
*/
|
||||
class CustomerProvisioning extends Component
|
||||
{
|
||||
use BuildsRunSteps;
|
||||
|
||||
private function currentRun(): ?ProvisioningRun
|
||||
{
|
||||
$user = auth()->user();
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = Customer::query()->where('email', $user->email)->first();
|
||||
if ($customer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProvisioningRun::query()
|
||||
->where('subject_type', Order::class)
|
||||
->whereIn('subject_id', $customer->orders()->select('id'))
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$run = $this->currentRun();
|
||||
|
||||
// Show the card while provisioning is in flight OR terminally failed; but
|
||||
// only POLL while it can still change (terminal failed must not poll forever).
|
||||
$show = $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED;
|
||||
$polling = $run !== null && in_array($run->status, [
|
||||
ProvisioningRun::STATUS_PENDING,
|
||||
ProvisioningRun::STATUS_RUNNING,
|
||||
ProvisioningRun::STATUS_WAITING,
|
||||
], true);
|
||||
|
||||
return view('livewire.customer-provisioning', [
|
||||
'active' => $show,
|
||||
'polling' => $polling,
|
||||
'failed' => $run?->status === ProvisioningRun::STATUS_FAILED,
|
||||
'steps' => $show ? $this->buildRunSteps($run) : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Models\Seat;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Traffic\TrafficMeter;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* The customer's Betriebsblatt: what they have, and the evidence that it is
|
||||
* being looked after.
|
||||
*
|
||||
* Every figure on this page comes from a record. Where something is not
|
||||
* measured yet — storage consumption, restore tests — the page says nothing
|
||||
* rather than showing a plausible number: this is the sheet a customer forwards
|
||||
* to their auditor, and one invented line on it is worse than a short register.
|
||||
*/
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Dashboard extends Component
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public function render()
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
// The instance that is actually in service. A cancelled one still has
|
||||
// rows, and would otherwise keep filling this page after it is gone.
|
||||
$instance = $customer?->instances()
|
||||
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$contract = $instance?->subscription;
|
||||
$maintenance = MaintenanceWindow::forInstance($instance)->first();
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'customer' => $customer,
|
||||
'instance' => $instance,
|
||||
'contract' => $contract,
|
||||
'domain' => $this->domain($instance),
|
||||
'location' => $this->location($instance),
|
||||
'seats' => $this->seats($customer, $contract),
|
||||
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
|
||||
// The measured series behind the template's ring and trend. Null
|
||||
// where nothing has been sampled yet — a new instance has no
|
||||
// fortnight, and drawing one at zero would tell its owner their
|
||||
// data had vanished.
|
||||
'disk' => $instance !== null ? $this->disk($instance) : null,
|
||||
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
|
||||
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
|
||||
'seatBreakdown' => $this->seatBreakdown($customer),
|
||||
'trend' => $instance !== null
|
||||
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
|
||||
: [],
|
||||
'proofs' => $this->proofs($instance, $maintenance),
|
||||
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
|
||||
// What the plan costs is true whether or not another invoice is
|
||||
// coming — a customer who has given notice still pays until the
|
||||
// term ends and still needs the figure on their master record.
|
||||
'planPrice' => $contract === null ? null : [
|
||||
'cents' => (int) $contract->price_cents,
|
||||
'currency' => (string) ($contract->currency ?: 'EUR'),
|
||||
'term' => (string) $contract->term,
|
||||
],
|
||||
'nextInvoice' => $this->nextInvoice($contract, $instance),
|
||||
'asOf' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds the seats, so the card can say something true underneath the
|
||||
* figure rather than repeating it.
|
||||
*
|
||||
* @return array<string, int> role => count, roles with nobody omitted
|
||||
*/
|
||||
private function seatBreakdown(?Customer $customer): array
|
||||
{
|
||||
if ($customer === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Seat::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->selectRaw('role, COUNT(*) as n')
|
||||
->groupBy('role')
|
||||
->pluck('n', 'role')
|
||||
->filter()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* How full the instance is, as measured — not as sold.
|
||||
*
|
||||
* Null until the sampler has managed a reading. The card then states the
|
||||
* contractual allowance, which is true, rather than a ring at a level
|
||||
* nobody measured.
|
||||
*
|
||||
* @return array{used: int, total: int, percent: float, week_delta: int|null}|null
|
||||
*/
|
||||
private function disk(Instance $instance): ?array
|
||||
{
|
||||
$metric = InstanceMetric::latestDisk($instance);
|
||||
|
||||
if ($metric === null || ! $metric->disk_total_bytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// What it grew by this week, which is the line the template carries
|
||||
// under the ring. Null when there is no reading a week back to compare
|
||||
// against — an instance three days old has no weekly trend, and
|
||||
// inventing "+0 GB" would read as "nothing happened".
|
||||
$weekAgo = InstanceMetric::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->whereNotNull('disk_used_bytes')
|
||||
->where('day', '<=', now()->subDays(7)->toDateString())
|
||||
->orderByDesc('day')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'used' => $metric->disk_used_bytes,
|
||||
'total' => $metric->disk_total_bytes,
|
||||
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
|
||||
'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** The address the customer actually reaches their cloud at. */
|
||||
private function domain(?Instance $instance): ?string
|
||||
{
|
||||
if ($instance === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $instance->custom_domain
|
||||
?: ($instance->subdomain
|
||||
? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com')
|
||||
: null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the data physically sits.
|
||||
*
|
||||
* Named from the datacenter register rather than from a translation string,
|
||||
* because this is the line a customer copies into a processing record — it
|
||||
* has to be the place their instance really runs on, not the place we
|
||||
* usually sell.
|
||||
*
|
||||
* @return array{name: string, note: null}|null
|
||||
*/
|
||||
private function location(?Instance $instance): ?array
|
||||
{
|
||||
$code = $instance?->host?->datacenter;
|
||||
|
||||
if ($code === null || $code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$datacenter = Datacenter::query()->where('code', $code)->first();
|
||||
|
||||
// The country, not the site. "Falkenstein" is how an operator places an
|
||||
// instance; a customer's processing record says which jurisdiction the
|
||||
// data sits in, and naming the building means editing customer-facing
|
||||
// copy every time the estate grows.
|
||||
return [
|
||||
'name' => $datacenter?->location ?: $code,
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seats in use against seats bought.
|
||||
*
|
||||
* Invited seats count as used: the licence is committed the moment the
|
||||
* invitation goes out, and showing them as free is how a customer finds out
|
||||
* at the worst moment that they cannot add the person in front of them.
|
||||
*
|
||||
* @return array{used: int, total: int|null}
|
||||
*/
|
||||
private function seats(?Customer $customer, ?Subscription $contract): array
|
||||
{
|
||||
return [
|
||||
'used' => $customer === null ? 0 : Seat::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->count(),
|
||||
'total' => $contract?->seats,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The proof register — what was done for this customer, and when.
|
||||
*
|
||||
* Only entries backed by a record. A row here is something the customer can
|
||||
* be asked to evidence, so "we back up nightly" is not one; "the last backup
|
||||
* completed at 03:12 and reported ok" is.
|
||||
*
|
||||
* @return array<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
|
||||
*/
|
||||
private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array
|
||||
{
|
||||
if ($instance === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$proofs = [];
|
||||
|
||||
// A failing job outranks a succeeding one, whatever their dates.
|
||||
//
|
||||
// Ordering by last_ok_at alone hides exactly the case this register
|
||||
// exists for: last night's job failed and so has no success time at
|
||||
// all, an older row that DID succeed sorts above it, and the sheet
|
||||
// reports backups as fine. On the page a customer shows their auditor,
|
||||
// a problem has to beat a date.
|
||||
$backups = $instance->backups()->get();
|
||||
$backup = $backups->first(fn ($b) => $b->status !== 'ok')
|
||||
?? $backups->sortByDesc('last_ok_at')->first();
|
||||
|
||||
if ($backup !== null) {
|
||||
$proofs[] = [
|
||||
'key' => 'backup',
|
||||
'at' => $backup->last_ok_at,
|
||||
// The job's own verdict, not "there is a row, so it worked".
|
||||
'state' => $backup->status === 'ok' ? 'ok' : 'attention',
|
||||
'note' => $backup->schedule,
|
||||
];
|
||||
}
|
||||
|
||||
// A certificate that stopped renewing is the failure a customer meets as
|
||||
// "my browser says my own cloud is unsafe", so it is stated either way.
|
||||
$proofs[] = [
|
||||
'key' => 'certificate',
|
||||
'at' => null,
|
||||
'state' => $instance->cert_ok ? 'ok' : 'attention',
|
||||
'note' => null,
|
||||
];
|
||||
|
||||
if ($maintenance !== null) {
|
||||
$proofs[] = [
|
||||
'key' => 'maintenance',
|
||||
'at' => $maintenance->starts_at,
|
||||
'state' => 'planned',
|
||||
'note' => $maintenance->title,
|
||||
];
|
||||
}
|
||||
|
||||
if ($instance->service_ends_at !== null) {
|
||||
$proofs[] = [
|
||||
'key' => 'service_ends',
|
||||
'at' => $instance->service_ends_at,
|
||||
'state' => 'attention',
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Newest first, but an entry with no time of its own — the certificate —
|
||||
// stays at the top rather than sinking to the bottom of the register.
|
||||
usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX));
|
||||
|
||||
return $proofs;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is owed next, from the contract rather than from a price list.
|
||||
*
|
||||
* The plan and the modules are kept apart. A customer with modules booked
|
||||
* would otherwise read the plan's price as their bill and be short every
|
||||
* month — and putting the combined figure next to the word "Paket" on the
|
||||
* master record would be just as wrong in the other direction.
|
||||
*
|
||||
* Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the
|
||||
* plan's price_cents is the price of a whole term. On a yearly contract the
|
||||
* modules therefore multiply by twelve to land on the same invoice.
|
||||
*
|
||||
* Nothing at all once the customer has given notice. Cancelling leaves the
|
||||
* subscription `active` until the term runs out — that is deliberate, the
|
||||
* service keeps running — but `current_period_end` is then the day service
|
||||
* ENDS, not the day the next charge falls. Billing a customer, on the sheet
|
||||
* they check their invoices against, for a renewal that will never happen is
|
||||
* the worst single line this page could carry. The end date is still stated;
|
||||
* it is in the proof register as "contract ends".
|
||||
*
|
||||
* @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null
|
||||
*/
|
||||
private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array
|
||||
{
|
||||
if ($contract === null || $contract->status === 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$months = $contract->isYearly() ? 12 : 1;
|
||||
|
||||
$addons = $contract->addons
|
||||
->whereNull('cancelled_at')
|
||||
->sum(fn ($addon) => $addon->monthlyCents()) * $months;
|
||||
|
||||
return [
|
||||
'plan' => (int) $contract->price_cents,
|
||||
'addons' => (int) $addons,
|
||||
'total' => (int) $contract->price_cents + (int) $addons,
|
||||
'currency' => (string) ($contract->currency ?: 'EUR'),
|
||||
'due' => $contract->current_period_end,
|
||||
'term' => (string) $contract->term,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ConfirmsPassword;
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use App\Services\Secrets\SecretCipher;
|
||||
use App\Support\Settings;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Editing a mailbox, in a modal (R20).
|
||||
*
|
||||
* A modal is reachable WITHOUT the page's route middleware, so it authorises
|
||||
* itself and re-reads the record instead of trusting a property the browser
|
||||
* hydrated. save() re-authorises independently of mount() for the same reason
|
||||
* Secrets::guard() checks on every action rather than once: a Livewire action
|
||||
* is reachable by anyone who can post to /livewire/update, and a mount that
|
||||
* succeeded earlier in the session proves nothing about the request now.
|
||||
*/
|
||||
class EditMailbox extends ModalComponent
|
||||
{
|
||||
use ConfirmsPassword;
|
||||
|
||||
public string $uuid = '';
|
||||
|
||||
public string $address = '';
|
||||
|
||||
public string $displayName = '';
|
||||
|
||||
public string $username = '';
|
||||
|
||||
/** Always starts empty: a stored password never travels to the browser. */
|
||||
public string $password = '';
|
||||
|
||||
public bool $noReply = false;
|
||||
|
||||
public bool $active = true;
|
||||
|
||||
/** Whether this mailbox logs in before it sends — Codex R15#4, P1b. */
|
||||
public bool $authenticates = true;
|
||||
|
||||
protected function confirmationGuard(): string
|
||||
{
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
$this->uuid = $box->uuid;
|
||||
$this->address = $box->address;
|
||||
$this->displayName = (string) $box->display_name;
|
||||
$this->username = (string) $box->username;
|
||||
$this->noReply = $box->no_reply;
|
||||
$this->active = $box->active;
|
||||
$this->authenticates = $box->authenticates;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$this->validate([
|
||||
'address' => ['required', 'email', 'max:255'],
|
||||
'displayName' => ['nullable', 'string', 'max:255'],
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail();
|
||||
|
||||
// Deactivating the mailbox "system" currently points at is not just
|
||||
// this purpose losing its sender: MailboxResolver::active() filters an
|
||||
// inactive mailbox at BOTH the direct lookup AND the system fallback,
|
||||
// so every OTHER purpose that has no mailbox of its own falls through
|
||||
// to nothing too. savePurposes() already refuses to leave system
|
||||
// empty; this closes the other way to reach the same broken state.
|
||||
if (! $this->active && $box->key === (string) Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM))) {
|
||||
$this->addError('active', __('mail_settings.cannot_deactivate_system'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Setting a new SMTP password is one of the two actions the
|
||||
// mail.manage split still leaves able to intercept mail outright (the
|
||||
// other is Admin\Mail::saveServer()) — gated the same second way
|
||||
// Admin\Secrets is: a capability decides who may open this modal at
|
||||
// all, a recent password decides whether THIS session may point the
|
||||
// outgoing account at new credentials.
|
||||
if ($this->password !== '') {
|
||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
||||
|
||||
// A password can only be STORED where SECRETS_KEY exists to
|
||||
// encrypt it under — the same condition the page's own banner
|
||||
// already names. Checked here rather than left for
|
||||
// Mailbox::setPasswordAttribute() to throw through: an uncaught
|
||||
// RuntimeException would render Laravel's debug page, whose
|
||||
// request-payload inspector can show the very password just
|
||||
// typed. The page must say so, not crash on it.
|
||||
if (! app(SecretCipher::class)->isUsable()) {
|
||||
$this->addError('password', __('mail_settings.no_key'));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$oldAddress = $box->address;
|
||||
$oldUsername = $box->username;
|
||||
$oldAuthenticates = $box->authenticates;
|
||||
|
||||
$box->address = $this->address;
|
||||
$box->display_name = $this->displayName ?: null;
|
||||
$box->username = $this->username ?: null;
|
||||
$box->no_reply = $this->noReply;
|
||||
$box->active = $this->active;
|
||||
$box->authenticates = $this->authenticates;
|
||||
|
||||
// Either one changes the identity smtpUsername() authenticates as:
|
||||
// username directly, or address as username's fallback when none is
|
||||
// set (including username being CLEARED back to that fallback).
|
||||
// authenticates itself is the fourth: whether that identity gets
|
||||
// sent at all. Leaving last_verified_at standing after any of the
|
||||
// three would keep showing a successful verification for a
|
||||
// connection nothing has actually tested since. Mailbox::
|
||||
// invalidateVerification() is the same clear Admin\Mail::saveServer()
|
||||
// uses for the server card, in its single-row shape — see that
|
||||
// method's own comment for why the two do NOT also share the "did
|
||||
// this actually change" comparison above this block.
|
||||
if ($box->address !== $oldAddress || $box->username !== $oldUsername || $box->authenticates !== $oldAuthenticates) {
|
||||
$box->invalidateVerification();
|
||||
}
|
||||
|
||||
// Empty means "leave it alone", not "delete it" — otherwise every edit
|
||||
// of an address would silently drop the password.
|
||||
if ($this->password !== '') {
|
||||
$box->password = $this->password;
|
||||
$box->invalidateVerification();
|
||||
}
|
||||
|
||||
$box->save();
|
||||
|
||||
$this->password = '';
|
||||
$this->dispatch('notify', message: __('mail_settings.saved'));
|
||||
$this->dispatch('mailbox-saved');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.edit-mailbox', [
|
||||
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\Seat;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Edit one seat, in a modal.
|
||||
*
|
||||
* Editing used to happen in the row itself. It worked and it looked wrong: the
|
||||
* row grew, the columns beside it jumped, and a table half in edit mode reads
|
||||
* as a rendering fault rather than as a form. Anything with fields of its own
|
||||
* gets a modal — see R20.
|
||||
*
|
||||
* A modal is reachable without passing the page's route middleware, so the
|
||||
* customer is resolved here rather than trusted from the caller: the uuid comes
|
||||
* from the browser and must never reach across customers.
|
||||
*/
|
||||
class EditSeat extends ModalComponent
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public string $uuid = '';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
/** Owners cannot be suspended or removed; everyone can be renamed. */
|
||||
public bool $isOwner = false;
|
||||
|
||||
/**
|
||||
* Whether the address may still be corrected.
|
||||
*
|
||||
* Only while the invitation is in flight. Once someone has accepted, the
|
||||
* address IS the person: editing it would hand one employee's access to
|
||||
* another with nobody told — a transfer of access wearing the clothes of a
|
||||
* rename.
|
||||
*/
|
||||
public bool $addressEditable = false;
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$seat = $this->seat($uuid);
|
||||
|
||||
abort_if($seat === null, 404);
|
||||
|
||||
$this->uuid = $uuid;
|
||||
$this->name = (string) $seat->name;
|
||||
$this->email = (string) $seat->email;
|
||||
$this->isOwner = $seat->role === 'owner';
|
||||
$this->addressEditable = $seat->status === 'invited';
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$customer = $this->requireCustomer();
|
||||
$seat = $this->seat($this->uuid);
|
||||
|
||||
if ($customer === null || $seat === null) {
|
||||
return $this->closeModal();
|
||||
}
|
||||
|
||||
$rules = ['name' => 'nullable|string|max:255'];
|
||||
|
||||
// Re-read from the record, never from the hydrated property: a forged
|
||||
// addressEditable would otherwise open the address of an accepted seat.
|
||||
if ($seat->status === 'invited') {
|
||||
$rules['email'] = 'required|email|max:255';
|
||||
}
|
||||
|
||||
$data = $this->validate($rules);
|
||||
|
||||
$changes = ['name' => trim($data['name'] ?? '') ?: null];
|
||||
|
||||
if ($seat->status === 'invited') {
|
||||
$address = trim($data['email']);
|
||||
|
||||
if ($address !== $seat->email) {
|
||||
if ($customer->seats()->where('email', $address)->whereKeyNot($seat->id)->exists()) {
|
||||
$this->addError('email', __('users.duplicate'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$changes['email'] = $address;
|
||||
}
|
||||
}
|
||||
|
||||
$seat->update($changes);
|
||||
|
||||
$this->dispatch('notify', message: __('users.saved'));
|
||||
|
||||
return $this->redirectRoute('users', navigate: true);
|
||||
}
|
||||
|
||||
private function seat(string $uuid): ?Seat
|
||||
{
|
||||
return $this->customer()?->seats()->where('uuid', $uuid)->first();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.edit-seat');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Invoices extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale);
|
||||
$date = fn (string $iso) => Carbon::parse($iso)->local()->locale($locale)->isoFormat('LL');
|
||||
|
||||
$months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01'])
|
||||
->map(fn ($m) => Carbon::parse($m)->local()->locale($locale)->isoFormat('MMM'))->all();
|
||||
|
||||
$rows = [
|
||||
['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'],
|
||||
['no' => 'CP-2026-0006', 'date' => $date('2026-06-01'), 'amount' => $eur(198), 'status' => 'paid'],
|
||||
['no' => 'CP-2026-0005', 'date' => $date('2026-05-01'), 'amount' => $eur(198), 'status' => 'paid'],
|
||||
['no' => 'CP-2026-0004', 'date' => $date('2026-04-01'), 'amount' => $eur(179), 'status' => 'paid'],
|
||||
['no' => 'CP-2026-0003', 'date' => $date('2026-03-15'), 'amount' => $eur(179), 'status' => 'paid'],
|
||||
];
|
||||
|
||||
return view('livewire.invoices', [
|
||||
'rows' => $rows,
|
||||
'nextCharge' => $date('2026-08-01'),
|
||||
'nextAmount' => $eur(198),
|
||||
'spendChart' => [
|
||||
'type' => 'line',
|
||||
'data' => [
|
||||
'labels' => $months,
|
||||
'datasets' => [[
|
||||
'label' => 'EUR',
|
||||
'data' => [179, 179, 198, 198, 198],
|
||||
'borderColor' => 'token:accent',
|
||||
'fill' => true,
|
||||
'tension' => 0.35,
|
||||
'pointRadius' => 0,
|
||||
'borderWidth' => 2,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'scales' => [
|
||||
'x' => ['grid' => ['display' => false]],
|
||||
'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']],
|
||||
],
|
||||
'plugins' => ['legend' => ['display' => false]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\SupportRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Raise a support request.
|
||||
*
|
||||
* The customer is asked for three things and nothing else: what it is about,
|
||||
* a subject, and the question. Everything an operator would otherwise have to
|
||||
* ask for — which customer, which instance, which plan — the system already
|
||||
* knows and attaches itself. Making somebody describe their own server back to
|
||||
* the people who built it is the part of support that annoys people most.
|
||||
*/
|
||||
class NewSupportRequest extends ModalComponent
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public string $category = 'technical';
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public function save()
|
||||
{
|
||||
$customer = $this->requireCustomer();
|
||||
|
||||
if ($customer === null) {
|
||||
return $this->closeModal();
|
||||
}
|
||||
|
||||
$data = $this->validate([
|
||||
'category' => ['required', Rule::in(SupportRequest::CATEGORIES)],
|
||||
'subject' => 'required|string|min:3|max:150',
|
||||
'body' => 'required|string|min:10|max:5000',
|
||||
]);
|
||||
|
||||
$instance = $customer->instances()
|
||||
->whereIn('status', ['active', 'cancellation_scheduled'])
|
||||
->latest('id')
|
||||
->first()
|
||||
?? $customer->instances()->latest('id')->first();
|
||||
|
||||
SupportRequest::create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance?->id,
|
||||
'subject' => $data['subject'],
|
||||
'category' => $data['category'],
|
||||
'body' => $data['body'],
|
||||
'status' => 'open',
|
||||
// The person, not the account: on a shared login the account name
|
||||
// says nothing about who is actually asking.
|
||||
'reported_by' => auth()->user()?->name ?: auth()->user()?->email,
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('support.sent'));
|
||||
|
||||
return $this->redirectRoute('support', navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.new-support-request', [
|
||||
'categories' => SupportRequest::CATEGORIES,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Settings extends Component
|
||||
{
|
||||
use \App\Livewire\Concerns\ChangesOwnPassword;
|
||||
use \App\Livewire\Concerns\ConfirmsPassword;
|
||||
|
||||
/** 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();
|
||||
|
||||
app(\Laravel\Fortify\Actions\EnableTwoFactorAuthentication::class)(auth()->user());
|
||||
}
|
||||
|
||||
/** Accept a code from the app, which is what actually turns it on. */
|
||||
public function confirmTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
try {
|
||||
app(\Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication::class)(
|
||||
auth()->user(),
|
||||
$this->twoFactorCode,
|
||||
);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('settings.twofa_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(auth()->user()->two_factor_recovery_codes), true);
|
||||
$this->dispatch('notify', message: __('settings.twofa_on'));
|
||||
}
|
||||
|
||||
public function regenerateRecoveryCodes(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(\Laravel\Fortify\Actions\GenerateNewRecoveryCodes::class)(auth()->user());
|
||||
$this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true);
|
||||
}
|
||||
|
||||
public function disableTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(\Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class)(auth()->user());
|
||||
$this->recoveryCodes = null;
|
||||
$this->dispatch('notify', message: __('settings.twofa_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('twofa-disable-confirmed')]
|
||||
public function onTwoFaDisableConfirmed(): 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);
|
||||
}
|
||||
|
||||
use ResolvesCustomer, WithFileUploads;
|
||||
|
||||
// Company / billing profile
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $companyName = '';
|
||||
|
||||
#[Validate('nullable|string|max:255')]
|
||||
public string $contactName = '';
|
||||
|
||||
#[Validate('nullable|string|max:64')]
|
||||
public string $phone = '';
|
||||
|
||||
#[Validate('nullable|string|max:64')]
|
||||
public string $vatId = '';
|
||||
|
||||
#[Validate('nullable|string|max:2000')]
|
||||
public string $billingAddress = '';
|
||||
|
||||
// Branding
|
||||
#[Validate('nullable|string|max:255')]
|
||||
public string $brandDisplayName = '';
|
||||
|
||||
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
|
||||
public string $brandPrimary = '';
|
||||
|
||||
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
|
||||
public string $brandAccent = '';
|
||||
|
||||
/** New logo upload (validated on save). */
|
||||
public $logo = null;
|
||||
|
||||
public ?string $brandLogoPath = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$c = $this->customer();
|
||||
if ($c === null) {
|
||||
return;
|
||||
}
|
||||
$this->companyName = $c->name ?? '';
|
||||
$this->contactName = $c->contact_name ?? '';
|
||||
$this->phone = $c->phone ?? '';
|
||||
$this->vatId = $c->vat_id ?? '';
|
||||
$this->billingAddress = $c->billing_address ?? '';
|
||||
$this->brandDisplayName = $c->brand_display_name ?? '';
|
||||
$this->brandPrimary = $c->brand_primary_color ?? '';
|
||||
$this->brandAccent = $c->brand_accent_color ?? '';
|
||||
$this->brandLogoPath = $c->brand_logo_path;
|
||||
}
|
||||
|
||||
public function saveProfile(): void
|
||||
{
|
||||
$c = $this->requireCustomer();
|
||||
if ($c === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateOnly('companyName');
|
||||
$data = $this->validate([
|
||||
'companyName' => 'required|string|max:255',
|
||||
'contactName' => 'nullable|string|max:255',
|
||||
'phone' => 'nullable|string|max:64',
|
||||
'vatId' => 'nullable|string|max:64',
|
||||
'billingAddress' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$c->update([
|
||||
'name' => $data['companyName'],
|
||||
'contact_name' => $data['contactName'] ?: null,
|
||||
'phone' => $data['phone'] ?: null,
|
||||
'vat_id' => $data['vatId'] ?: null,
|
||||
'billing_address' => $data['billingAddress'] ?: null,
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('settings.profile_saved'));
|
||||
}
|
||||
|
||||
public function saveBranding(): void
|
||||
{
|
||||
$c = $this->requireCustomer();
|
||||
if ($c === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'brandDisplayName' => 'nullable|string|max:255',
|
||||
'brandPrimary' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
'brandAccent' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
'logo' => 'nullable|image|mimes:png,webp|max:2048',
|
||||
]);
|
||||
|
||||
// Store the new upload, but keep the old file until the DB row that
|
||||
// references it is updated — delete the old one only after that commits,
|
||||
// so a failed update never orphans a file or dangles a reference.
|
||||
$oldToDelete = null;
|
||||
if ($this->logo !== null) {
|
||||
$oldToDelete = $this->brandLogoPath;
|
||||
$this->brandLogoPath = $this->logo->store('branding', 'public');
|
||||
$this->logo = null;
|
||||
}
|
||||
|
||||
$c->update([
|
||||
'brand_display_name' => $this->brandDisplayName ?: null,
|
||||
'brand_primary_color' => $this->brandPrimary ?: null,
|
||||
'brand_accent_color' => $this->brandAccent ?: null,
|
||||
'brand_logo_path' => $this->brandLogoPath,
|
||||
]);
|
||||
|
||||
if ($oldToDelete !== null && $oldToDelete !== $this->brandLogoPath) {
|
||||
Storage::disk('public')->delete($oldToDelete);
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('settings.branding_saved'));
|
||||
}
|
||||
|
||||
public function removeLogo(): void
|
||||
{
|
||||
$c = $this->requireCustomer();
|
||||
if ($c === null) {
|
||||
return;
|
||||
}
|
||||
if ($this->brandLogoPath !== null) {
|
||||
Storage::disk('public')->delete($this->brandLogoPath);
|
||||
}
|
||||
$this->brandLogoPath = null;
|
||||
$c->update(['brand_logo_path' => null]);
|
||||
$this->dispatch('notify', message: __('settings.branding_saved'));
|
||||
}
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
public function render()
|
||||
{
|
||||
$c = $this->customer();
|
||||
// Prefer the active/cancelling instance for the package section so the
|
||||
// controls line up with what cancellation actually targets.
|
||||
$active = $c?->instances()->where('status', 'active')->latest('id')->first();
|
||||
$scheduled = $c?->instances()->where('status', 'cancellation_scheduled')->latest('id')->first();
|
||||
$instance = $active ?? $scheduled ?? $c?->instances()->latest('id')->first();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
return view('livewire.settings', [
|
||||
// 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' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null,
|
||||
'twoFactorOn' => $user->two_factor_confirmed_at !== null,
|
||||
'twoFactorQr' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null
|
||||
? $user->twoFactorQrCodeSvg()
|
||||
: null,
|
||||
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
||||
'customer' => $c,
|
||||
'instance' => $instance,
|
||||
'branding' => $c?->brandingResolved(),
|
||||
'logoUrl' => $this->brandLogoPath ? Storage::disk('public')->url($this->brandLogoPath) : null,
|
||||
'hasActivePackage' => $active !== null,
|
||||
'cancellationScheduled' => $active === null && $scheduled !== null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\SupportRequest;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Support extends Component
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public function render()
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
$requests = $customer
|
||||
? SupportRequest::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
return view('livewire.support', [
|
||||
'requests' => $requests,
|
||||
'openCount' => $requests->filter(fn (SupportRequest $r) => $r->isOpen())->count(),
|
||||
// The answers link into the panel where the panel can actually do
|
||||
// the thing being asked about — an FAQ that only describes a button
|
||||
// makes the reader hunt for it.
|
||||
'faqs' => [
|
||||
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1'), 'to' => route('backups'), 'cta' => __('support.faq_to_backups')],
|
||||
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2'), 'to' => route('users'), 'cta' => __('support.faq_to_users')],
|
||||
['q' => __('support.faq_q3'), 'a' => __('support.faq_a3'), 'to' => null, 'cta' => null],
|
||||
['q' => __('support.faq_q4'), 'a' => __('support.faq_a4'), 'to' => route('billing'), 'cta' => __('support.faq_to_billing')],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Seat;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Users extends Component
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
#[Validate('required|email|max:255')]
|
||||
public string $inviteEmail = '';
|
||||
|
||||
#[Validate('nullable|string|max:255')]
|
||||
public string $inviteName = '';
|
||||
|
||||
#[Validate('required|in:admin,member,readonly')]
|
||||
public string $inviteRole = 'member';
|
||||
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Every customer starts with themselves as the owner seat. firstOrCreate
|
||||
// keyed on (customer_id, email) is idempotent; the catch covers the
|
||||
// concurrent-first-visit race against the unique index.
|
||||
$customer = $this->customer();
|
||||
if ($customer !== null && $customer->seats()->count() === 0) {
|
||||
try {
|
||||
$customer->seats()->firstOrCreate(
|
||||
['email' => $customer->email],
|
||||
['name' => $customer->name, 'role' => 'owner', 'status' => 'active', 'invited_at' => now()],
|
||||
);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Another concurrent first visit created it — fine.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function invite(): void
|
||||
{
|
||||
$customer = $this->requireCustomer();
|
||||
if ($customer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $this->validate();
|
||||
|
||||
// Serialize the limit check with the insert so two concurrent invites for
|
||||
// the last free seat can't both pass (lock the customer row).
|
||||
$result = DB::transaction(function () use ($customer, $data) {
|
||||
$locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
|
||||
|
||||
if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) {
|
||||
return 'duplicate';
|
||||
}
|
||||
if ($this->usedSeats($locked) >= $this->seatLimit($locked)) {
|
||||
return 'limit';
|
||||
}
|
||||
|
||||
$locked->seats()->create([
|
||||
'email' => $data['inviteEmail'],
|
||||
'name' => $data['inviteName'] ?: null,
|
||||
'role' => $data['inviteRole'],
|
||||
'status' => 'invited',
|
||||
'invited_at' => now(),
|
||||
]);
|
||||
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
if ($result === 'limit') {
|
||||
$this->addError('inviteEmail', __('users.limit_reached'));
|
||||
|
||||
return;
|
||||
}
|
||||
if ($result === 'duplicate') {
|
||||
$this->addError('inviteEmail', __('users.duplicate'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset('inviteEmail', 'inviteName', 'inviteRole');
|
||||
$this->inviteRole = 'member';
|
||||
$this->dispatch('notify', message: __('users.invited'));
|
||||
}
|
||||
|
||||
public function setRole(string $uuid, string $role): void
|
||||
{
|
||||
if (! in_array($role, Seat::ROLES, true)) {
|
||||
return;
|
||||
}
|
||||
$customer = $this->requireCustomer();
|
||||
if ($customer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock the customer so a concurrent owner change can't race past the guard.
|
||||
$ok = DB::transaction(function () use ($customer, $uuid, $role) {
|
||||
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
|
||||
$seat = $customer->seats()->where('uuid', $uuid)->first();
|
||||
if ($seat === null) {
|
||||
return true;
|
||||
}
|
||||
if ($seat->role === 'owner' && $role !== 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
|
||||
return false; // would remove the last owner
|
||||
}
|
||||
$seat->update(['role' => $role]);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (! $ok) {
|
||||
$this->dispatch('notify', message: __('users.last_owner'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a seat without destroying it.
|
||||
*
|
||||
* The action an owner actually needs when someone leaves: access stops now,
|
||||
* and the record of who held it survives — which is the half a deletion
|
||||
* throws away, on a product sold on being able to show who had access to
|
||||
* what.
|
||||
*/
|
||||
public function suspend(string $uuid): void
|
||||
{
|
||||
$customer = $this->requireCustomer();
|
||||
|
||||
if ($customer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seat = $customer->seats()->where('uuid', $uuid)->first();
|
||||
|
||||
if ($seat === null || $seat->role === 'owner') {
|
||||
// The owner cannot lock themselves out of their own cloud.
|
||||
$this->dispatch('notify', message: __('users.owner_locked'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']);
|
||||
|
||||
$this->dispatch('notify', message: __(
|
||||
$seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated',
|
||||
));
|
||||
}
|
||||
|
||||
public function revoke(string $uuid): void
|
||||
{
|
||||
$customer = $this->requireCustomer();
|
||||
if ($customer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($customer, $uuid) {
|
||||
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
|
||||
$seat = $customer->seats()->where('uuid', $uuid)->first();
|
||||
if ($seat === null) {
|
||||
return 'gone';
|
||||
}
|
||||
if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
|
||||
return 'last_owner';
|
||||
}
|
||||
$seat->delete();
|
||||
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
if ($result === 'last_owner') {
|
||||
$this->dispatch('notify', message: __('users.last_owner'));
|
||||
} elseif ($result === 'ok') {
|
||||
$this->dispatch('notify', message: __('users.revoked'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The revoke button opens ConfirmRevokeSeat instead of calling revoke()
|
||||
* directly (R23); its confirm button dispatches back here.
|
||||
*/
|
||||
#[On('seat-revoke-confirmed')]
|
||||
public function onRevokeConfirmed(string $uuid): void
|
||||
{
|
||||
$this->revoke($uuid);
|
||||
}
|
||||
|
||||
public function resend(string $uuid): void
|
||||
{
|
||||
// Invite delivery is mocked for now.
|
||||
if ($this->seat($uuid) !== null) {
|
||||
$this->dispatch('notify', message: __('users.resent'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function ownerCount(): int
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
return $customer ? $customer->seats()->where('role', 'owner')->count() : 0;
|
||||
}
|
||||
|
||||
private function usedSeats(Customer $customer): int
|
||||
{
|
||||
return $customer->seats()->where('status', '!=', 'revoked')->count();
|
||||
}
|
||||
|
||||
private function seatLimit(Customer $customer): int
|
||||
{
|
||||
// The entitlement follows the active (or cancelling) package, not a newer
|
||||
// failed/deprovisioned record.
|
||||
$instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first()
|
||||
?? $customer->instances()->latest('id')->first();
|
||||
// From the contract: how many people a customer may invite is part of
|
||||
// what they bought. Cutting a plan's seats in the catalogue must not
|
||||
// lock users out of an existing customer's cloud.
|
||||
return (int) ($instance?->subscription?->seats ?? 5);
|
||||
}
|
||||
|
||||
private function seat(string $uuid): ?Seat
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
return $customer?->seats()->where('uuid', $uuid)->first();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
|
||||
|
||||
// The actions column is ALWAYS drawn. It used to be hidden when the
|
||||
// only seat was the owner, on the reasoning that there was nothing to
|
||||
// act on — but every seat can be renamed, and a column that disappears
|
||||
// does not read as "nothing applies here", it reads as "this product
|
||||
// cannot do that". Which is exactly how it was reported.
|
||||
return view('livewire.users', [
|
||||
'seats' => $seats,
|
||||
'used' => $customer ? $this->usedSeats($customer) : 0,
|
||||
'limit' => $customer ? $this->seatLimit($customer) : 0,
|
||||
'roles' => Seat::ROLES,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Concerns;
|
||||
|
||||
use App\Services\Mail\MailboxResolver;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* From and Reply-To, taken from the mailbox behind a purpose.
|
||||
*
|
||||
* Reply-To is the whole reason sending alone is enough: a customer answering a
|
||||
* support reply lands in the support mailbox, read in an ordinary mail client,
|
||||
* so no IMAP is needed to close the loop.
|
||||
*
|
||||
* Except on a no-reply mailbox. A "no-reply" address you can reply to is a lie
|
||||
* in the sender, and the one place the promise is made is the address itself.
|
||||
*/
|
||||
trait SendsFromMailbox
|
||||
{
|
||||
protected function mailboxEnvelope(string $purpose, string $subject): Envelope
|
||||
{
|
||||
[$from, $replyTo] = $this->mailboxAddresses($purpose);
|
||||
|
||||
return new Envelope(
|
||||
from: $from,
|
||||
replyTo: $replyTo ? [$replyTo] : [],
|
||||
subject: $subject,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The From and Reply-To addresses for $purpose's mailbox — the ONE place
|
||||
* that decides "no configured mailbox means no sender fields at all" and
|
||||
* "no_reply means no Reply-To". Shared by every consumer of a purpose
|
||||
* mailbox regardless of the shape it builds — an Envelope here (a
|
||||
* Mailable), a MailMessage in CloudReady (a Notification, which cannot
|
||||
* use mailboxEnvelope() directly since it is not building an Envelope).
|
||||
* Two independent copies of this same decision is exactly the shape of
|
||||
* gap Task 4 found and fixed in MailboxTransport::resolution().
|
||||
*
|
||||
* @return array{0: ?Address, 1: ?Address} [from, replyTo]
|
||||
*/
|
||||
protected function mailboxAddresses(string $purpose): array
|
||||
{
|
||||
$box = app(MailboxResolver::class)->for($purpose);
|
||||
|
||||
if ($box === null) {
|
||||
// No mailbox configured yet — both null, so a caller falls back
|
||||
// to its own framework default rather than dereferencing null.
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$from = new Address($box->address, $box->display_name ?: null);
|
||||
|
||||
return [$from, $box->no_reply ? null : $from];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Customer;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Mail\Mailables\Headers;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
/** The ledger row this mail confirms once actually delivered. */
|
||||
public ?int $notificationId = null;
|
||||
|
||||
public function __construct(
|
||||
public MaintenanceWindow $window,
|
||||
public Customer $customer,
|
||||
) {
|
||||
// Names the mailer, which is all that is serialised into the queue —
|
||||
// the credentials behind it are resolved when the worker sends.
|
||||
$this->mailer('cp_'.MailPurpose::MAINTENANCE);
|
||||
}
|
||||
|
||||
public function headers(): Headers
|
||||
{
|
||||
return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return $this->mailboxEnvelope(
|
||||
MailPurpose::MAINTENANCE,
|
||||
__('maintenance.mail_subject', ['title' => $this->window->title]),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(markdown: 'mail.maintenance-announcement', with: [
|
||||
'title' => $this->window->title,
|
||||
'description' => $this->window->public_description,
|
||||
'startsAt' => $this->window->starts_at,
|
||||
'endsAt' => $this->window->ends_at,
|
||||
'name' => $this->customer->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Customer;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Mail\Mailables\Headers;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MaintenanceCancelledMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
/** The ledger row this mail confirms once actually delivered. */
|
||||
public ?int $notificationId = null;
|
||||
|
||||
public function __construct(
|
||||
public MaintenanceWindow $window,
|
||||
public Customer $customer,
|
||||
) {
|
||||
$this->mailer('cp_'.MailPurpose::MAINTENANCE);
|
||||
}
|
||||
|
||||
public function headers(): Headers
|
||||
{
|
||||
return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return $this->mailboxEnvelope(
|
||||
MailPurpose::MAINTENANCE,
|
||||
__('maintenance.mail_cancel_subject', ['title' => $this->window->title]),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(markdown: 'mail.maintenance-cancelled', with: [
|
||||
'title' => $this->window->title,
|
||||
'name' => $this->customer->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Transport;
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Mail\MailboxResolver;
|
||||
use App\Services\Mail\MailTlsPolicy;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Mail\Transport\ArrayTransport;
|
||||
use Illuminate\Mail\Transport\LogTransport;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Mailer\Envelope;
|
||||
use Symfony\Component\Mailer\SentMessage;
|
||||
use Symfony\Component\Mailer\Transport\NullTransport;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
|
||||
use Symfony\Component\Mailer\Transport\TransportInterface;
|
||||
use Symfony\Component\Mime\RawMessage;
|
||||
|
||||
/**
|
||||
* Resolves its credentials when it sends, not when it is built.
|
||||
*
|
||||
* The mails are queued. A worker builds its mailer once and lives for hours, so
|
||||
* anything decided in a constructor is decided for the rest of that worker's
|
||||
* life — including a password the operator has since corrected in the console.
|
||||
*
|
||||
* The delegate is cached against a FINGERPRINT of the credentials rather than
|
||||
* rebuilt per message: correct when they change, and no new SMTP connection per
|
||||
* mail when they do not.
|
||||
*/
|
||||
class MailboxTransport implements TransportInterface
|
||||
{
|
||||
/**
|
||||
* mail.default values that must never open a real connection.
|
||||
*
|
||||
* 'array' is here because phpunit.xml sets it as the suite-wide test
|
||||
* default — the first feature test that sends through a purpose mailer
|
||||
* without Mail::fake() must not reach real SMTP just because 'array' is
|
||||
* not the exact string 'log'. `null` is here because a stored setting can
|
||||
* decode to it just as easily as to a real value (see the port note in
|
||||
* delegate()): an unconfigured mailer must fail SAFE, not fail open.
|
||||
*/
|
||||
private const NON_DELIVERING = ['log', 'array', null];
|
||||
|
||||
private ?TransportInterface $delegate = null;
|
||||
|
||||
private ?string $fingerprint = null;
|
||||
|
||||
public function __construct(private readonly string $purpose) {}
|
||||
|
||||
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
|
||||
{
|
||||
return $this->delegate()->send($message, $envelope);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'mailbox://'.$this->purpose.'/'.($this->describe() ?? 'unconfigured');
|
||||
}
|
||||
|
||||
/** What this transport currently points at — used by __toString and tests. */
|
||||
private function describe(): ?string
|
||||
{
|
||||
[$mode, $box] = $this->resolution();
|
||||
|
||||
return match ($mode) {
|
||||
'mailbox' => $box?->address,
|
||||
// Same verdict delegate() throws on, not a second opinion: a DSN
|
||||
// that still read as a plain address here is the exact harm this
|
||||
// exists to prevent — it looked healthy right up until send()
|
||||
// threw. The address stays visible so an operator can tell WHICH
|
||||
// mailbox needs a password, rather than just that something does.
|
||||
'unconfigured' => $box === null ? null : $box->address.' [unconfigured]',
|
||||
default => $mode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE place that decides "log, array, null-default, an unconfigured
|
||||
* mailbox, or a real one" — describe() and delegate() both branch on this
|
||||
* result rather than each running their own copy of a check. Two copies
|
||||
* of the same guard is what let a mutation that broke only ONE of them
|
||||
* hide behind a passing suite: __toString() kept reporting "log" (and
|
||||
* later, a plain address) while delegate() quietly did something else —
|
||||
* built a real SMTP transport regardless of MAIL_MAILER, then threw on a
|
||||
* mailbox describe() had just shown as fine.
|
||||
*
|
||||
* @return array{0: string, 1: ?Mailbox}
|
||||
*/
|
||||
private function resolution(): array
|
||||
{
|
||||
$default = config('mail.default');
|
||||
|
||||
if (in_array($default, self::NON_DELIVERING, true)) {
|
||||
return [$default ?? 'null', null];
|
||||
}
|
||||
|
||||
$box = app(MailboxResolver::class)->for($this->purpose);
|
||||
|
||||
return $box !== null && $box->isConfigured()
|
||||
? ['mailbox', $box]
|
||||
: ['unconfigured', $box];
|
||||
}
|
||||
|
||||
private function delegate(): TransportInterface
|
||||
{
|
||||
[$mode, $box] = $this->resolution();
|
||||
|
||||
if ($mode === 'unconfigured') {
|
||||
// Loud, not silent: a mail with no mailbox behind it must not look
|
||||
// like it was sent. A missing or deactivated MAPPING already fell
|
||||
// back to system inside MailboxResolver; reaching this line means
|
||||
// the mailbox we ended up with — possibly system itself — has no
|
||||
// usable credentials (no row at all, or one with no password),
|
||||
// and switching to yet another address would only hide that.
|
||||
throw new RuntimeException(
|
||||
"No configured mailbox for mail purpose [{$this->purpose}]."
|
||||
);
|
||||
}
|
||||
|
||||
if ($mode !== 'mailbox') {
|
||||
return match ($mode) {
|
||||
'log' => new LogTransport(Log::channel(config('mail.mailers.log.channel'))),
|
||||
'array' => new ArrayTransport,
|
||||
default => new NullTransport, // 'null' — mail.default not configured at all
|
||||
};
|
||||
}
|
||||
|
||||
// $mode === 'mailbox' only when resolution() already confirmed $box
|
||||
// is non-null and isConfigured() — nothing left to check here.
|
||||
$host = (string) Settings::get('mail.host', '');
|
||||
$port = (int) Settings::get('mail.port', 587);
|
||||
$encryption = (string) Settings::get('mail.encryption', 'tls');
|
||||
|
||||
// Guarded the same way as the missing mailbox above. Tasks 6-7 are
|
||||
// what write these settings, so today they default to blank/absent —
|
||||
// and a STORED null port json-decodes to null, which casts to 0; left
|
||||
// unguarded, EsmtpTransport turns port 0 into plaintext port 25
|
||||
// rather than refusing (the 587 default only applies when the row is
|
||||
// absent, not when it holds null). Loud beats a silently downgraded
|
||||
// connection.
|
||||
if ($host === '') {
|
||||
throw new RuntimeException('Mail server host is not configured — cannot send mail.');
|
||||
}
|
||||
|
||||
if ($port < 1) {
|
||||
throw new RuntimeException('Mail server port is not configured — cannot send mail.');
|
||||
}
|
||||
|
||||
// authenticates is part of the fingerprint too, not only host/port/
|
||||
// encryption/username/password: Mailbox::smtpUsername() always
|
||||
// returns something non-empty (it falls back to the address), so an
|
||||
// operator toggling authenticates off with everything else unchanged
|
||||
// must still invalidate the cached delegate below — otherwise a
|
||||
// long-running queue worker would keep the OLD delegate, which
|
||||
// already has setUsername()/setPassword() called on it from before
|
||||
// the toggle, still attempting AUTH the operator just turned off.
|
||||
//
|
||||
// Codex R15#5, P2 comparison pass: the password component is read
|
||||
// the same way setPassword() below is gated — only when this mailbox
|
||||
// authenticates. $box->password decrypts under SECRETS_KEY, and an
|
||||
// unauthenticated mailbox that once had a password (an operator
|
||||
// unchecked "requires a password" without clearing the field, which
|
||||
// EditMailbox::save() deliberately leaves alone) still carries that
|
||||
// ciphertext in the column. Decrypting it here regardless would
|
||||
// crash a real send the moment SECRETS_KEY became unusable, purely
|
||||
// to fingerprint a value setUsername()/setPassword() below is never
|
||||
// going to read either.
|
||||
$fingerprint = md5(implode('|', [
|
||||
$host, $port, $encryption, (int) $box->authenticates, $box->smtpUsername(),
|
||||
$box->authenticates ? (string) $box->password : '',
|
||||
]));
|
||||
|
||||
if ($this->delegate === null || $this->fingerprint !== $fingerprint) {
|
||||
// MailTlsPolicy is the ONE place "ssl, tls, or none" turns into
|
||||
// EsmtpTransport's three TLS switches — see its docblock. The
|
||||
// third constructor argument here is implicit TLS (smtps, port
|
||||
// 465), not STARTTLS: passing true for 'tls' would open an SSL
|
||||
// socket against a server expecting STARTTLS and hang.
|
||||
$policy = MailTlsPolicy::for($encryption, $port);
|
||||
|
||||
$transport = $policy->apply(new EsmtpTransport($host, $port, $policy->implicit));
|
||||
|
||||
// Neither this transport nor MailboxTester bounded its socket
|
||||
// before this fix: a filtered outbound port (587 dropped by a
|
||||
// firewall, unlike a closed one that refuses instantly) hung
|
||||
// until PHP's default_socket_timeout (60s ini default) — for a
|
||||
// queue worker, that is one send blocking every other job behind
|
||||
// it. 30s, not MailboxTester's 10s: a worker can afford to wait
|
||||
// longer than an operator watching a browser, and a send that
|
||||
// times out is retried by the queue, not lost.
|
||||
$stream = $transport->getStream();
|
||||
if ($stream instanceof SocketStream) {
|
||||
$stream->setTimeout(30);
|
||||
}
|
||||
|
||||
// Codex R15#4, P1b: only called when this mailbox actually
|
||||
// authenticates. smtpUsername()'s address fallback means it is
|
||||
// NEVER empty, so calling setUsername() unconditionally would
|
||||
// make Symfony attempt AUTH against any server that advertises
|
||||
// it — exactly what a trusted, unauthenticated relay was never
|
||||
// asked to answer.
|
||||
if ($box->authenticates) {
|
||||
$transport->setUsername($box->smtpUsername());
|
||||
$transport->setPassword((string) $box->password);
|
||||
}
|
||||
|
||||
$this->delegate = $transport;
|
||||
$this->fingerprint = $fingerprint;
|
||||
}
|
||||
|
||||
return $this->delegate;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Backup extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'external_job_id', 'schedule', 'last_ok_at', 'status'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['last_ok_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function instance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Instance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Builders;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A query builder that refuses to rewrite history.
|
||||
*
|
||||
* Model events (`updating`, `deleting`) only fire for instance operations, so a
|
||||
* guard on the model alone still lets `Model::query()->where(...)->update(...)`
|
||||
* through — which is exactly the shape a careless data fix takes. For a table
|
||||
* whose value IS that it cannot be edited, the guard has to sit where the bulk
|
||||
* operations pass.
|
||||
*
|
||||
* Not a security boundary: anyone with database access can still do as they
|
||||
* please. It is a guard against the application quietly doing it by accident,
|
||||
* which is the realistic way a register loses its meaning.
|
||||
*/
|
||||
class AppendOnlyBuilder extends Builder
|
||||
{
|
||||
public function update(array $values)
|
||||
{
|
||||
throw new RuntimeException(
|
||||
'The proof register is append-only. Record a correcting event instead of editing history.'
|
||||
);
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
throw new RuntimeException(
|
||||
'The proof register is append-only. A record that can be deleted is not evidence.'
|
||||
);
|
||||
}
|
||||
|
||||
public function forceDelete()
|
||||
{
|
||||
return $this->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Assigns a random UUID on creation. Records that expose a `uuid` column use it
|
||||
* as their route key (R11: URLs address records by UUID, not integer PK).
|
||||
*/
|
||||
trait HasUuid
|
||||
{
|
||||
protected static function bootHasUuid(): void
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Exceptions\IdentityCollisionException;
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Database\Factories\CustomerFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Customer extends Model
|
||||
{
|
||||
/** Comparable form: spaces and punctuation are cosmetic in a VAT number. */
|
||||
public static function normaliseVatId(?string $value): string
|
||||
{
|
||||
return strtoupper(preg_replace('/[\s.-]+/', '', (string) $value) ?? '');
|
||||
}
|
||||
|
||||
public function normalisedVatId(): string
|
||||
{
|
||||
return self::normaliseVatId($this->vat_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for the value that was actually checked. Binding it to the
|
||||
* value rather than to a flag means editing the field cannot inherit the
|
||||
* old confirmation, whichever code path does the editing.
|
||||
*/
|
||||
public function hasVerifiedVatId(): bool
|
||||
{
|
||||
// Both sides normalised: a verifier that stores the number in display
|
||||
// form ("DE 811 907 980") must not invalidate a genuine confirmation.
|
||||
return $this->vat_id_verified_at !== null
|
||||
&& $this->normalisedVatId() !== ''
|
||||
&& $this->normalisedVatId() === self::normaliseVatId($this->vat_id_verified_value);
|
||||
}
|
||||
|
||||
/** @use HasFactory<CustomerFactory> */
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address',
|
||||
'locale', 'stripe_customer_id', 'status', 'closed_at',
|
||||
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['closed_at' => 'datetime', 'vat_id_verified_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function seats(): HasMany
|
||||
{
|
||||
return $this->hasMany(Seat::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve branding: customer values where set, else CluPilot defaults. Used
|
||||
* for previews and snapshotted into the provisioning run so retries are
|
||||
* deterministic. NULL is stored for "unset" — defaults are never copied in.
|
||||
*
|
||||
* @return array{display_name:string,logo_path:?string,primary_color:string,accent_color:string,is_default:bool}
|
||||
*/
|
||||
public function brandingResolved(): array
|
||||
{
|
||||
$defaults = (array) config('provisioning.branding_defaults');
|
||||
|
||||
return [
|
||||
'display_name' => $this->brand_display_name ?: ($defaults['display_name'] ?? 'CluPilot'),
|
||||
'logo_path' => $this->brand_logo_path ?: ($defaults['logo_path'] ?? null),
|
||||
'primary_color' => $this->brand_primary_color ?: ($defaults['primary_color'] ?? '#f97316'),
|
||||
'accent_color' => $this->brand_accent_color ?: ($defaults['accent_color'] ?? '#c2560a'),
|
||||
'is_default' => $this->brand_display_name === null
|
||||
&& $this->brand_logo_path === null
|
||||
&& $this->brand_primary_color === null
|
||||
&& $this->brand_accent_color === null,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function instances(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instance::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this address already belongs to a customer identity — R21's
|
||||
* other direction, checked before an `operators` row is created or
|
||||
* renamed onto it (Admin\Settings::saveAccount(), ::inviteStaff(), and
|
||||
* the clupilot:create-operator command — one rule, three call sites).
|
||||
*
|
||||
* Checks `users` directly, not only `customers`: the two are usually in
|
||||
* lockstep (see ensureUser()), but a `users` row can outlive or predate
|
||||
* a matching `customers.email` — an email changed on one side only, or
|
||||
* legacy/orphaned data — and a `customers`-only check is blind to
|
||||
* exactly that row.
|
||||
*/
|
||||
public static function emailTaken(string $email): bool
|
||||
{
|
||||
return static::query()->where('email', $email)->exists()
|
||||
|| User::query()->where('email', $email)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create the portal login account for this customer and link it.
|
||||
* Race-safe against the unique email index. Refuses by construction to
|
||||
* create or link a `users` row for an address that already belongs to an
|
||||
* `operators` row (R21) — that table split killed the old "an operator
|
||||
* account never has an is_admin users row" guarantee this method used to
|
||||
* lean on, and left this one open: nothing stops the SAME address from
|
||||
* being provisioned as a customer independently of its operator row.
|
||||
*
|
||||
* @throws IdentityCollisionException when the address is already an operator's.
|
||||
*/
|
||||
public function ensureUser(): User
|
||||
{
|
||||
if ($this->user) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
if (Operator::query()->where('email', $this->email)->exists()) {
|
||||
throw new IdentityCollisionException($this->email);
|
||||
}
|
||||
|
||||
if (($existing = User::query()->where('email', $this->email)->first()) !== null) {
|
||||
$this->update(['user_id' => $existing->id]);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
try {
|
||||
$user = User::query()->create([
|
||||
'email' => $this->email,
|
||||
'name' => $this->name,
|
||||
'password' => Hash::make(Str::random(40)),
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Concurrent first-time creation — re-fetch and link the winner.
|
||||
$user = User::query()->where('email', $this->email)->firstOrFail();
|
||||
}
|
||||
|
||||
$this->update(['user_id' => $user->id]);
|
||||
|
||||
return $user->refresh();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Datacenter extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DatacenterFactory> */
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = ['code', 'name', 'facility', 'location', 'active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('active', true);
|
||||
}
|
||||
|
||||
public function hosts()
|
||||
{
|
||||
return $this->hasMany(Host::class, 'datacenter', 'code');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DnsRecord extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'provider', 'record_id', 'fqdn', 'type', 'value'];
|
||||
|
||||
public function instance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Instance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use App\Provisioning\Contracts\ProvisioningSubject;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Host extends Model implements ProvisioningSubject
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\HostFactory> */
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
|
||||
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
|
||||
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_name', 'dns_record_id',
|
||||
];
|
||||
|
||||
protected $hidden = ['api_token_ref'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_token_ref' => 'encrypted',
|
||||
'last_seen_at' => 'datetime',
|
||||
'total_gb' => 'integer',
|
||||
'total_ram_mb' => 'integer',
|
||||
'cpu_cores' => 'integer',
|
||||
'cpu_weight' => 'integer',
|
||||
'reserve_pct' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** Placement runs (polymorphic subject). */
|
||||
public function runs(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ProvisioningRun::class, 'subject');
|
||||
}
|
||||
|
||||
/** Runner hook: a failed onboarding run moves the host to the error status. */
|
||||
public function onProvisioningFailed(): void
|
||||
{
|
||||
$this->update(['status' => 'error']);
|
||||
}
|
||||
|
||||
public function instances(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instance::class);
|
||||
}
|
||||
|
||||
public function maintenanceWindows(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(MaintenanceWindow::class);
|
||||
}
|
||||
|
||||
/** Free committable storage: total minus reserve. */
|
||||
public function freeGb(): int
|
||||
{
|
||||
if ($this->total_gb === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host storage already committed, counted as the VM disk allocation
|
||||
* (disk_gb) since that is what is actually placed on the host — not the
|
||||
* (smaller) Nextcloud user quota. A failed instance still counts while its VM
|
||||
* exists (has a vmid); a failure before any VM was created releases it.
|
||||
*/
|
||||
public function committedGb(): int
|
||||
{
|
||||
// Answer a preloaded sum when the caller supplied one (see
|
||||
// Instance::scopeOccupyingHost) — listing every host otherwise costs a
|
||||
// query per host, twice over once usedPct() asks again.
|
||||
//
|
||||
// Tested for PRESENCE, not for null: SUM over no rows is null, so a
|
||||
// host with nothing on it arrives preloaded-but-null. Reading that as
|
||||
// "not preloaded" would re-query for exactly the empty hosts, which is
|
||||
// the case the preload exists for.
|
||||
if (array_key_exists('committed_disk_gb', $this->getAttributes())) {
|
||||
return (int) $this->getAttributes()['committed_disk_gb'];
|
||||
}
|
||||
|
||||
return (int) $this->instances()->occupyingHost()->sum('disk_gb');
|
||||
}
|
||||
|
||||
/** Storage still available for new instances. */
|
||||
public function availableGb(): int
|
||||
{
|
||||
return max(0, $this->freeGb() - $this->committedGb());
|
||||
}
|
||||
|
||||
/** Used-storage percentage (committed vs committable free), 0–100. */
|
||||
public function usedPct(): int
|
||||
{
|
||||
$free = $this->freeGb();
|
||||
|
||||
return $free > 0 ? min(100, (int) round($this->committedGb() / $free * 100)) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat health from last_seen_at: online (≤5 min), stale (≤30 min),
|
||||
* offline (older or never seen). Drives the health dot in the console.
|
||||
*/
|
||||
public function healthState(): string
|
||||
{
|
||||
if ($this->last_seen_at === null) {
|
||||
return 'offline';
|
||||
}
|
||||
$minutes = $this->last_seen_at->diffInMinutes(now());
|
||||
|
||||
return match (true) {
|
||||
$minutes <= 5 => 'online',
|
||||
$minutes <= 30 => 'stale',
|
||||
default => 'offline',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement (spec §1): first active host in the datacenter with enough free
|
||||
* committable storage. Cluster is ignored in v1.0.
|
||||
*/
|
||||
public static function placeableIn(string $datacenter, int $quotaGb): ?self
|
||||
{
|
||||
return self::query()
|
||||
->where('datacenter', $datacenter)
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->first(fn (self $host) => $host->availableGb() >= $quotaGb);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class Instance extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InstanceFactory> */
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb',
|
||||
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref',
|
||||
'route_written', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['nc_admin_ref'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nc_admin_ref' => 'encrypted',
|
||||
'route_written' => 'boolean',
|
||||
'cert_ok' => 'boolean',
|
||||
'vmid' => 'integer',
|
||||
'traffic_addons' => 'integer',
|
||||
'quota_gb' => 'integer',
|
||||
'disk_gb' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'cores' => 'integer',
|
||||
'cancel_requested_at' => 'datetime',
|
||||
'service_ends_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Instances that are actually occupying storage on their host.
|
||||
*
|
||||
* A failed instance still holds its disk while its VM exists; a failure
|
||||
* before any VM was created releases it. Kept here rather than written out
|
||||
* at each call site so the host's own accounting and anything reporting on
|
||||
* it cannot drift apart — a dashboard that counted differently from
|
||||
* placement would call a host comfortable while orders were being refused
|
||||
* on it.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder<self> $query
|
||||
*/
|
||||
public function scopeOccupyingHost($query): void
|
||||
{
|
||||
$query->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid'));
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract this machine fulfils. The authority on what the customer is
|
||||
* entitled to — the catalogue only describes what we sell today.
|
||||
*/
|
||||
public function subscription(): HasOne
|
||||
{
|
||||
return $this->hasOne(Subscription::class);
|
||||
}
|
||||
|
||||
public function host(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Host::class);
|
||||
}
|
||||
|
||||
public function dnsRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(DnsRecord::class);
|
||||
}
|
||||
|
||||
public function backups(): HasMany
|
||||
{
|
||||
return $this->hasMany(Backup::class);
|
||||
}
|
||||
|
||||
public function monitoringTargets(): HasMany
|
||||
{
|
||||
return $this->hasMany(MonitoringTarget::class);
|
||||
}
|
||||
|
||||
public function onboardingTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(OnboardingTask::class);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue