tests / release (push) Blocked by required conditionsDetails
Drei eigene Saetze statt einer geteilten Meldung an den drei
Inhaber-Wachen (sperren, Rolle aendern, entfernen) — jede nennt jetzt
den Grund: der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine
eigene Cloud verwaltet.
Der Kommentar zum Auffangnetz in NextcloudUsers::applyRole() behauptete
einen engeren Schutz, als &&/|| linksassoziativ tatsaechlich bilden —
korrigiert statt geklammert, weil Klammern die gerade erst gebaute
|| true-Erkennung in FakeProxmoxClient gebrochen haetten, fuer einen in
der Praxis folgenlosen Fall.
Beide Fakes (FakeProxmoxClient, FakeRemoteShell) verweisen jetzt im
Kopfkommentar aufeinander: sie behandeln ein angehaengtes || true
unterschiedlich, und wer nur den einen kennt, soll das an der Klasse
lesen koennen.
Die Restnaht beim nachgeschickten Sperren (SyncSeatToNextcloud::invite())
bleibt Code wie er ist — Begruendung als Kommentar an der Stelle: die
Naht ist real, aber folgenlos (kein nc_synced_at, kein Wiederholen-Knopf,
Weg zurueck bleibt die Vordertuer).
Und ein kurzer Satz an der restore-Aktion in retry(), der die schon
vorhandene, aber weit oben stehende Begruendung buendelt: kein Feld fuer
ein faelliges enable, weil das die naechste Behauptung ueber eine Cloud
waere, in die das Portal nicht sehen kann.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Befunde aus dem Re-Review.
Die Mailwache fehlte an retry(). sendInvite() weist ab, wenn der Versand nicht
eingerichtet ist — der Zweig `nc_synced_at === null => 'invite'` in retry()
schickt genau dasselbe `occ user:add --generate-password --email` und tat es
nicht. Der Weg dorthin ist am ersten Tag rein durch Klicken erreichbar: /users
steht offen, solange die Instanz noch in Bereitstellung ist (customer.active
prueft den Kunden, nicht die Instanz), und ohne AKTIVE Instanz sagt
mailversandBereit() `true`. Also kein Hinweis, keine Wache, Einladung hinaus,
Auftrag scheitert an `no_instance`, Zeile rot, Wiederholen-Knopf da. Wird die
Bereitstellung fertig, ohne dass der Versand steht, legte ein Klick das Konto
an, occ beendete mit 0, und die Plakette sagte "Eingeladen". Zweiter Weg zum
selben Ende: ein einmal eingerichtetes Versandkonto wird abgeschaltet, und jede
rote Zeile ohne nc_synced_at fuehrt beim Wiederholen dorthin.
Die Wache gilt nur fuer `invite`. `disable` und `restore` verschicken nichts —
sie duerfen auch ohne Mailversand laufen, und sie sind die Rueckfahrkarte aus
einem Fehlschlag. Sie zu sperren hiesse, einen offenen Zugang offen zu lassen,
weil eine Mail nicht ginge. Die Pruefung faehrt den ganzen Weg ab, mit echtem
Auftragslauf in der Mitte.
Und der W1-Fix hatte eine Zusicherung aufgeweicht: an `status === 'invited'` war
die Adresse des INHABER-Sitzes nie aenderbar, denn er steht immer auf 'active'.
An nc_username allein wurde sie es, bis linkToInstanceAdmin() greift. Die Folge
ist kein Umbenennen — laeuft die Adresse des Inhaber-Sitzes von der
Kundenadresse weg, legt "Anlegen" mit der echten Adresse eine ZWEITE Zeile fuer
dieselbe Person an, die gegen die Platzgrenze zaehlt. Der Inhaber-Sitz ist der
eine Sitz, dessen Adresse nicht ihm gehoert, sondern dem Kundenkonto.
Die Bedingung steht jetzt einmal als adresseAenderbar() statt dreimal
abgeschrieben; auseinanderlaufen muss sie nur einmal, um eine Luecke zu sein.
Der bestehende Umbenennen-Test prueft addressEditable nicht und waere gruen
geblieben — der neue faehrt am Formular vorbei und haelt zugleich fest, dass
Umbenennen am Inhaber-Sitz erlaubt bleibt.
Beide gegen den zurueckgedrehten Fix rot gesehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die zweite Haelfte von K4. Die Wanderung legt das Versandkonto an; solange es
niemand ausgefuellt hat, bestand die Attrappe unveraendert fort — und sie ist
nicht der Randfall, sondern der Normalfall: der Mailserver dieses Produkts
entsteht gerade erst, das Postfach ist am ersten Tag garantiert leer.
Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das
Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten
Versand kommt dort aber nichts heraus, und `occ user:add --generate-password
--email` gelingt trotzdem: Nextcloud legt das Konto an, versucht die
Willkommensmail, protokolliert intern einen Fehler und beendet mit 0. Der
Auftrag meldete Erfolg, die Zeile sprang auf "Eingeladen", die Meldung versprach
einen Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand
bekam etwas.
sendInvite() fragt jetzt GuestMailConfig::for($instance)->available(): ein
reiner Blick in die Datenbank, kein Tunnel, keine Warteschlange, deshalb darf er
auf der Seite stehen. Steht der Versand nicht, geht kein Auftrag hinaus, der
Sitz bleibt unveraendert — insbesondere ohne Anmeldenamen — und die Meldung sagt,
dass nichts verschickt wurde und woran es liegt.
Drei Entscheidungen dabei:
Der Knopf bleibt stehen, der Hinweis steht ueber der Tabelle. Ihn an jeder Zeile
verschwinden zu lassen liest sich nicht als "geht hier gerade nicht", sondern als
"das kann dieses Produkt nicht" — genau die Beschwerde, die schon einmal dazu
gefuehrt hat, dass die Aktionsspalte immer gezeichnet wird. Und der Zustand ist
voruebergehend: er endet, sobald der Betrieb das Konto ausfuellt.
Die Wache steht VOR dem Ratelimit. Sonst haette ein Inhaber seine Versuche
aufgebraucht, bevor ueberhaupt einer hinausgehen konnte.
Ohne laufende Instanz greift sie gar nicht: dann scheitert der Auftrag ohnehin an
`no_instance` und die Zeile sagt das im Klartext. Diese Wache gilt dem anderen
Fall — die Cloud laeuft, nur der Versand fehlt.
Das Anlegen bleibt offen: es verspricht ausdruecklich keine Mail, und der
Hinweistext sagt das auch. Der Bereitstellungsschritt ist unangetastet und bleibt
bei "nicht eingerichtet ist etwas anderes als kaputt".
Fuenf Zusicherungen, drei davon gegen den zurueckgedrehten Fix rot gesehen; die
uebrigen zwei sind Grenzpruefungen und muessen in beide Richtungen gruen sein.
Acht Bestandspruefungen richten den Versand jetzt ueber eine eigene
Hilfsfunktion ein — zwei davon haetten sonst aus dem falschen Grund bestanden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Befunde aus dem Gesamt-Review, alle dieselbe Klasse: die Zeile im Portal
behauptet etwas, das in der Cloud des Kunden nicht gilt.
K1 — Entziehen, waehrend die Einladung noch in der Warteschlange steht. Der
Arbeiter teilt sich die Warteschlange mit der bezahlten Bereitstellung; das
Fenster ist Minuten lang. queueSync() stieg bei nc_synced_at === null aus und
kannte damit den dritten Waechter-Fall nicht: bei `pending` entsteht dort
gerade etwas, das gesperrt werden muss. Und der invite-Auftrag liest den Status
jetzt am Ende frisch aus der Datenbank nach und schiebt bei revoked/suspended
ein disable hinterher — dieselbe Begruendung wie beim schon gebauten "ein
angelegtes Konto sofort vermerken". Vorher endete der Ablauf mit
status=revoked, nc_state=synced und einem aktiven Konto in der Nextcloud, ohne
jeden Knopf, es nachzuholen.
K2 — retry() konnte ein gescheitertes Entsperren nie wiederholen: die Ableitung
kannte disable, invite und role, aber kein enable, und waehlte deshalb role.
Der Auftrag fuhr Gruppen und Quota, gelang, die Zeile sprang auf "Aktiv" — und
user:enable war nie geschickt. Woran das zu erkennen waere, steht nirgends am
Sitz; ein Feld dafuer waere die naechste Behauptung ueber die Cloud, die
irgendwann nicht mehr stimmt. Deshalb raet retry() nicht, sondern schickt an
einer offenen Zeile beides: der neue Auftrag `restore` sperrt auf UND setzt die
Rolle. suspend() bleibt bei enable, denn dort ist bekannt, was fehlt.
W2 — der Inhaber konnte sich selbst aussperren. setRole() nahm jede Rolle aus
Seat::ROLES an, also auch owner; das Auswahlfeld bietet sie nicht an, die
Livewire-Methode ist trotzdem oeffentlich erreichbar. Mit zwei Inhaber-Sitzen
griff die Zaehlung in revoke() nicht mehr, und der echte Inhaber bekam
user:disable admin samt user:auth-tokens:delete admin in seine eigene Cloud.
revoke() und setRole() weisen owner jetzt genauso ab wie suspend(), und owner
ist keine zulaessige Zielrolle mehr. Damit faellt die Zaehlung selbst weg —
eine Sperre, die man sich erst erarbeiten muss, ist keine — und mit ihr die
Meldung users.last_owner.
Sechs Pruefungen, jede einzeln gegen den zurueckgedrehten Fix rot gesehen.
Umlaute in den beruehrten Dateien nachgezogen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Reste aus dem letzten Fix, beide dieselbe Klasse: die Zeile behauptet etwas
ueber den Gast, das dort nicht gilt.
Der Wiederholen-Knopf an einer entzogenen, nie gespiegelten Zeile lief auf
user:disable gegen einen Benutzer, den es nie gab — scheitert, dieselbe rote
Zeile, beliebig oft. retry() traegt jetzt denselben nc_synced_at-Vorbehalt wie
queueSync(), mit invite als Ausnahme, denn genau dafuer ist der Knopf da. Und
gezeichnet wird er nur noch, wo der Server ihn auch annimmt.
Nach einer Wiederaufnahme stand nc_state auf synced und status auf invited, also
zeichnete die Spalte "Eingeladen" — fuer jemanden, den niemand eingeladen hat
und dessen Konto im Gast weiter gesperrt ist. nc_state geht beim Wiederaufnehmen
zurueck auf none; nc_username und nc_synced_at bleiben, denn an letzterem haengt
das Entsperren beim folgenden Einladen. Eine Pruefung geht den ganzen Weg.
Der letzte Fix hat zwei eigene Loecher gerissen, beide dieselbe Klasse wie das,
was er schliessen sollte: die Oberflaeche versicherte etwas, das im Gast nicht
eingetreten war.
Entzogen stand VOR dem Fehlschlag, also verschwanden Grund und
Wiederholen-Knopf genau dort, wo sie am noetigsten sind: scheitert das disable,
ist das Konto weiter offen, waehrend die Zeile "entzogen" sagt. Es gibt keinen
Wiederholungslauf und keinen Abgleich, und diese Seite ist die einzige, die
nc_state anzeigt. Beides steht jetzt nebeneinander.
Und die Meldung empfahl einen neuen Sitz, den die Eindeutigkeit von
(customer_id, email) unmoeglich machte. addSeat() nimmt einen entzogenen Sitz
derselben Adresse wieder auf — durch dieselbe Platzpruefung wie jeder neue,
denn das ist der Grund, warum es die Vordertuer sein muss und kein Knopf an der
Zeile. Der Auftrag entsperrt dabei, was revoke() gesperrt hat; user:welcome tut
das nicht.
retry() bekommt die owner-Wache nach, die bisher nur am Knopf davor hing. Und
der Kommentar in mount() behauptet keine Heilung mehr, die nicht stattfindet.
Dass revoke() nicht mehr loescht, hat eine Kehrseite, die nirgends stand: der
Umschalter in suspend() kannte nur zwei Zustaende und machte aus einem
entzogenen Sitz beim zweiten Klick wieder einen aktiven — samt user:enable und
vorbei an der Platzgrenze, die nur beim Anlegen geprueft wird. sendInvite() bot
denselben Weg. Beide weisen 'revoked' jetzt ab, die Zeile traegt keinen
Handlungsknopf mehr, und wieder aufmachen kann man sie gar nicht: der Weg
zurueck ist ein neuer Sitz.
Der Einladen-Knopf stand auch an der Inhaber-Zeile. Ein Klick vor der fertigen
Bereitstellung haette spaeter ein zweites Konto in der Gruppe admin angelegt,
neben dem echten — und mount() haette den Sitz danach nie wieder verknuepft,
weil die Bedingung am Zustand hing statt am Anmeldenamen.
queueSync() entscheidet ueber nc_synced_at statt ueber nc_state: ein Sitz,
dessen Einladung an einem unerreichbaren Gast scheiterte, hat dort nichts, was
man sperren koennte. Damit das keine Luecke reisst, vermerkt der Auftrag ein
angelegtes Konto sofort, auch wenn die Rolle danach scheitert.
Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf
die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und
der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und
Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder.
Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde,
mit echter Restzeit in der Meldung statt stummer Verweigerung.
Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis.
Three things reported together.
── Editing belongs in a modal (R20)
The seats table grew its input fields into the row. It worked and it looked
broken: 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. The project already had
the answer — EditDatacenter, whose own header comment says it avoids exactly
that row-height jump — and the seats table simply did not use it.
EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route
middleware, so it resolves the customer itself and re-reads the record rather
than trusting a hydrated property: a forged addressEditable would otherwise
open the address of an accepted seat, and the address is the person — editing
it hands one employee's access to another with nobody told. Only an invitation
still in flight can have its address corrected, which is the case that actually
comes up.
The actions column is now always drawn. It used to disappear 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 vanishes does not read as "not
applicable here" — it reads as "this product cannot do that", which is how it
was reported, three times. The owner's row says "Geschützt" rather than leaving
an empty cell.
Also caught here: the edit fields carried class-wide #[Validate] attributes, so
an empty edit form made the INVITE button fail on a field the invite form does
not have. Rules for an action belong to the action.
── The update button
"I cannot run an update, it says everything is current." `behind` is a READING
taken by the agent every five minutes, not the state of the world — push a
commit and the console insists it is up to date and refuses to act. The agent
does its own fetch before deciding, so asking against a stale reading costs one
fetch and finds nothing; being locked out costs the deployment. The button is
now offered whenever an agent is alive and no run is in flight, labelled for
what it does rather than for what the last reading said.
── The update never announced that it had finished
Because the thing being watched restarts the thing doing the watching. Mid-run
every wire:poll request fails, and what answers afterwards is a new build being
questioned by the old page's JavaScript — so the card sat on "läuft" until
somebody reloaded by hand. A small Alpine watcher now asks a plain JSON
endpoint (no Livewire, no component state, no assets), treats a failed request
as the restart rather than as a fault worth giving up over, and reloads once
the build it is looking at is no longer the build it started with.
── Support
The page was a decorated placeholder: a button that raised a toast saying the
form was "only hinted at in the prototype", three invented ticket titles living
in the translation file, and no way to see what became of anything. It looked
thin because nothing on it was real.
It now leads with the customer's own requests — what somebody arriving here
wants to know is what they asked and whether anyone answered — with contact
details moved to the side where they belong. The form attaches the plan, the
instance and who is asking automatically: making a customer describe their own
server back to the people who built it is the part of support people hate. FAQ
answers end where the thing can actually be done, and "is it me or is it you"
is answered by a link to the status page.
637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page
view may grow an input field inside a <td>.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.
That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.
Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.
The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.
Also here, from the same round of review:
- The chart's blue border was Tailwind's own `ring` utility colliding with a
component class of the same name. Renamed to `.metric-ring`. This was
dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
every seat that is not the owner, with the owner refused in the action itself
and not merely hidden in the markup.
DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.
Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Availability is now counted from our own monitoring answers. Uptime Kuma knows
the figure, but the REST bridge in front of it only answers "healthy, yes or
no", and waiting for the bridge to grow an endpoint would have left the panel
without the number indefinitely. The sync already asks on a schedule; counting
the answers gives a percentage that is ours and explainable.
An unanswered check is not a failed one — the counter only moves when a real
answer arrived, so our monitoring going down does not become the customer's
outage on the one figure they are asked to trust. An instance nobody has ever
checked shows no availability rather than 100 %.
The downgrade path existed nowhere. Going down is not the mirror of going up:
it can ask an instance to hold more than the target plan allows. Smaller plans
are now listed even when they cannot be taken, with the obstacle in the
customer's own numbers — "you have 31 users, this package allows 10" — because
a greyed-out button that does not name what is in the way is the thing people
ring about. Storage is checked against the last real reading rather than the
contractual allowance, or anyone who once bought a large plan could never leave
it. The limit is re-checked in the action: a rule enforced only in markup is
not enforced.
And the smaller corrections from this round:
- The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP
and MariaDB versions, which tell a tenant nothing they can act on.
- "EU — Serverstandort im Angebot festgehalten" under a label already reading
"Serverstandort" was two sentences saying nothing. It is the country.
- Support promised a reply "binnen 4 Std." with nothing behind it. What is true
everywhere else is an answer on the same working day.
- The actions column appeared even when no row in the table had an action.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
now SAY why nothing happened (operator accounts have no Customer) instead of
returning silently — the actual cause of 'dead buttons', '0/0 seats' and
'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
grouped host picker with per-datacenter select-all + selection counter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
- seats table + model; Users page manages team members (owner auto-created)
- invite respects plan seat allowance + dedupe; role change + revoke guarded
so the last owner can never be removed or demoted
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>