Commit Graph

57 Commits (5c2e481985f4232d71452e343d98d2cd2ce47b64)

Author SHA1 Message Date
nexxo 5c2e481985 fix(security): console and account checks now survive a Livewire action
tests / pest (push) Successful in 7m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Livewire re-applies only the middleware on its persistent list when an action
posts to /livewire/update. Its defaults cover auth; ours were not on the list —
only RestrictAdminHost had been added. So EnsureAdmin and EnsureCustomerActive
guarded the PAGE and not the actions, and the page is not where the actions run:
a signed-in non-operator who could reach the admin host could drive console
components, and a suspended customer could keep driving the portal.

Found by Codex while reviewing the domain-separation design, and confirmed
against Livewire's own default list in vendor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:04:34 +02:00
nexxo f6ddf45dc0 feat(admin): a console for creating, pricing and scheduling plans
The owner can now do from the console what only a config edit could do before:
create a plan line, draft a version, price it, publish it into a window, and
pull a plan out of the shop.

Two pages and one modal, because the catalogue has exactly two levels. The
plan list is a name, a rank and a kill switch. Everything that can be got
wrong — capabilities, prices, windows — lives on the versions, where the page
is built around the one rule that matters: a draft is freely editable, a
published version is not touchable at all. So drafting and publishing are
separate acts, and publishing says plainly that it is final.

The console refuses everything the catalogue refuses, on the form rather than
as a stack trace: an overlapping window, a window that ends before it starts,
a version with no price for a term we sell on, and — new here — a version with
no VM template, which would be bought and then fail provisioning every time.
Numbers are bounded on both sides, because a mistyped price otherwise
overflows the column and answers with a 500 instead of saying which field was
wrong. Performance class and features are picked from the keys we have labels
for; free text would be frozen at publication and shown to customers as a raw
translation key forever.

Concurrency, since two admins share one catalogue: draft numbers are allocated
under a lock on the family, publication is an atomic conditional claim, and
discarding a draft is one statement conditional on it still being a draft —
otherwise a draft published in the meantime could be deleted out from under
the customers now contracted to it.

`plans.manage` (Owner and Admin) guards the pages themselves, not only the
buttons, and the modal authorises itself — prices, drafts and unreleased plans
are not something to leave readable to anyone who types the URL.

Also fixes the shared checkbox, which ticked in the browser's own blue on
every page that used one: a native checkbox ignores text colour and needs
accent-color.

Verified in the browser: withdrawing a plan in the console removes it from the
customer's billing page immediately, and restoring it brings it back. The
public landing page carries no catalogue-driven plan list, so there is nothing
there to hide.

421 tests green. Codex review clean after six rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:41:51 +02:00
nexxo 6387c747d0 feat(billing): the plan catalogue becomes three tables, and config stops selling
Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.

