13 Commits (aaf8697c3d4bbdca293f7ede4a3dfe20f2821a7b)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
40f255565b |
Ein Knopf, der eine Mail verspricht, die niemand verschicken kann
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> |
|
|
|
1fd5d5f8d2 |
Was der Auftrag ausfuehrt, ist die Absicht von jetzt, nicht die von vorhin
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> |
|
|
|
02a5120b9c |
Der Weg zurueck geht durch die Vordertuer, und ein Fehlschlag bleibt sichtbar
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. |
|
|
|
a569d2215b |
Entzogen bleibt entzogen, und der Inhaber laedt sich nicht selbst ein
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. |
|
|
|
0e3a76a7cb |
Aus der Attrappe wird Verwaltung
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. |
|
|
|
973fcb3f2d | Replace native confirm() dialogs with the app's own modal pattern | |
|
|
9b8d5dfd1e |
Editing in modals, an update button that is not gated on a stale reading, and a support page that is real
tests / pest (push) Successful in 7m18s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 3s
Details
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> |
|
|
|
435a202fdd |
Match the panel to the approved template, measured rather than assumed
tests / pest (push) Successful in 8m49s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
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> |
|
|
|
85fcc154b8 |
Measure availability, and let a customer move down again
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> |
|
|
|
676c38643c |
Follow the approved template, and stop showing invented records
The dashboard was built to my own idea of the layout rather than to the template that was signed off: a coloured pill where the template has a stamped mono line, bare figures where it has a label with a chevron, a unit on its own line instead of beside the number, and no ring at all. There is no point agreeing a design and then building something adjacent to it. The metric card is now a component that draws the template's form, and the pages use it. Two more places were still showing data that does not exist: - Support listed three tickets to every customer, complete with reference numbers and dates. There is no ticket model; it was fixture text. A request list also belongs in the console, where an operator can see who filed what. Removed until there is something real behind it. - The datacentre name reached the customer through the cloud tab's copy as well. Customer surfaces name the jurisdiction; the building is operator information. And the actions column rendered an empty cell for the owner, who can be neither re-invited nor revoked. An empty cell reads as a missing feature; it now says so with a dash and a title. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a321d963d7 |
fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign
- 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> |
|
|
|
3b288486cd |
feat(portal): seats management — invite/role/revoke against plan limit
- 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> |
|
|
|
c4cff8f67c |
feat(portal): full sidebar — Cloud, Users, Backups, Invoices, Support
Each sidebar tab is now a full-page class-based Livewire component (R1/R2) with an English route (R13), localized DE/EN (R16), Chart.js islands and token styling: - Cloud: instance details + specs + storage-over-time line chart + actions. - Users: user table (roles/groups/status badges) + users-by-group doughnut. - Backups: 14-day size bar chart + backup history table + restore. - Invoices: invoice table + next-charge card + monthly-spend bar chart (locale- aware month labels, Number::currency amounts). - Support: contact cards + tickets table + FAQ accordion (Alpine). - Sidebar links all tabs with routeIs() active state; global toast in app shell. - All fixture dates/numbers locale-aware (Carbon isoFormat / Number::format). 12 new Pest tests (guard + render per tab) → 26 green. R12 browser: all six tabs HTTP 200 with ZERO console errors (Chart.js clean). Codex (R15) — clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |