271 Commits (v1.3.5)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
f8f0de4892 |
Let an update install host packages, without handing out root
Asked for directly: "später wenn ich etwas lokal erweitere will ich mich nicht auf alle adminseiten einloggen müssen und extra rsync installieren". An update runs as the service account, so anything root-owned on the host is out of its reach and turns into "log into every server once and paste this". The obvious fix — sudo on deploy/install-agent.sh — is not a grant at all: the service account owns the checkout and can rewrite the very script it would be allowed to run as root. So the privileged part is written OUT of the checkout by install-agent.sh, to /usr/local/sbin/clupilot-host-step, root:root 0755, from a quoted here-document. The service account cannot influence a byte of it. sudoers names one exact command line including its argument, so a step added to the helper later is not covered by a grant written before it existed, and the helper refuses anything not on its own list as well. update.sh uses it only when rsync is actually missing, after the restart and never fatally: the archive is collected hours later, and an update that died over a package would be the bigger problem. A helper older than the steps the updater wants is reported through the existing one-time-setup hint — silently doing nothing is worse than an error. Existing servers still need `sudo bash deploy/install-agent.sh` once, because that is the run that puts the helper there. After it, they do not. 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> |
|
|
|
132be7fdd5 |
Leave the archive readable by whoever collects from it
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Reported as "the folder arrives empty". It was not empty. The folder was 0700 and owned by the account that wrote it, so the account that COLLECTS — an rsync over ssh, a backup agent, anything that is not the writer — could not enter it, and an unreadable directory looks exactly like an empty one. The invoice had been sitting in it the whole time. The cause is that the default follows the process umask, so under a restrictive one 0755 quietly becomes 0700. The permissions are declared on the disk now rather than left to inherit, and a test writes under umask 0077 and asserts the mode — the ordinary umask hides this completely, which is why it was found on a server and not here. Two mistakes of mine on the way to it, both worth recording. I read the folder as the wrong user, got no entries back, and concluded the file had been deleted — it was a permission failure reading as an unprivileged account, not a missing file. And the first fix was a chmod on a variable that does not exist in that method, silenced by an @ so it failed without a word. Flysystem takes its permissions from configuration; a chmod afterwards was the wrong layer even before it was the wrong variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
79564947d4 |
Actually issue the invoice when the money arrives
tests / pest (push) Failing after 7m24s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Everything was built — numbering, the frozen snapshot, the PDF, the mail, the archive and its retention — and none of it was connected to anything. IssueInvoice was called from nowhere but its own tests. So on a real server no invoice had ever been created, the handover directory was empty, and rsync was correctly copying nothing. Reported as "nothing gets copied", which it was. One mail, not two. The invoice is issued the moment the payment lands, so an order confirmation and an invoice mail would otherwise go out in the same second for the same event — a defect rather than a preference. The invoice mail carries the document and says everything the confirmation said, so it replaces it whenever there is an invoice; the confirmation stays for when there cannot be one. Issuing is allowed to fail without taking the purchase with it. IssueInvoice refuses while the company details are incomplete, and that refusal is correct — an invoice without a registered name or a VAT number is not a valid invoice, and issuing one consumes a number that can never be handed out again. The order still stands, the machine is still built, and the invoice can be issued later. Nothing here may throw at the webhook either: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning and build a second machine over a missing VAT number. 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> |
|
|
|
f0ec2fe03f |
Keep the SFTP credential on the row it belongs to, encrypted
tests / pest (push) Failing after 7m22s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
The first version put it in the credential vault. The vault refuses it, and correctly: it keeps a registry of fixed, documented credentials so the Secrets page can describe each one, and a per-destination password is not that. Forcing a dynamic key into that registry would have made the registry meaningless. Encrypted onto the row with the same cipher instead — the same key, so it is no more readable in a database dump than anything the vault holds, and it lives with the record it describes. A rotated SECRETS_KEY now says so by name rather than surfacing as an authentication failure against a host that is perfectly fine. Also answers the question that prompted this: the host, port and user fields are there, and appear once SFTP is chosen. A mounted directory has no host on purpose — the mount is made on the server with mount(8), and the application only names the directory it writes into. A test asserts both halves, because "the fields are there, you just cannot see them yet" is not something anybody should have to take on trust. 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> |
|
|
|
3fb7e5f08c |
Decide the VAT rate in one place, before the two disagree
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
The Finance tab wrote a rate into settings while TaxTreatment kept reading one from config — two sources for one number, added by me two commits ago. It had not bitten yet only because both happened to say 20. TaxTreatment now takes the domestic rate from CompanyProfile, which falls back to the .env-derived config until an operator saves one. Same shape as ProvisioningSettings, which already solves this for the deployment values, and the same reason: an operator can change a settings page and cannot change a config value, so a page that appears to set the rate and does not is worse than no page at all. Reverse charge and the seller country stay where they are. That logic already existed, already handles a verified EU VAT ID correctly, and nothing here needed to know about it — which is exactly why it should not have been duplicated. 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> |
|
|
|
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> |
|
|
|
21762284a7 |
Bind the portal to its own hostname too, so the website cannot serve it
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The previous release bound the landing page and stopped there. The website duly
vanished from the portal — and the portal stayed reachable from the website:
www.clupilot.com/dashboard, /login, /settings all kept working. Half a
separation is no separation, and it was reported back within the hour.
APP_HOST binds every portal route, Fortify's own POST actions included through
fortify.domain — a sign-in form on the website's hostname would otherwise post
to a route that answers there. SITE_HOST now takes the legal pages and
robots.txt with the landing page rather than leaving them everywhere; binding
them is also what makes route('legal.impressum') produce a www URL from inside
a queued mail, where there is no request to take a hostname from.
There is no clever middleware here and there should not be. Route::domain() is
the mechanism: a route registered for one host does not exist on another, and a
request for it gets the 404 it deserves. The only deliberate exception is "/" on
the portal host, which redirects to the dashboard or the sign-in page — that is
what people type from memory, and turning it into an error page to make a point
about hostnames helps nobody. The Stripe webhook and /up stay host-agnostic:
Stripe posts to whichever URL it was given, and a mismatch there loses payments.
Both are opt-in and empty by default, which every development machine reached
by a bare IP depends on.
The tests now check BOTH directions for every path rather than only the one
that was reported. That is the mistake this commit exists to correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
735daf46a4 |
Lay the foundation for self-issued invoices: series, numbers, frozen documents
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
An invoice is a tax document, so two things drive the shape of this. It is frozen at issue. Everything the finished document says — issuer address, VAT number, bank details, customer address, every line, the rate, the totals — is copied into the invoice row when the number is assigned. The PDF is rendered from that and never stored, which is what was asked for; rendering it from today's settings instead would have been simpler and wrong, because an address changed in 2028 would rewrite an invoice from 2026. The width of this table IS the feature. Numbers are gapless and ascending, which is a legal requirement rather than a preference, and that is why none of it is derived from the invoices table. "Highest number plus one" hands the same number to two sales that land together and hands a used number back out after a row is removed — a number already printed on a document somebody holds. The counter is a column, read and advanced under a row lock inside the caller's transaction, and taking one outside a transaction throws rather than quietly working. Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each with its own prefix and its own counter, because a credit note is not an invoice with a minus sign in front of it. Seeded rather than left to the operator: an installation with no series cannot issue anything, and discovering that at the first sale is the worst possible moment. TCPDF added, for the renderer that comes next. There is deliberately no test for the outside-a-transaction guard, and a note where it would have been: RefreshDatabase opens a transaction around every test, so the guard cannot fire and a green test for it would be green for a reason that has nothing to do with the guard. 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. |
|
|
|
a457bea87e |
Read the DNS and monitoring tokens from the vault, not config
Two of the Zugangsdaten page's three entries were decorative: whatever an operator typed was encrypted and stored, but HttpHetznerDnsClient and HttpMonitoringClient still read config() directly, so the stored value never reached an outgoing request. Both now resolve through SecretVault::get() at the point of use, matching how the Stripe client already worked. |
|
|
|
49a3ca2e33 |
Keep the shop window off the portal's front door
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The landing page was registered host-agnostically, so app.clupilot.com served it: marketing copy, a pricing table and a "sign up" call to action, at the address where a customer's servers live. Reported exactly that way. SITE_HOST binds the website to its own hostname. Every other host — the portal, a bare IP — answers "/" with the product: the dashboard for somebody signed in, the sign-in page for everybody else. Never a 404 there: "/" is what people type from memory, and turning that into an error page to make a point about hostnames helps nobody. Empty keeps today's behaviour, which every development machine reached by IP depends on. The console is unaffected — its routes are registered first and win on their own host, the ordering the status page above already documents. The legal pages are deliberately not bound. An imprint has to be reachable from wherever the reader is standing, and a mail footer links to it from whichever host sent the mail. Also drops "just reply to this message" from two mails. A reply lands in the billing or provisioning mailbox rather than in the support queue somebody actually works through — so it now says to raise it in the portal, where it gets a place in that queue instead of getting lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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 | |
|
|
4d4d1dd5d5 |
Stop the update agent dying on its own tag arithmetic
tests / pest (push) Failing after 8m4s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Since 1.2.0 the agent has ended with a non-zero status on every tick, before writing anything. The console showed a check that never finished and a last-checked time frozen at the moment of the deployment, and the update button could not be reached at all — the one mechanism that would have delivered the fix. Two instances of the same trap, both mine, both under `set -Eeuo pipefail`: The tag count was `release_version_gt … && echo`. The LAST iteration is almost always a tag that is not newer, so the loop ended non-zero, pipefail carried it out of the pipeline, the command substitution inherited it, and set -e ended the agent. `if` rather than `&&` is the whole fix: an if whose condition is false still returns 0. The newest tag came from `git tag … | head -1`. head exits after one line, git takes SIGPIPE, pipefail does the rest. install-agent.sh has carried a written warning about exactly this since it was written; I read it and wrote it anyway. Both now live in deploy/lib/release.sh, because the reason neither was caught is that no PHP test can reach a bash pipeline. tests/Feature/ReleaseComparisonTest.php runs the real functions in a real throwaway repository under the agent's own set -Eeuo pipefail, and asserts the EXIT CODE as well as the answer — an answer nobody lives to read is not an answer. The SIGPIPE case needs four hundred tags to reproduce, because a short list finishes writing before head leaves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ed4167eba3 | Close SSH and the Proxmox UI to the world once the tunnel is proven up | |
|
|
6998a22527 |
Repair ownership by looking inside, not at the door
tests / pest (push) Failing after 8m2s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The ownership repair added in 1.1.1 sampled the owner of each top-level directory and skipped the recursion when it already matched. node_modules was owned by www-data; node_modules/.vite-temp, left behind by an earlier root build, was not. So the repair walked past it, `npm run build` failed with EACCES, and the deployment stopped in maintenance mode — on the release whose whole point was that this could not happen. A directory's owner says nothing about its contents. It now walks each tree once with find and changes only the entries that are wrong, which is also cheaper than the detect-then-chown-everything it replaces. Verified against the shape that actually failed: a root-owned file inside a www-data-owned node_modules, repaired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2b1989f53d | Move host DNS names off the public zone into vpn-dns's internal hostsdir | |
|
|
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. |
|
|
|
00b9107ee3 |
Deploy as the application's user, and take back what root already took
tests / pest (push) Failing after 7m19s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The 500 on the VPN config download was never in the VPN code. storage/logs/ laravel.log was owned by root, mode 644, since 27.07 04:16 — the moment a failed `artisan optimize` wrote its error there during a deployment. From then on the application could not append to its own log: Monolog threw on every attempt, and a throw while logging is a 500 on any page that logs, with nothing written down to say why. The error page said "Er wurde protokolliert". It was not. It surfaced on the config download because that is one of the few pages writing a log line on its way through — on success and on a wrong password alike. Pages that log nothing were unaffected, which is exactly why it looked like a VPN fault for days. `docker compose exec` is root unless told otherwise, and every deployment script relied on that default — including the agent's allowlist sync, which runs every minute. docker/entrypoint.sh had it right all along and drops to www-data for precisely these commands. Now so does everything else, help text included: telling an operator to run artisan as root is how the file ends up owned by root in the first place. update.sh also repairs what an earlier run left behind, before its first unprivileged step rather than after — a root-owned log file is not self-healing, the page that trips over it is nowhere near the deployment that caused it, and with in_app unprivileged a root-owned vendor/ would break the next composer step outright. tests/Feature/DeploymentRunsAsTheAppUserTest.php holds the line. It was checked against the previous commit and finds all nine places that were wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
b64a9f9238 |
Give the two-factor page a way back, and a shape
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
Clicking "Einrichten" generated a secret and showed the QR with no exit: leaving meant navigating away, which left an unconfirmed enrolment on the account that nothing on the console ever displayed. cancelSetup() clears it — it was never confirmed, so it never protected anything. The page was also one card with four states stacked into it. Now each state is its own panel, the enrolment step says which step it is and carries numbered instructions beside the code, and the recovery codes get their own card with a copy button instead of being a footnote under whatever came before them. |
|
|
|
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> |
|
|
|
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> |
|
|
|
164145463c |
Send an operator with an expired session to sign in, not a 500
tests / pest (push) Failing after 7m15s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
VpnConfigAccess::reveal() dereferenced Auth::guard('operator')->user()
unguarded, which is null when a modal's session has ended since it was
opened (a Livewire action reaches its component through /livewire/update
on its own, without the page's original route middleware). The same
unguarded pattern was in TwoFactorSetup, InstanceAdminAccess and Settings.
Add App\Livewire\Concerns\ResolvesOperator with currentOperator(), the one
place that resolves the operator and redirects to admin.login when there
isn't one, and use it everywhere the pattern occurred.
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> |