- Availability is computed on every read (available_from <= now < until,
  half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
  into or out of sale: a job that fails to run is a plan that silently
  misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
  scheduling takes a lock on the family and rejects an overlapping window —
  two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
  price: a checkout is not instant, and an amount edited between the session
  opening and the webhook landing would contract someone at a price they were
  never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
  deleted, and a family key cannot be renamed. All of those would null the
  provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
  resolved by plan NAME hands back today's terms, which is the split-brain one
  level up. A checkout that carried its version is honoured even after the
  window closes — they paid for what they were shown.

Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.

Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.

Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.

402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:05:56 +02:00
nexxo 55b1a1468f fix(admin): the admin password is shown once, and means it
tests / pest (push) Successful in 7m37s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The handoff stayed in the cache for its full ten minutes, so any replayed
component request could fetch the plaintext again — "shown once" was a figure
of speech. It is consumed on the first read and the token dropped; the payload
reaches the view and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:34:57 +02:00
nexxo d40113be8e fix(admin): never reset a stranger's VM, never leave the console spinning
Two from Codex:

- The job accepted any stored instance with a host. A closed one's VMID can have
  been reused on the same machine, and the reset would then land on another
  customer's VM. It now requires a live instance, and the action is not offered
  for anything else.
- An unreachable Proxmox or guest agent throws rather than returning an exit
  code, so nothing was ever written to the handoff and the modal polled forever.
  The throw is caught, and a failed() handler covers anything that still escapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:32:53 +02:00
nexxo aae0457e19 feat(admin): administrator access to a customer's Nextcloud
Impersonation borrows the customer's portal session; this is access to their
installation, which is a different thing and now a different button.

Nextcloud has no passwordless admin jump, so this does the only honest thing it
offers: it resets OUR managed admin account inside that installation and hands
the credentials over once. The customer's own accounts are untouched, and the
next request sets a new password again.

- New capability instances.adminlogin, Owner and Admin only — stronger than
  impersonation, because it hands over control rather than a session.
- The operator's own password is required every time, rate-limited: taking over
  a customer's installation is not something an unattended browser should manage
  on its own.
- The reset runs on the provisioning worker (it owns the tunnel and the Proxmox
  credentials); the console polls a handoff token, so the credentials never
  enter a Livewire snapshot.
- A silent instance is reported as such instead of appearing to succeed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:30:59 +02:00
nexxo 53d305340f fix(vpn): a late revocation must not disconnect whoever holds the key now
tests / pest (push) Failing after 49s Details
tests / assets (push) Successful in 51s Details
tests / release (push) Has been skipped Details
Once a key is free, someone can legitimately create a new access with it. The
forced revocation looked the key up and would have deleted that person's access
and pulled their key off the hub. It now acts only on an adoption artifact — a
system peer with no owner and no creator — and leaves anything else alone.

Re-issuing also threw when VPN_CONFIG_KEY was missing, which is exactly the
situation the console tells people to fix by re-issuing. It now hands the new
config over once instead of storing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:24:55 +02:00
nexxo 4f9ca27732 fix(vpn): a replaced key must not stall the reconciliation
Codex found that a sync landing between the key swap and its removal would see
the old key as unknown. Writing the test showed something worse than the
duplicate he predicted: the adoption cannot even happen — the address still
belongs to the live access — so the insert violated the unique index and took
the ENTIRE reconciliation down with it. One stale key would have stopped every
peer's state from updating.

The sync now recognises that case and queues the removal instead of adopting,
and a key with no row of its own is revoked with force: it can only ever be
removed, never kept, so the removal cannot be talked out of it by a row that
should not exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:23:15 +02:00
nexxo 556a560506 feat(vpn): explain a missing download, and offer a way out
An access created without storing its config had no download button and no
explanation — it looked broken. It now shows a dimmed button that says why
(the private key only exists on your device), and every staff access gets
"Re-issue": a new keypair, old key off the hub before the new one goes on,
so two peers never claim the same tunnel address at once. A stored config is
re-encrypted in step, or the owner would download a key the hub no longer
accepts.

Host peers are excluded: their key belongs to the machine's own wg0, and
swapping it here would cut the host off with nothing left to repair it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:20:04 +02:00
nexxo 7f60f88542 fix(maintenance): make the window form usable
The action labels wrapped to two lines and crushed the column — now icon
buttons with tooltips, like every other admin table. The form column was too
narrow for its content: the datetime fields silently clipped their own value,
so they are stacked and full width now, with duration chips because typing an
end timestamp by hand is the fiddliest part of the form. Host rows show a real
selected state and a per-datacenter count, and the impact column says
"1 Host · 1 Kunde" instead of "Host(s) · Kunde(n)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:05:33 +02:00
nexxo 4ffdafc614 fix(vpn): losing the operator roles closes the owner doors by itself
Ownership alone kept download, block and delete reachable for someone whose
roles were removed by any path other than revokeStaff() — a direct role edit,
say. Being staff is now part of ownership, so revocation closes these doors
without depending on whoever removed the role also remembering the tunnel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:13 +02:00
nexxo e5f19f8db3 fix(vpn): count downloads in the database; repair the race test
The download counter was read-modify-written, so two concurrent retrievals
recorded one. It is an audit trail — under-reporting is the failure mode it
must not have.

The creation-race test broke when issuing moved into a transaction: its
simulated competitor writes inside our transaction, so the rollback removes
that row too. It now asserts what actually matters (the loser is told, and
creates nothing) and states what a single-connection sqlite test cannot show.
This test was red in the previous commit — my mistake for committing before
reading the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:48:38 +02:00
nexxo 02750a7c9a fix(vpn): lock the owner row while issuing an access
A revocation committing between the operator check and the insert would find no
peer to remove and still leave the revoked colleague with a brand-new tunnel.
Issuing now runs in a transaction that locks the owner row — the same row
revokeStaff() locks — so the two cannot interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:46:23 +02:00
nexxo 3bd3e64a3d fix(vpn): pause polling while a private config is on screen
Every five-second poll re-rendered the page, putting the private key into
another HTTP response — the handoff kept it out of the snapshot but not out of
the responses. Traffic figures can wait the minute it takes to copy a key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:44:31 +02:00
nexxo c2b18ecfbc fix(vpn): drop the previous config before showing a new access
Creating a second access with a supplied key skipped the config branch, so the
first access's private configuration stayed on screen under the new name — and
someone would have handed it out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:42:38 +02:00
nexxo 04ba9dd2a9 fix(vpn): an open modal stops serving the config once access is revoked
Once revealed, the plaintext sits in the handoff cache for ten minutes, and the
modal read it from the token alone. A former owner could keep pulling the key
out of an open modal after revocation — the exact window purgeSecret() exists to
close. Every request now re-checks the policy and drops the handoff when it no
longer holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:39:30 +02:00
nexxo db7b8a6ee3 fix(vpn): keep the navigation entry after the capability split
The sidebar still gated the VPN entry on vpn.manage, which the split deleted —
so the page would have vanished from the console for every role, Owner
included. Every operator can hold their own access, so the entry is shown to
all of them and the page itself shows only what the policy allows. Guarded by a
test across all five roles, which is the assertion my rewrite had dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:37:35 +02:00
nexxo 371d517261 fix(vpn): dispatch the revocation removal after the transaction commits
A worker could pick the job up mid-transaction, still see the peer as active,
leave it on the hub — and never be asked again, because the committed state is
only a soft delete. A revoked colleague would keep their tunnel until some
later reconciliation noticed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:36:11 +02:00
nexxo 1112b4cdec fix(vpn): classify adopted peers as system, not staff; drop duplicate env line
Codex spotted the migration labelling sync-adopted peers as staff with no
owner, which breaks the invariant the same migration introduces. The live sync
had the identical hole — it never set kind at all, so every adopted peer landed
on the 'staff' default. Both now use system for a peer nobody is behind, and
promote it to host once the link is known.

.env.example carried ADMIN_HOSTS twice; phpdotenv keeps the first, so a fresh
checkout would have got the list without localhost and 404'd its own console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:18 +02:00
nexxo 0d9b62eb50 feat(vpn): ownership, a Developer role, and password-gated config retrieval
Reworked after a design consultation with Codex, which pushed back on my first
proposal in three useful ways.

Ownership and rights:
- vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is
  not managing it, and neither is holding its private key.
- Record-level rules live in VpnPeerPolicy, not in permissions: an access
  belongs to a person, and ownership is what grants sight of it. Every operator
  reaches the page, but sees only their own unless they may see all.
- Issuing an access is not self-service — it reaches the management network, so
  it needs vpn.manage.all even for oneself.
- New Developer role: sees everything, manages nothing. Writing code does not
  imply authority over other people's access.
- kind (staff|host|system) replaces a null user_id that had to mean two
  different things at once.

Config storage, opt-in per access:
- Downloading is owner-only — explicitly NOT for view.all or manage.all. An
  admin who needs access revokes this one and issues their own, which keeps the
  record of who holds what honest.
- The password is asked on EVERY retrieval, rate-limited. Laravel's
  password.confirm keeps a 15-minute session stamp, which would authorise
  unlimited later downloads from an unattended browser.
- Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not
  also hand over the management network. Purged on revocation and when a staff
  member is revoked — console taken away, tunnel left open was the worst case.
- The plaintext never enters a Livewire snapshot (the page polls every five
  seconds); the component carries an opaque handle instead.

Also: copy button works without HTTPS again (navigator.clipboard only exists in
a secure context, so over http it silently did nothing), plus config download
as a file and a QR code for the mobile apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:31:54 +02:00
nexxo d1302e17bc fix(vpn): purging a host no longer leaves a phantom access behind
The FK only nulled host_id, so the adopted peer stayed enabled-but-absent: shown
as "wird angewendet" forever, and re-enabling it would have put the deleted
host's key and address back on the hub. PurgeHost now tombstones that row, which
hands it to the same revocation machinery as everything else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:00:13 +02:00
nexxo c54b718566 fix(vpn): never let an access hijack a host key; name adopted peers correctly
- A host's wg_pubkey may not be in vpn_peers yet, so the duplicate check passed
  and an operator could create an access with it. ApplyVpnPeer would then run
  wg set with a freshly allocated address, rewriting that host's allowed-ip and
  cutting CluPilot's management tunnel to a live machine. Creation now rejects
  keys already held by a host, checked under the allocation lock.
- A sync landing between addPeer() and the step storing wg_pubkey adopted the
  peer as "unknown", and a later sync filled in host_id but left the name, so
  the page showed that host as unknown forever. The name is now filled in when
  the link becomes known — only for sync-adopted rows, so an access an operator
  named keeps its name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:58:03 +02:00
nexxo 28e681b9df fix(vpn): lock every hub mutation; resolve duplicate keys inside the lock
- ConfigureWireguard and RemoveWireguardPeer mutate the hub too, and were not
  taking the lock, so a sync could still read the interface around them and
  write stale state. Both now hold wireguard:hub, which is what makes the
  read/mutation guarantee actually hold.
- The duplicate-key check ran before the allocation lock, so two concurrent
  creations with the same key both passed it and the loser hit the unique index
  as an unhandled 500. The check moved inside the lock, with the index kept as
  a caught backstop for any writer that does not take it — reproduced in a test
  by inserting a colliding row from within the allocation call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:55:57 +02:00
nexxo 8eb9d7ad30 fix(vpn): serialize hub reads against hub mutations
A sync could read the hub snapshot just before ApplyVpnPeer removed a peer and
purged its tombstone, then adopt what it had seen as a fresh enabled access —
restoring a revoked one. Both jobs now hold Cache::lock('wireguard:hub') around
read-decide-mutate, so a reconciliation can never straddle a removal. Retry
removals are dispatched after the lock is released, because the sync queue
driver runs them inline and they take the same lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:53:13 +02:00
nexxo 9137eae6df fix(vpn): a stale enable job can no longer resurrect a deleted access
If an add job was retried after the access had been revoked and its tombstone
purged, it found no row and trusted its own enabled=true payload — re-adding a
key that no longer had a row anyone could revoke it with. A missing row can only
mean the access is gone, so the captured payload may now remove but never
enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:50:57 +02:00
nexxo 1b193ba4ea fix(vpn): join the host pipeline's allocation lock; re-authorize on every poll
Two more from Codex:

- Addresses come from one subnet but live in two tables (hosts.wg_ip and
  vpn_peers.allowed_ip), so a concurrent host onboarding and console creation
  could each see the same address as free and both insert — neither unique
  index sees the other. Creation now waits on Cache::lock('wireguard:allocate'),
  the same lock ConfigureWireguard already holds while reserving a host address.

- mount() runs once, so revoking vpn.manage left an open tab polling every five
  seconds and still receiving fresh peer state. Authorization now also runs on
  hydration, which is what the poll goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:49:09 +02:00
nexxo 08670ecbee fix(vpn): stop revoked accesses coming back and stale jobs undoing intent
Two lifecycle holes Codex found in the new VPN management:

1. Delete removed the row before the hub knew. If the removal job was delayed
   or failed, SyncVpnPeers saw a peer it did not recognise and adopted it back
   as a live access — silently restoring what an operator had just revoked, and
   with no row left to revoke it again. Deletion is now a soft-delete
   tombstone: the row survives until the hub confirms the peer is gone, the
   sync retries the removal instead of adopting, and the tombstone is purged
   only once the peer is really absent. It also keeps the key and the tunnel
   address reserved meanwhile, so neither is handed out twice.

2. ApplyVpnPeer applied the state captured at dispatch. A retried block job
   could therefore undo a later unblock, and nothing would repair it — the sync
   only observes state, it never re-applies intent. The job now resolves the
   current desired state from the row and treats its payload as a fallback for
   the case where the row is already gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:41:03 +02:00
nexxo 71ca0e1394 feat(admin): VPN access management with live peer state
The console can now create, block and remove VPN accesses, and shows who is
actually connected — traffic counters, source endpoint and last handshake.

Design notes:
- The web container has no wg0, so the page never talks to WireGuard. Live
  state is copied in by SyncVpnPeers on the provisioning queue (the only worker
  whose container owns the interface); the page polls the database and merely
  nudges that job, throttled so many open tabs cannot flood the queue.
- enabled (operator intent) and present (observed on the hub) are stored
  separately, so a sync can never quietly undo a block and drift stays visible
  as "wird angewendet".
- The sync adopts peers the host pipeline registered, naming them after their
  host — otherwise "who is on the VPN" would have blind spots.
- Keypairs are generated in PHP via libsodium rather than shelling out to
  wg genkey (no interface in this container, and it keeps generation testable).
  The private key is shown once and never stored.
- allocateIp() now also considers vpn_peers, which would otherwise be handed a
  tunnel address a host already holds.
- vpn.manage is a new capability held by Owner/Admin only, and it hides the nav
  entry too — a VPN access reaches the management network.

Verified end to end against the real hub, not just mocks: created an access in
the browser, connected with the generated config, watched the console flip to
"Verbunden" with real counters, then blocked it and confirmed the peer was
gone from wg0 and the client could no longer handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:38:12 +02:00
nexxo ba861ad579 fix(security): match ADMIN_HOSTS case-insensitively; share test helpers
DNS names are case-insensitive and Symfony's getHost() always returns lowercase,
so ADMIN_HOSTS=Admin.Example.com rejected every request and locked operators
out. Normalised in the config AND at the comparison, so the value is safe
whichever route it took in (env, cached config, runtime set).

operator()/admin() moved from RbacTest/HostManagementTest to tests/Pest.php:
they were only loaded when those files happened to be in the run, so a targeted
single-file run died on 'undefined function operator()'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:56:26 +02:00
nexxo f5f4d2a8cd fix(security): close the Livewire bypass of the admin host restriction
Admin actions post to /livewire/update, which a path-scoped guard skips — an
operator session could drive admin components through a PUBLIC hostname despite
ADMIN_HOSTS. The restriction is now registered as Livewire-persistent middleware
and listed on the admin route group, so Livewire re-applies it from the
component snapshot.

Proven by replaying a snapshot taken from the real rendered page: identical
payload -> 404 on a public host, 200 on the allowed host (positive control, so
the test cannot pass for the wrong reason).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:50:22 +02:00
nexxo 1f42c05648 feat(security): admin console can be pinned to non-public hostnames
The Proxmox fleet and the operator console must never be publicly reachable. The
primary control is the reverse proxy, but nginx here is a catch-all
(server_name _), so /admin was served on EVERY hostname — a proxy
misconfiguration would expose it. ADMIN_HOSTS pins it; any other host gets 404
(not 403: a public domain must not disclose that a console exists).

Prepended to the  group instead of the admin route group on purpose: route
middleware is reordered by Laravel's priority list, which runs  first — a
guest would then be redirected to /login and learn the console is there. Covered
by a test for exactly that case. Empty ADMIN_HOSTS = unrestricted, so nobody is
locked out by upgrading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:45:54 +02:00
nexxo ac3b3a4e11 fix(portal): scope the per-instance maintenance badge to that instance's host
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:12:33 +02:00
nexxo 406f753311 fix(admin): remove is_admin self-heal — RBAC is the only console boundary (no revocation bypass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:35:14 +02:00
nexxo 8304f2e7dc fix(admin): self-heal legacy is_admin into Owner (no RBAC bypass); validate datacenter country server-side
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:19:24 +02:00
nexxo b50f97568f fix(admin): no admin lockout (is_admin fallback); centered modal width; datacenter edit modal + country dropdown
- isOperator()/EnsureAdmin/broadcast fall back to is_admin so a legacy admin is
  never locked out by a stale permission cache
- published modal: centered max-w-lg card (dynamic modalWidth class was purged
  by Tailwind → full-width)
- datacenter edit moved to a modal (no row-height jump); location is now a
  country dropdown (config/countries.php) instead of free text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:09:17 +02:00
nexxo ae1f78f534 fix(admin): shown-once temp password for invited staff; suppress announcements for cancelled windows at send time
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:23:21 +02:00
nexxo 718e8568c1 fix(admin): require host on any maintenance save; cancel-notify only delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:44 +02:00
nexxo ab5ba2b169 fix(admin): dedup pending maintenance notifications (updated_at claim + staleness threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:16:15 +02:00
nexxo 52fa7a34a2 feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:07:21 +02:00
nexxo bc2e95ba18 fix(admin): block role escalation of non-staff; validate maintenance host ids atomically; rollback ledger on dispatch failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:01:45 +02:00
nexxo eff8c08258 feat(admin): maintenance windows — schedule once, assign many hosts, notify
- maintenance_windows + host pivot + notification ledger; derived state (never
  stored); affected-customer + banner queries live off instances
- admin /maintenance: create draft/publish, multi-host select, impact counts,
  cancel; capability-gated (maintenance.manage)
- publish emails affected customers once (queued Mailable, ledger-idempotent)
- customer portal maintenance banner (upcoming <=72h + active) merged per window

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:53:50 +02:00
nexxo c5d60340b7 feat(admin): staff RBAC (spatie) + admin settings page
- 5 operator roles (Owner/Admin/Support/Billing/Read-only) seeded via migration
  with a capability catalogue; app checks capabilities via Gate, never role names
- every mutating admin action authorizes server-side (hosts/datacenters/customers/
  impersonate/provisioning); is_admin reads migrated to console.view / isOperator()
- admin /settings: own account + Owner-only staff invite/role/revoke with
  last-owner, self-role and customer-collision guards (transactional)
- sidebar 'zum Kundenportal' link replaced with Settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:47:42 +02:00
nexxo 08f61b43f5 fix(ui): modal above sidebar (z-index); datacenter edit buttons stay right; toast slides from bottom; provisioning retry action
- modal wrapper z-10 -> z-[70] (was behind the z-40 sidebar); toasts slide up/down
- datacenter inline-edit colspan fix so save/cancel stay right-aligned
- admin provisioning: retry a failed run (table + panel), mirrors host-detail retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:29:37 +02:00
nexxo 3bf0dfb548 fix: seat limit + suspend guard per Codex; harden factory email uniqueness
- seat limit follows the active/cancelling package, not a newer inactive record
- suspend/reactivate toggle refuses closed accounts (terminal); closed shown in list
- factory emails use a large unique numeric space (safeEmail pool could collide
  late in a full suite run)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:53:15 +02:00
nexxo ef110b06db feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:30:21 +02:00
nexxo d3d686e575 feat(admin): scalable host list (search/filter/health) + host detail redesign
- host list: search + datacenter/status filters, dense table with heartbeat
  health dot, instance count, capacity meter
- host detail: health hero (last_seen), storage/compute breakdown, technical
  facts, hosted-instances table
- Host model: usedPct() + healthState() helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:27:53 +02:00
nexxo 54d9a05235 feat(admin): datacenter edit+delete (guarded); MRR chart fills card; host-load as meters
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:22:15 +02:00
nexxo 80348d6a3f fix: unique customer↔user link + reject inactive datacenters on host create
- customers.user_id unique so impersonation/billing can't cross customers
- HostCreate validates datacenter is active (exists rule + active=1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:56:07 +02:00
nexxo b44e224bee fix: backfill datacenters on migrate; resolve billing customer by user link
- migration seeds fsn/hel + any host-referenced code so existing installs
  keep selectable datacenters after upgrade
- Billing::customer() resolves via user_id, email only as legacy fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:53:06 +02:00
nexxo bc278c8fa1 feat(admin): datacenters management (create + select)
datacenters table/model + admin CRUD (/admin/datacenters, nav). HostCreate
picks from active datacenters (validated); hosts show the datacenter code.
Seeded fsn/hel. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:25:17 +02:00