137 Commits (feat/plan-reopen)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
b3651a14be |
Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.
Start impossible — not bookable, not upgradable, not offered
Team optional, via the `custom_domain` module
Business included, because the plan version carries the feature
Enterprise included, likewise
App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.
Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.
Enforced where it can be enforced today:
- BookAddon refuses the module, with the sentence the customer is shown.
GrantAddon now writes its synthetic order inside the transaction, so a
refusal cannot leave a paid order for a module nobody got.
- The portal drops the card where it cannot be booked and shows it as
included where the package carries it — no price, no button.
- Billing::purchase() re-checks, because a hidden button is markup.
- DowngradeCheck reports the consequence beside the blockers, so the
customer reads it above the button rather than after it: losing the
domain (Team -> Start), or the choice (Business -> Team, where keeping
it means booking the module).
- PlanChange::settleCustomDomain() applies it once a change lands: the
module stops being charged for and the instance goes back to its
platform address. It clears the whole domain, not only the verified
flag — clupilot:verify-domains re-reads every row that still has a
domain and a token, and would switch it back on the next night.
- The price sheet's own-domain row now reads dash / optional + price /
included, from the same authority; the three cell states already
existed and the view is unchanged.
STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:
abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.
Follow-ups, named rather than built:
- Nothing applies a plan change anywhere yet, so settleCustomDomain()
has no caller. Whoever builds that must call it after the new contract
opens, and must decide whether a booked module is carried onto the new
contract — enforce() cancels the booking on the contract it is given.
- Deactivation sets the state; it does not re-run provisioning. Traefik
and Nextcloud's trusted_domains still name the old address until
ConfigureDnsAndTls / ConfigureNextcloud run again.
- App\Support\Navigation still shows the Domain tab to every customer;
it can ask allowsCustomer() the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
517606b4db |
Split the price sheet into what everyone gets, what differs, and what costs extra
One boolean matrix was carrying three different kinds of statement, and it made all three unreadable. Updates, backup and monitoring were a tick in every column — a comparison that compares nothing — and the same promises were repeated in a prose line under the table. They are now stated once, above it, together with the platform address in the shape a customer will actually type (ihrefirma.<zone>, from the configured zone) rather than as the phrase "eigene Subdomain", which said nothing and was modelled as though an own domain replaced it. Provisioning issues both. Support was two independent booleans on one axis, so enterprise — which carries the SLA and not the priority flag — rendered "Bevorzugter Support —" beside team's tick: the top plan looking worse than the middle one. It is one row of levels now, and a plan with neither shows Standard, because standard support is a level and not an absence. A cell had two states, so a dash meant both "you cannot have this" and "you can have this for nine euros". There are three now, and the middle one quotes AddonCatalogue — which is also how the modules we have been selling all along finally appear on the page at all. Nothing is invented: where we sell no module for a missing feature, the cell stays a dash. custom_domain joins the module list at the owner's figure so the two plans without it can be quoted a price instead of told no. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d4e8822dbb |
Paketversionen: Verkauf wieder aufnehmen und saubere Übergabe
tests / pest (push) Failing after 7m58s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Eine geschlossene Version konnte nie wieder in den Verkauf. Schlimmer: um ein Paket zu ändern, musste die laufende Version zuerst beendet werden — und wenn die Nachfolgerin dann nicht sofort veröffentlicht wurde, war das Paket dauerhaft dunkel. - PlanCatalogue::reopen() setzt available_until zurück auf null, über schedule(), damit Family-Lock und Überlappungsprüfung dieselben bleiben. Ein nie veröffentlichter Entwurf wird abgelehnt. - PlanCatalogue::publish() übergibt eine laufende Version an ihre Nachfolgerin (available_until = deren Start) statt die Überlappung abzulehnen — unter demselben Lock, in derselben Transaktion. Nur die unmittelbare Vorgängerin, und nur wenn es genau eine gibt. - Konsole: Knopf „Wieder verkaufen", übersetzte Ablehnung statt der englischen Ausnahme, und die Übergabe wird im Veröffentlichen-Formular vorher angekündigt (Version und Zeitpunkt, ->local()). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8c105c17d6 |
Move each plan's marketing copy from the controller into the console
tests / pest (push) Failing after 8m10s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Audience line and note were LandingController::COPY, a hardcoded array keyed on exactly the four plan keys the seed migration created — a plan made under any other key rendered with no copy at all. They move onto the family for the same reason the recommendation mark does: they are a stance about the product line, not a capability that changes with a price, so they must not be frozen per version. Backfilled in the migration from the controller's own strings, so the public page prints exactly what it printed a moment ago. A family with no copy yet renders without the audience line rather than an empty gap. Feature labels (managed_updates, daily_backups, ...) stay in the controller: they name catalogue features, not individual plans, so there is no per-plan or per-version place to hang an editable customer-facing label without re-introducing a shared feature table. That table existed once and was deliberately removed when the plan catalogue closed its pricing split-brain; bringing it back for wording alone is a bigger, separate decision. |
|
|
|
4ef5b9519c |
Let the owner mark one plan as recommended, from the console
Recommended lives on the plan family, not a version: it is a stance about
the product line ("which plan we point a visitor towards"), not a
capability that changes with a price, and freezing it per version would
make the mark silently vanish every time a new version publishes.
Setting a plan recommended clears whichever other plan carried the mark,
inside one transaction locking the whole table — two operators
recommending two different plans at once must not both win. The public
price sheet already had the highlighted-card treatment for this (it was
driven by a hardcoded "team" constant); it now reads the family instead.
|
|
|
|
012d0f5092 |
Say EU, and stop promising to fix somebody else's deleted folder
tests / pest (push) Failing after 8m13s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two corrections from a read of the live pages. The location. Three public places named countries or a city — "aus deutschen und finnischen Rechenzentren" on the placeholder, "in Österreich und Deutschland" in the FAQ, "EU · Wien" in the product panel — and they did not even agree with each other. They all say EU now, which is the thing that is promised, the thing the AV-Vertrag pins down, and the thing that stays true when a host moves. The same string in the customer dashboard was worse: 'EU · Rechenzentrum Falkenstein' was hard-coded and shown to every customer whatever host their instance actually runs on. A specific claim nobody derived from the record. The closing headline asked who you call when a folder is missing on a Monday morning. It promised the wrong thing: what happens inside a customer's own Nextcloud is their own house — the recycle bin and the version history are right there — and presenting ourselves as the first call for somebody else's user error offers a service we do not run. It now asks who looks after updates, backups and outages, which is what the comparison section already says in the operator's own words and what the product is actually for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b843c10ec3 |
Give the update screen the product's colours, and only one window
tests / pest (push) Failing after 8m16s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Two complaints, both correct, both mine. Dark blue. The panel carried a `prefers-color-scheme: dark` block — the only one in a product that has no dark mode. On a phone set to dark the update screen, and nothing else, turned dark blue. A dark mode for one page is not a dark mode; it is one page that does not match. It is gone. And even in daylight the panel was not this product's colours: it used Tailwind's slate ramp (#0f172a, #64748b, #e2e8f0), which is blue-tinted, against neutrals that are warm. Every value is now a copy of a token from portal-tokens.css, with the comment saying so, because this file cannot import them — it renders when the asset build may not exist. The small window then the big one. The settings card grew its own little "läuft gerade" block the instant a run started; up to three seconds later the full-page overlay covered it and said the same thing in a different size. The card no longer says it at all, and the overlay now opens on the button's own click — requestUpdate dispatches an event, but only once a run is actually pending, so a refused second press does not black out the console. Two more things found while looking: The panel had `min-height: 100%`, which resolves against the parent's height. On the 503 page the parent is <body>, whose height is auto — so it computed to zero, the panel was only as tall as its text, and the bottom two thirds of the screen stayed the browser's default white. Viewport units now, dvh so a phone's collapsing address bar leaves no strip either. And the mark was a plain orange tile again, the same empty-square that was just fixed on the placeholder. The real logo is inlined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4755a4f362 |
Redesign the pre-launch placeholder, and fix its wordmark and mark
tests / pest (push) Failing after 8m7s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Reported from a phone on status.clupilot.com, which serves this page because the site is still switched to private. Two real defects behind the complaint: The wordmark read "Clu Pilot Cloud". `gap` on a flex container applies between EVERY child and a bare text node is a child — so an inline-flex row wrapped around "Clu", <i>Pilot</i> and " Cloud" put nine pixels between each of them. The wordmark is one element now and the gap only ever separates it from the mark. The logo was a plain gradient tile with nothing in it, which reads as an image that failed to load. That is a poor thing to leave somebody with on a page whose whole message is "we are building this properly". The real mark — cloud plus autopilot ascent — is inlined; it has to be in the file because this page renders when the asset build may not exist at all. The redesign itself: the card is gone. The page is now a centred composition on the open ground — the state as a pill above everything, the mark, the headline at the size the rest of the site uses, and the three promises the paragraph was already making set as their own line so there is something to look at besides one block of text. One warm bloom behind it, drawn as a gradient rather than a blurred disc, which banded into visible rings. Still self-contained, and still the only page exempt from the shared stylesheet: it is served mid-deploy and on a fresh install, where @vite would throw instead of rendering the reassurance somebody came for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b85df4c141 |
Rebuild the status page as a status page
Asked for after looking at how real ones are built. The shape they all share is a convention, and a convention is what lets somebody find the answer without being taught the page: banner, components each with ninety days of uptime, then the written record underneath. This page had only the banner — the version that is of no use the morning after, because "Alle Dienste in Betrieb" is true and says nothing to somebody who was locked out the evening before. Three things are new. The ninety-day bar. It cannot be reconstructed after the fact: the monitoring table holds the last verdict per instance, not a history. So a sampler (clupilot:sample-status, every five minutes beside the monitoring sync) writes counts into one row per component per day, and the page divides them. A day with no row is drawn as a gap and says "nicht aufgezeichnet" — never green. Degraded samples count as reachable in the percentage but still colour the day: folding them into downtime reports a slow morning as an outage, ignoring them loses the only trace of it. The incident record. An operator reports a disruption in the console, posts updates as it develops, and the entry that says "behoben" is the same action that closes it — two separate steps is how a page ends up with a resolved note under an incident that still reports as ongoing. There is deliberately no way to edit an update: it is a statement made at a time, corrections are a further entry. An open incident may make a component look worse than the probes found it, never better. Scheduled maintenance, from the windows the console already keeps rather than a second list somebody has to remember to fill in. The measurement moved out of the controller into ServiceHealth, shared with the sampler, so the banner and the bars cannot disagree about what "healthy" means. The page now uses the shared design system and the site's header and footer. Its self-contained stylesheet was there to survive a broken asset build; it does not survive one — if the stylesheet cannot be served the application serving this page is already failing, and mid-deployment every route answers with the maintenance page. What the exemption bought was a third design nobody maintained. Found while building: durationMinutes had its operands the wrong way round, and diffInMinutes is signed, so the public page printed "Dauer -86 Minuten". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3b85e8f1d1 |
Grant a package or module from the customer's row in the console
tests / pest (push) Failing after 7m55s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
GrantPlan is a modal (R20), opened from a "Schenken" button on the customer's own row in Admin\Customers, gated by customers.grant_plan rather than merely hidden. It offers a whole package (plan, term, datacenter, an optional quota bonus baked into the frozen snapshot) or a module on an existing contract, either free or at a discount the operator sets against today's catalogue price. Existing grants show on the same modal — who gave what, when, and until when — with a warning once a dated grant is nearing its end; nothing lapses on its own, exactly as decided. The customers list also badges a granted plan in the Paket column. |
|
|
|
c71b2c362b |
Skip the invoice for a full gift, keep it out of revenue, hide its price
IssueInvoice now rejects free-grant orders before it will consume an invoice number for them — a full gift produces no invoice at all, while a discounted grant still charges something and is invoiced exactly like an ordinary sale. Keyed on the linked subscription/add-on's own provenance, not on the amount being zero, so a genuinely fully-discounted Stripe checkout is unaffected. Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count — a gift at 0 must not drag the average down — and shows their number as its own KPI instead of folding it in, since the owner measures himself on it. The portal (Cloud, Billing) shows a granted package or module without a price at all: not "free", not struck through, just the service, so a later conversion to paid does not read as a negotiation. |
|
|
|
696c8834b8 |
Redesign the public website instead of recolouring it
tests / pest (push) Failing after 8m15s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Correctly rejected: the last pass was the old page in new paint. It kept the skeleton — eight numbered sections, a split hero, a twelve-row comparison matrix — and changed only the palette. A palette is not a design. What is actually different now: - The § numbering is gone. Numbered sections with a drawn rule are a document convention, and the document voice is exactly what the product's design system dropped. - The hero shows the product instead of describing it: centred type, then a wide panel built from the real console's own tokens, cropped by the section below. The old hero put a table of facts beside the headline, so a visitor read two columns before seeing anything. - Six identical cards, then six more, then six more became one asymmetric grid — a large panel, a dark tile, an accent tile, a wide module row — so the important thing is allowed to be bigger than the rest. - The industries are one dense two-column list instead of six cards repeating each other's shape. - The page has one dark hinge in the middle (the deployment) rather than a dark section among seven light ones. - The evidence table became rows that pair the measure with the piece of paper you can hand somebody, which is what it is for. - Prices are cards, with the full matrix folded away underneath. The matrix used to be the first thing a visitor met: twelve rows of ticks before a single price was legible. - The FAQ heading sits still while the questions scroll past it. - The footer is a real footer. The header is a floating bar rather than a full-width band, and opaque in both states — translucency plus backdrop-blur reads as a rendering fault over the dark panels that scroll under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
43d58de7f8 |
Rebuild the public website in the shared design system
Reported plainly: the site is still in the old style. It was — it was the last of the three surfaces the token file describes that had never been converted. landing.blade.php carried its own complete design system inside a <style> block: warm paper, IBM Plex Serif headlines, 3px corners, its own scale of greys, written before the tokens existed. Somebody who clicked "Anmelden" left one company and arrived at another. The page now draws from resources/css/portal-tokens.css like the portal and the console: same surfaces, same radii, same button hierarchy, same small-caps label, one family at weight 700 instead of a serif. What still differs is rhythm, which is what a marketing page is allowed to differ in. Content is unchanged. New: components/layouts/site.blade.php holds the header and footer for every public page, and legal.blade.php uses it too — the imprint used to be a stranded card in a third style again. Two things found on the way: - .lbl set `color: var(--muted)`, which is not a token. An undefined var() in `color` computes to `unset`, so every small-caps label on every surface was whatever colour its container happened to be. - The big figures were set in mono. Mono gives the decimal comma a full glyph cell, so "99,95" read as three separate numbers. coming-soon and the status page stay self-contained on purpose — both are served when the application may not have a built stylesheet at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0671e8e119 |
Show one design while updating, not two swapping mid-run
Reported from live: pressing update showed a blue panel first, which then vanished and was replaced by a completely different "we are updating" screen. Both were mine. The console drew its own overlay in the app's light tokens; a moment later the containers went down and Laravel's 503 page took over with its own standalone styling. One event, two faces, swapping while an operator watches. There is now one panel — resources/views/partials/updating-panel.blade.php — rendered by both. It carries its own inline CSS and cpu- prefixed class names because the 503 page is served with the application down: no compiled stylesheet, no Tailwind, nothing but what is in that file. Dark mode comes from prefers-color-scheme for the same reason. The operator detail (step, running since, log tail) rides inside the panel behind a flag, since the same file is shown to customers on the 503 page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
15b536f393 |
Mint the archive collection key from the console instead of by hand
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Setting a NAS up to collect the invoice archive meant three machines and a dozen commands: generate a keypair there, carry the public half to the server, write it into authorized_keys with a restriction, install rsync, and get every path right on the first try. Every one of those steps was a place to be told "permission denied" with no clue which of the three was wrong. It was, and several times over. One button now. The panel asks, the host does it — because the host is where all of it lives: the home directory, ssh-keygen, rrsync, and the archive itself. The panel is www-data in a container and owns none of that, so it uses the mailbox it already uses for updates. The private half is shown exactly once, alongside the finished rsync command, and is never written to the database: it exists to be copied into a NAS, and storing it "for convenience" would put a working credential in every backup of that database. rrsync does the restricting, not a pinned rsync option string. That string differs between rsync versions and fails silently — a refusal with no reason given, which is the shape of the afternoon this replaces. If rrsync is missing the agent refuses rather than issuing an unrestricted key while the panel says it is restricted. rsync now comes with install-agent.sh, which already runs as root once per machine. It has to be on the HOST: a NAS connects by ssh and sshd starts `rsync --server` here, so without it the pull fails with "command not found" from a NAS whose own setup is perfectly correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d8ff717cb5 |
Let a destination say how it is laid out and how long anything stays
tests / pest (push) Failing after 7m29s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two destinations doing two jobs. A handover directory a NAS collects from wants a folder per day and wants emptying — thirty days is enough to notice a NAS that stopped collecting, and keeping more only hides it. An archive on the NAS itself wants a folder per year and wants nothing deleted, ever. So both are per destination rather than global. Deleting is safe here in a way it usually is not, and that is worth saying plainly: the invoice is a frozen document in the database and its PDF is rendered from that on demand. Nothing is moved anywhere — it is copied, and anything removed from a directory can be produced again and re-exported. The directory is a handover point, not the record. keep_days is null by default and stays null unless somebody sets it: a retention rule nobody asked for is a deletion nobody expected. The prune only touches folders whose NAME is one of the two shapes this application writes, and leaves everything else alone — a prune that deletes what it does not recognise is how an archive loses something nobody was watching. It also skips a destination whose path is missing rather than acting on whatever is underneath an absent mountpoint. The shape is matched with a pattern before parsing rather than by handing nonsense to Carbon: under strict mode createFromFormat throws instead of returning false, so one folder somebody made by hand would have ended the whole prune instead of being skipped. The test with a foreign folder in it found that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0528b56a32 |
Name no vendor in the export settings
tests / pest (push) Failing after 7m36s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A destination is a kind of connection, not a product. The wording said "Hetzner Storage Box" and the host field suggested one particular provider's hostname format, which reads as a requirement rather than an example — and the whole point of the directory kind is that whatever is behind it is nobody's business here. Both kinds stay. A directory is how an NFS 4.1 mount is used: the mount is made on the server, and the application writes into the path it produces. SFTP is for a destination that is not mounted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b3081a544b |
Let the export have as many destinations as somebody wants
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
One path and one exported_at could not say "on the office NAS, not yet on the off-site box" — which is the only interesting question once there is more than one destination, and the reason for a second is precisely that any single one can fail. A row per destination now, and a row per (invoice, destination) pair. Two kinds, and the difference is where the protocol lives. 'local' is a directory this machine can already write to: a NAS mounted over NFS 4.1, a bind mount, a disk. The application knows nothing about the protocol, the target changes without code changing with it, and the whole thing stays testable against a temporary directory. 'sftp' is for something reachable with credentials — a Hetzner Storage Box being the obvious one — and the password goes into the secret vault with only its key on the row, because a credential in a settings table is a credential in every database dump. An empty password field on an existing target means unchanged, not cleared: the field cannot show what is stored, so blank-means-delete would wipe it on every unrelated edit. One job per pair rather than one per invoice. A job writing to both would retry the one that worked every time the other did not, and report one outcome for two different things. The ordering fix the tests found: Laravel's local adapter creates its root when it is CONSTRUCTED, so the "never create the root" guard was checking a directory the disk had just made for it. It runs before the disk is built now. That guard is the difference between an archive and a hole in the ground — mkdir on an absent mountpoint succeeds, writes onto the container's own disk and reports success, and an archive that silently is not the archive is worse than none. The single path from the previous commit is migrated into a destination called "Archiv" rather than dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ab4b0311d4 |
Copy every invoice to the archive when it is issued, and notice when that fails
tests / pest (push) Failing after 11m24s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The application knows nothing about NFS and should not. It writes to a directory; whether that is a local disk, a bind mount or a NAS over NFS 4.1 belongs to the mount. No protocol library, the target changes without code changing with it, and the whole thing is testable against a temp directory. The finding that shaped it: mkdir -p on a path whose mount is NOT there succeeds. It creates the empty directory beneath the mountpoint, writes the invoice onto the container's own disk and reports success — so an unmounted NAS would quietly collect invoices locally while every check said it worked. An archive that silently is not the archive is worse than none. The root is therefore never created, only used: a missing root means a missing mount, and that is a failure. Only the year subdirectory is created, inside a root already proven to exist. Written to a .part and renamed. A rename within one filesystem is atomic, so whatever watches that folder never sees a truncated PDF and takes it for a finished invoice. The size is read back afterwards rather than the write being trusted: over a network mount a short write can report success, and an archive of truncated files looks exactly like one that worked. Queued, not done during the request. A network mount can block for its whole timeout, and an invoice must not fail to be issued because a NAS is rebooting. Dispatched after the commit, or a worker could look for an invoice that has not landed yet. And copy-on-issue is not enough on its own: an invoice issued while the office connection was down never arrives, the queue eventually gives up, and nobody opens an archive to check whether last Tuesday is in it. An hourly sweep re-queues whatever is still missing, the failure is recorded on the invoice rather than only in a log, and the Finance page says how many are outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
33e5b15099 |
Restart the workers automatically after saving .env, instead of handing the operator back to the shell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
86bf4f26e0 |
List issued invoices in the console, with no way to change one
tests / pest (push) Failing after 7m34s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Read-only by design, and the absence is the feature: an issued invoice is corrected by cancelling it and issuing another, which is the only lawful way to correct one. There is no edit button here whose absence needs explaining, and a test asserts there is none. The PDF is a plain route rather than a Livewire action. A download is a download, and routing one through a component round-trip only adds a way for it to fail. Two findings on the way, and both were mine. The year filter used YEAR() in raw SQL, which is MySQL's. SQLite has no such function, so the page worked against the real database and threw against the test one — a query that only runs on one engine makes the test database a different product from the one being shipped. The years are derived in PHP now. And a test put a User on the operator guard, where EnsureAdmin calls isActive() on it and a 403 becomes a 500. Not a code defect: actingAs() calls Auth::shouldUse(), so a second actingAs() in the same test with no guard named lands on whichever guard the FIRST one named. That trap is written up in CLAUDE.md, I quoted it earlier today, and I walked into it anyway. The tests are split now and both name their guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
92bcdcd186 |
Send the invoice with the invoice attached
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
The PDF is rendered when the mail goes out rather than fetched from anywhere, because nothing stores it — and rendering from the frozen snapshot means the attachment is the document as issued no matter when the queue gets to it or how often it retries. fromData rather than fromPath: there is no path. Three attempts at testing it, and the first two were wrong in ways worth recording. Attachment::$data does not exist — the closure lives in a protected $resolver, and reaching for it was me testing the framework's internals rather than my own code. And a byte comparison of the PDF cannot work at all: TCPDF embeds a creation time and a document id, so two renders of one invoice differ. Split into the two things that can actually go wrong. The mail carries an attachment with the right filename and type — that is metadata, and it is asserted as metadata. And the renderer produces a real PDF rather than an empty one, asserted where the renderer is, because an attachment closure that returns nothing still makes a perfectly valid mail with a nought-byte file on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cd54212b34 |
Issue an invoice from what somebody bought, and freeze it there
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
One invoice per purchase rather than per order: a customer who buys a plan and two add-ons in one go has bought once, and three invoices for one purchase is three times the paperwork for the same money. Each order becomes a line, which is also how the add-ons get listed individually. Everything the document says is copied into the snapshot at the moment the number is assigned — issuer, recipient, lines, rate, the reason for the rate. A test changes the company name and the customer name afterwards and asserts the invoice still says what it said. That is the whole reason no PDF is stored. The number and the invoice commit in one transaction. A number taken and then lost to a failure is a gap in a series that must not have one, and the refusal test asserts that a rejected invoice leaves the counter where it was. It refuses outright until the company details are complete. An invoice without a registered name, an address or a VAT number is not a valid invoice here, and issuing one consumes a number that can never be handed out again. VAT comes from TaxTreatment, which already existed and already handles a verified EU VAT ID correctly — including the reverse-charge note, without which a zero-rated invoice says nothing about why no VAT was charged. The billing address is carried as the lines it was written as. It is one free-text field today, and guessing which line is the postcode would put it where the street belongs, on a document nobody can correct afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4646880b90 |
Print every line at full price and take the discount off once, in euros
tests / pest (push) Failing after 7m40s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Second correction to the same block, and this one came from the right place: a line reading 202,50 beside "Rabatt 10 %" does not say whether the discount is in that number or still to come. It was unreadable, and unreadable on the one figure a customer checks. Lines are at full price now. Discounts are still entered per line — that is where they belong and where somebody decides them — but they are summed and shown once, in euros, under a subtotal that the "Gesamt netto" column adds up to. Subtotal, one subtraction, VAT, total: a sum anybody can follow without being told which figures are already adjusted. The gross line-total column is gone and a gross UNIT price takes its place. Full-price gross totals would sum to 315,60 against a total of 288,60 — the same contradiction in a different column. Nobody adds up unit prices, and a consumer reading this wants to know what one of the things costs. lineTotal() and adjustmentLabel() went with it. Nothing calls them any more, and a helper kept for a layout that no longer exists is the next person's wrong turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d21e4f0eb2 |
Put a discount on the line it belongs to, so the columns add up
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
With one discount applied under the table, the gross column summed to 315,60 against a total of 284,04. Nothing was miscalculated — the discount simply came after — but a reader who adds up a column found a document that does not agree with itself, and no way to tell which figure was the real one. On an invoice that is not a presentation detail. A discount or surcharge now sits on its line, percentage or fixed amount, and is inside that line's total. The label under the line is built from the same value that did the arithmetic, so the words cannot end up describing a different number than the one printed beside them. The invoice-wide adjustment stays in the arithmetic for anything that genuinely applies to the whole document, but nothing uses it by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
90131f088a |
Add a raw .env editor to the Integrations page, with a net under it
Everything the console has no field for is still real — MAIL_*, DB_*, APP_KEY, whichever key nobody has built a form for yet — and without a way to touch it from here, the operator needs a shell and the console page misses its own point. EnvFileEditor is the net, not just the warning: 1. Validates before writing. A line that is neither blank, nor a comment, nor KEY=value is rejected outright, and an empty file (syntactically "valid" by that rule, but not survivable) is refused too. write() never touches the file before checking the new content. 2. Backs up before every write that actually happens — a timestamped copy beside .env, before the new content lands. Never pruned automatically; the page says so, next to where it says where they land. 3. Names the keys a mistake here can lock an operator out with — APP_KEY, DB_*, REDIS_*, SESSION_* — rather than a blanket warning nobody reads. 4. Gated by secrets.manage and the same confirmed password the vault entries use — this is the one place on the page that can reach every credential the vault otherwise keeps write-only. 5. Says plainly what saving does not do: queue, queue-provisioning, scheduler and reverb only read .env at their own startup, and names the restart command plus config:clear. 6. Marks which .env keys SecretVault currently overrides, so editing a line that a stored vault value already shadows does not look broken. Two issues surfaced by Codex review and fixed before this commit: the raw file content stayed in the Livewire component snapshot after the password confirmation window expired on its own (not only on an explicit re-lock), and the first save on an installation with no .env yet failed trying to back up a file that was never there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3fadaa14b0 |
Merge credentials and infrastructure into one Integrations page
Zugangsdaten and Infrastruktur split the same subject by storage mechanism — SecretVault vs App\Support\Settings — instead of by what each field configures, which is not a distinction an operator setting up Stripe or DNS should have to know or care about. One page now, grouped by integration: Zahlungen (Stripe), DNS (Hetzner), Monitoring, VPN/WireGuard, SSH-Identität. A secret and a plain setting sit side by side within a section; the difference stays visible — a vault entry is write-only and shows only an outline, a setting shows its value in full — as a property of the field, not a reason for a separate page. Both storage mechanisms are unchanged underneath: this is a presentation change, not a data migration. The vault keeps its own gate (secrets.manage + a recently confirmed password); plain settings keep theirs (hosts.manage alone). Reachable with either capability, since the two are no longer two pages a role happens to see one, both, or neither of — every section and every save action still checks its own capability server-side. admin.secrets and admin.infrastructure redirect here permanently rather than 404ing a bookmark. The nav entry that replaces both is visible to either capability — Navigation's capability field now accepts an array meaning "any of these", not only a single ability. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f8c923b48a |
Stop the invoice printing my own indentation
tests / pest (push) Failing after 8m3s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
TCPDF takes leading whitespace in HTML literally, so every line of the address and every line description came out indented by however far the Blade template happened to be nested — and the one line that had no indentation, the VAT number, sat flush against the margin looking like the mistake. It was the only correct one. Also puts the title and the invoice number in one block. As an h1 with a paragraph beneath it, TCPDF gave the heading a margin of its own and the two drifted a centimetre apart. Found by rendering the page and looking at it, which is the only way any of this is ever found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c1896e70fc |
Render an invoice, with the arithmetic held to integer cents
tests / pest (push) Failing after 7m9s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Has been skipped
Details
TCPDF 6, deliberately, after installing 7 and finding out what it is. Version 7 is not a newer TCPDF; it is a compatibility shim over tc-lib-pdf whose own mapping table lists 115 of 293 methods as stubs, and whose font package ships no font files at all — the first render died on a missing helvetica.json. The 6.x line carries a hundred and sixty-five fonts, a working writeHTML and real header and footer hooks, and is what everybody means by TCPDF. The body is a Blade template handed to writeHTML rather than a hundred Cell() calls with millimetre coordinates, because this table has to be adjusted the first time somebody sees it on paper, and nobody adjusts the second kind. InvoiceMath is integer cents end to end, and rounds once. A percentage applied line by line and rounded each time drifts from the same percentage applied to the sum — three lines at 3,33 € less 10 % is 99 cents that way and 100 cents the honest way, and the customer's calculator is what finds it. Gross is always derived from net and never the reverse: taking VAT back out of a gross figure returns a different number often enough to matter. A discount and a surcharge are one operation with opposite signs, so nothing has to remember which of two flags means which. Quantities are thousandths, because hours, gigabytes and part-months are all real, and are printed with the trailing zeroes stripped — "12,000 Std." reads as a defect. Every line shows quantity, unit price net, total net AND total gross: this goes to businesses and to consumers, and each reads a different column first. Verifying it took two goes and both failures were mine. `strings` over a PDF set in a Unicode font finds nothing, because the text is stored as subset glyph ids — the check has to render in a core font to be a check at all. And two of the strings I then declared missing were simply expectations I had got wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
77bd30ca56 |
Answer on every name for the website, and send them all to one
tests / pest (push) Failing after 7m23s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
SITE_HOST takes a comma-separated list now, the first name canonical. It serves the site; every other name in the list redirects there permanently, path and query intact. An apex domain and its www are two names for one thing, and answering on both without picking one splits search rankings and cookies between them. The redirect is a host-bound catch-all rather than middleware. Middleware that only some routes carry is the same half-measure this file already made once, and a catch-all registered without a host would swallow every path in the application — which is what the third test is there to stop. The test harness needed fixing too, and the fix is the finding. Router::dispatch on a standalone router does not rebind the container's request, so a route reading one — through the helper OR through an injected parameter, both of which resolve from the container — gets the test's request rather than the one being answered. The redirect silently lost its query string in the test while working in production. dispatchOn() binds it where the framework would. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e6d2e6dc33 |
Give the console a Finance tab: company details, VAT rate, invoice series
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Its own tab rather than a section of Settings, as asked. What is set here appears on a legal document, and burying it beside the site-visibility switch invites somebody to change one in passing. Everything the printed invoice needs — registered name, address, Firmenbuch number and court, VAT ID, bank details, payment terms, logo — with the logo replaceable, because a company changes its mark. None of it reaches an invoice that already exists: the values are copied into each document when its number is assigned, so a corrected address is correct from the next one onwards and cannot rewrite an old one. The console refuses to consider itself ready while a name, an address or a VAT number is missing, and says which. An invoice without them is not a valid invoice in Austria, and issuing one is worse than refusing to. The series editor carries the same shape as EditDatacenter and for the same reason. The prefix is free while the series is empty and frozen once a document carries it: changing RE to AR renames nothing and leaves a series whose past says RE and whose future says AR, with no record they belong together. The counter may be raised — an operator migrating from another system needs exactly that — but never lowered, because a lower number is one already printed on a document somebody holds. Both rules are recomputed from the database on save rather than read back from the properties the browser returns. CompanyProfile drops keys that are not part of the profile. It is fed from a form, and a forged key must not be able to write an arbitrary setting into the same table the site-visibility switch lives in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
864126ec7f |
Add the SSH identity to the vault, and give deployment config a console page
tests / pest (push) Failing after 7m28s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The SSH private key CluPilot deploys to every host is now stored through SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH) at all three real consumers — SshTraefikWriter and HostStep::keyLogin. kuma.password/kuma.totp stay in .env: they authenticate a separate Python container at boot, and nothing in this app reads them, so the vault would have nothing to wire them to. A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings, makes the non-secret deployment settings that used to force a shell — DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config path, the SSH public key, and the monitoring bridge URL — visible and editable from the console, each wired to every real consumer. WG subnet/hub IP and Kuma's own connection details stay in .env: the compose file and a separate container read those before this application ever does. |
|
|
|
7a5a71dc89 | Read VPN host state from the synced peer table, not a live hub call | |
|
|
b1ab0f7f31 |
Put every mail in one design, and confirm an order when the money arrives
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Four templates, one layout. Two of them — the maintenance announcement and its cancellation — still wore Laravel's markdown component, which brings its own logo, its own button and its own footer: the product was sending mail that looked like two different companies, one of which was the framework. New: an order confirmation, sent when the payment lands rather than when provisioning finishes. Those are minutes apart at best and can be much longer, and somebody who has just been charged and heard nothing assumes the worst about both the charge and the product. It deliberately promises no time for the cloud itself — provisioning takes what it takes, CloudReady announces the end, and a promised minute that slips is worse than no promise. Queued after the transaction, never inside it, and never allowed to fail the webhook: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning over a mail server having a bad minute. The finished-cloud mail now names what was actually built — address, plan, storage, location — because somebody who ordered a fortnight ago is checking it against what they bought, and that is a comparison rather than a sentence. Still no credential in it. Two tests earn their place. One holds every template on the shared layout, so a new one written in a hurry cannot copy-paste its way back to the framework's. The other proves the finished-cloud mail carries no password — asserted against the data the mail is given and the HTML it renders, not against the template's source, because the first version of that test tripped over the word in a comment and would have passed a template that leaked the value through a variable. It tested the prose, not the mail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
eb743d34ed | Show WireGuard tunnel state per host in the hosts list | |
|
|
5b8c28595a |
Require a confirmed address before an account can use anything
Registration opened an account that could sign in immediately on an address nobody had proved was theirs — and anybody can type a stranger's into a registration form. Both halves were commented out: the Fortify feature and MustVerifyEmail on the model. `verified` sits on the whole portal group rather than on the pages that spend money. An unconfirmed address is not a billing problem to be caught at checkout; it is an account that may not belong to the person holding it, and the servers, the users and the backups behind it are worth as much to whoever typed the address as to whoever owns it. The mail is a Mailable in this product's design rather than Laravel's notification, which would arrive with the framework's logo, button and footer as the first thing anybody ever receives from CluPilot. It also has to leave through the SYSTEM mailbox like every other account mail, which the notification path does not do on its own. The link is signed and expires, and the address is part of what is signed — so a link issued to a mistyped address stops working the moment the address is corrected, rather than confirming an address nobody can read. Tests cover the tampered link, the expired one, the corrected address, and one account trying to confirm another. The waiting page is ours (Fortify has views off) and offers the only two things that move somebody forward: send it again, or sign out and register with the address they meant. The resend is throttled, because otherwise it is a button that makes the server send mail to an arbitrary address as fast as it can be clicked. Somebody already confirmed is redirected away from it — the `verified` middleware only guards the other direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
07141a96bf |
Show where an account is signed in, and let it sign the other places out
Completes the device work: the warning mail now has somewhere to send people. A warning whose only advice is "if this was not you, take action" names a problem and hands back nothing to do about it. Both sides get their own component rather than one shared with the guard passed in. Operators and customers are two guards and two tables (R21) — a component taking the guard as input is one forged property away from listing and ending the other side's sessions. Every query in SessionRegistry is scoped by guard as well as by id for the same reason, and three tests hold that line, because the unscoped version looks exactly like a working feature while doing it. TouchLoginSession keeps each row pointing at the session it describes. Both sign-in paths raise the Login event BEFORE session()->regenerate(), so the row is written against an id that is dead a moment later — and the once-a-minute throttle would have held the correction back for the first minute of every session, hiding the session its reader is sitting in. A changed id is therefore never throttled. A session counts as live only if the framework's row exists AND was touched within the session lifetime. The join alone is not enough: Laravel collects expired sessions by lottery, so on a quiet installation rows sit there for days and the list would offer somebody a laptop they shut last month. Ending the others goes through the product's own modal (R23) and leaves the asking session alone: signing somebody out of the page they are using to sign other people out is not what the button says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2609393e3a |
Recognise the devices an account signs in from, and warn about a new one
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Sessions lived in Redis, where they cannot be enumerated — one key per session, no index by user — so "where am I signed in" and "end that one" were not answerable at all. The database driver keeps a row per session and makes ending one a delete. It costs every currently signed-in person one forced sign-in, once, which is why it is happening now rather than later. The warning is only worth anything if it is rare, so the two obvious signals are deliberately not used. Not the IP: it changes on every train, every café, every mobile handover, and behind a VPN it is not theirs to begin with. Not the user-agent: Chrome ships every four weeks and the string changes each time, which would be twelve warnings a year per customer, describing nothing. A device is a long-lived signed cookie holding a random token, and nothing else is consulted. The honest cost — cleared cookies, a private window, a second browser — is written into the mail itself, because a reader who thinks the warning is wrong stops reading the next one. Three things the tests found or hold: The first device on an account is new and must not warn. Replying to somebody creating an account by telling them they signed in from a new device is noise on the one message that has to stay worth reading. token_hash was globally unique, and the shared-browser test failed on it. Two people on one machine share one cookie and must each own a device row against it; the obvious repair — issue a fresh token for the second — would orphan the first person's row and warn them about their own desk. Unique is now per identity. Recording a device may never block a sign-in. A safeguard that locks the owner out when it breaks has stopped being one, so the listener logs and swallows, and a test drops the tables to prove it. Sessions are linked by a uuid carried inside the session payload rather than by the session id, which Laravel rotates on every sign-in and every regenerate(). Laravel's own sessions.user_id cannot carry the link either: it is filled from the default guard, while operators and customers are two guards and two tables (R21) — operator 1 and customer 1 would each be offered the other's sessions. Also lands the shared mail layout the rest of the templates will use: tables and inline styles for Outlook, no image and no web font because half of all clients block remote content, and the button in --accent-active rather than the brand orange, which is 2.9:1 against white. Not tagged: the session list is not built yet, and without a tag no server can install this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
75b4a47768 |
Stop mailing the initial admin password, hold it in the panel until noted
The password no longer travels through third-party mail servers or sits forever in an inbox: it moves onto the instance (encrypted, same as nc_admin_ref) and shows on the dashboard until the customer clicks "Notiert", which deletes it for good. |
|
|
|
21b7aaca42 |
Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Asked for directly: a commit on main must change nothing on a server, and only a tag whose version is higher than the installed one may be installed. The agent asked two different questions before this. A pinned server compared tags; everything else counted commits on its branch — so a console following main offered an update for every push, verified or not, and "Jetzt aktualisieren" meant "take whatever is on the branch right now". Landing on main and being released are different events, and only the second is a decision somebody made. It is one question now, regardless of what the checkout is attached to: is there a tag whose version is higher than the deployed one? Compared with sort -V against the version in the deployment manifest — not the VERSION file, which an update moves before it migrates, so a run that failed in between would leave the checkout claiming a version it never finished installing. A run always targets that tag; the branch path is gone. A request that arrives with nothing to install is answered rather than obeyed, before anything announces a run. The console follows: the button is disabled with nothing released, says so in a line rather than leaving a grey button to be guessed at, names the version it would install rather than a count, and refuses the action server-side as well — a Livewire action is a public endpoint, and one place a rule may not live is only in the disabled attribute of a button. VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
184350494d |
Let a half-finished two-factor enrolment be cancelled, and rework the setup page
tests / pest (push) Failing after 7m39s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Found uncommitted in the working tree at the start of this session and carried in as its own commit so it can be reverted on its own. Not written here: a cancel action that clears an unconfirmed secret, the wording for every state of the page, and a rebuilt setup view. The suite is green with it in place, including the R18-R23 scan tests. |
|
|
|
d65ab71029 |
Let a datacenter code be corrected while nothing depends on it, and say which building
The code was immutable, which made a typo permanent. It cannot be freely mutable either, and not only because hosts.datacenter references it: each host's DNS name was minted from it and registered at the provider, the machine's own hostname was set from it as it was built, the counter handing out those numbers is keyed by it, and every past order stores it as plain text with no foreign key to follow. Renaming a code with hosts in it is not a rename — it is a datacenter called hel full of machines called fsn-01. So it is editable exactly while nothing references it, which is the case that matters: a typo noticed shortly after creating one. Once a host or an order depends on it the field is disabled and says what is holding it, with the numbers. The lock is recomputed from the database on every save, never read back from the property the browser returns. Alongside it, an optional free-text field for the specific datacenter — fsn-dc-15. A provider runs several within one location, on separate power and separate uplinks, and two entries both reading "Falkenstein (fsn)" are not a choice anybody can make. Nothing keys off it; the code remains the identifier. Also renames the country loop variables in the edit modal. They were $code and $name — the component's own properties — and shadowed them for the rest of the file. Harmless while nothing below the loop read them, which stopped being true the moment the code became an editable field above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
07634c8a1d |
Answer a check at once, start an update at once, and count down to neither
Checking for updates was a request written into the same mailbox as a real update, collected by the same agent on the same systemd timer — so asking what was new was answered on the next tick, and the console, having nothing else to show, counted down to it. An operator who asked a question was told an update would start in 3:44. A systemd path unit now wakes the agent the moment the request file is written, for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms as soon as the service goes inactive, so a request the agent could not consume — it holds a lock for the length of an update — would restart it in a tight loop. The timer stays as the fallback, and a drop-in disables systemd's start rate limit, because two triggers on one oneshot service can otherwise wedge the unit and leave neither of them able to run. The countdown is gone from both the settings card and the maintenance overlay. It could not be made honest: its target was recomputed on every poll, and where the interval the agent reports is shorter than the timer actually installed on that host it had already gone by, so the fallback moved it forward again each time. It ran backwards four seconds and snapped back to a full minute, forever — reported exactly that way. A queued run says it is starting; a queued check says nothing beyond its badge, because a check starts nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
64efe45f0d |
Recover silently from an expired session, and show a connection banner when offline
tests / pest (push) Failing after 7m22s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
bda61ca560 | Pin the sidebar header and footer so only the nav list scrolls, and shorten two action labels that wrapped | |
|
|
f05547921d | Separate checking for updates from applying them, and show live progress | |
|
|
973fcb3f2d | Replace native confirm() dialogs with the app's own modal pattern | |
|
|
c2681f2801 |
Show a full-screen overlay on every console page while an update runs
tests / pest (push) Failing after 7m27s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
254674f456 |
Bound the mail test-send and real send to a timeout instead of hanging
tests / pest (push) Failing after 7m25s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A blocked outbound port (587 filtered at a firewall, as on the live server) left both MailboxTester and MailboxTransport blocking until PHP's default_socket_timeout (60s) or the reverse proxy cut the request — a 504 with no error the operator ever saw. Mail::build() honours a 'timeout' config key by calling SocketStream::setTimeout() (confirmed by reading MailManager::configureSmtpTransport() and by an end-to-end run against a genuinely unroutable host), so MailboxTester now passes 'timeout' => 10 through it. MailboxTransport builds its EsmtpTransport directly rather than through Mail::build(), so it sets the same call on the transport's stream, at 30s — a queue worker can afford to wait longer than an operator watching a browser, and a timed-out send is retried rather than lost. The test button also now shows a visible "sending" state while the request is in flight, matching the wire:loading span pattern already used on admin/host-create's save button — previously only wire:loading.attr="disabled" fired, leaving a greyed-out button with no other sign anything was happening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
27292237c3 |
Move operator two-factor enrolment off admin.settings onto its own page
RequireOperatorTwoFactor exempted the whole of admin.settings while compulsory two-factor was on, 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. Enrolment (secret, QR, confirm, recovery codes, regenerate, disable) moves to its own route and component, admin.two-factor-setup / Admin\TwoFactorSetup, reachable by every operator regardless of capability. That is now the only page the middleware exempts besides logout, and its redirect points there instead. admin.settings goes back behind the policy like every other console page; only the policy switch itself stays there, since only someone who already satisfies it should be the one changing it. |