19 Commits (006ce3568b57733b347bf3c4e3e45e15830273ae)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
880b5f1998 |
Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.
Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.
billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.
ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.
HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.
HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.
StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.
1966 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
955f1b874f |
Deliver the processing agreement, and hold the proof it was accepted
Art. 28(3) GDPR wants a contract wherever personal data is processed on somebody else's behalf, which is the whole of what this product does. "In writing" there includes electronic form (Art. 28(9)), so a document the customer can read plus a recorded acceptance is enough — no signature on paper. The website already promises "AV-Vertrag inklusive", which means it has to be obtainable without asking us for it. It was not obtainable at all. **The text is never this application's.** An operator uploads the document their lawyer wrote, names the version, and publishes it; the measures ride along as a second file, because they are an annex to the agreement and "which measures applied when this customer accepted" has to have one answer. Inventing the text here would have been worse than having none. **Uploading and publishing are two acts.** Acceptance is per version, so publishing leaves every customer who accepted the previous one outstanding again — correct, and far too expensive to trigger by dropping a file on a form. It goes through a confirmation modal that says exactly that (R23). **The customer's side** is a card in the contract tab: read the agreement, read the measures, one press to conclude it. What that press records is what makes it evidence rather than a flag — the version, the moment, the address it came from, and the login that pressed. Pressing twice is one agreement (unique index, not a check somebody can forget), and a superseded acceptance is kept rather than overwritten: it was true when it was made, and the history is the point. Nothing renders until a version is in force. A card offering an agreement that does not exist is worse than the silence. The files live on the private disk and are served through routes that check who is asking — an agreement is not a public asset, and a guessable URL to one would be a list of who our customers are. The customer route takes no version parameter: which document applies is ours to say. `dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps the platform running does not thereby decide what every customer is asked to agree to — and a capability written under `web` since the 2026-07-29 move lands in a guard nothing authenticates against, which is how this one first shipped answering 403 to a role that visibly had it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e83b17ec37 |
Put every missing field on one page before a customer pays
Admin\Readiness renders App\Support\Readiness::byGroup() end to end: state, label, breaks sentence and a fix link for every check, a button on the four that have to ask a real service (DnsTokenCheck, WireguardEndpointCheck, VmTemplateCheck, StripeCheck) and only ever run on demand, and one headline line — ready for the operator's current mode, or how many blocking items are still open. Wired into the nav and into Overview's own notice list beside the same gate as Admin\Integrations, since both halves it configures come back together on this page. Fixed two check keys the guard test caught for real: onboarding.ssh_key and delivery.inbound_password did not textually contain the vault key they check (ssh.private_key, inbound_mail.password), which is exactly the kind of silent gap this whole task exists to catch. Renamed to onboarding.ssh_private_key and delivery.inbound_mail_password, with the two existing test files that referenced the old keys updated to match. Also fixed the guard test's own assertion: Pest's toContain() is variadic, so a second string argument is a second required substring, not a failure message (the same trap tests/Feature/ModalHeightTest.php already documents about itself) — it failed on every entry, including ones already covered. Replaced with str_contains()+toBeTrue(), which keeps the check and the message both. Two pre-existing Overview tests assumed a bare install has nothing to report; it now correctly always starts with a readiness gap, so both were narrowed to what they actually test — no invented data, no invented incident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c354ad1463 |
Make mails readable on a phone, and let an operator look at one
Three things the owner reported, and one sentence that promised something
this product does not do.
**The mails had a fixed width.** components/mail/layout.blade.php set
width="600" and width:600px, so every phone showed a 600px canvas in a
390px window and one had to scroll sideways to read a sentence. Now the
table is width="100%" with max-width:600px — fluid, with 600 as a ceiling
rather than a floor. A mail cannot fix this with a media query (Outlook
renders with Word and drops <style> blocks), which is why the whole file
is inline attributes and why the shrinking has to be built in. Side
padding went 40px -> 24px in the layout and in all nine mail views:
40 each side leaves 278px of a 390px screen to read in.
**There was no way to look at a mail.** An invoice mail needs an invoice,
a maintenance announcement needs a window, and "register an account to
see whether the confirmation reads well" is not a workflow — which is how
the fixed width survived for months. App\Services\Mail\MailPreviews
builds all nine from sample data, every record with make() and never
create(), so a preview leaves no invoice, order or window behind and
draws no number from a series. Two ways to see one: in a tab as the
document itself, or sent to the signed-in operator's own address. There
is no recipient field — that would be a form for mailing strangers.
sendNow(), not send(): every mailable here implements ShouldQueue, and
send() defers to the queue for those, which would have put a failure in a
worker's log while the button reported success to somebody waiting for
the mail. The test caught that.
**The booking page had no shell.** Order declared layouts.portal — the
bare shell the sign-in pages use, no navigation and no padding — so the
page sat flush against the top and bottom of the window with no way back
to anything. layouts.portal-app, like every other portal page.
**The consent box said something untrue.** Its refusal read "otherwise we
can only build your cloud once the withdrawal period has ended", which
describes a path that does not exist: nothing here delivers fourteen days
later, and an unticked box simply means no order. Said plainly now, and
said on the page before somebody runs into the refusal.
Also: GrantedPlanPortalTest asserted assertDontSee('179'), which matches
any id containing those digits — and ids climb through a suite run
because a rolled-back transaction does not reset an auto-increment
counter. It passed alone and failed in company. Asserts on '179,00' now,
which is what a leaked price would actually look like.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
33566cd404 |
Show the customer, and write the answers once
tests / pest (push) Failing after 9m49s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Two complaints, one cause: the customer page was a compose box with three
lists under it. It showed nothing ABOUT the customer, and every answer was
typed from scratch.
The page is five tabs now. Stammdaten — contact, phone, since when,
language, and the tax standing: consumer or business, with "nobody asked"
saying so rather than passing for business, because that answer decides
whether a withdrawal right exists at all. Paket — the contract's own
FROZEN figures, the modules at the price they were booked at, and the
machine: address, state, host, datacenter. Zahlungen — invoices with net,
gross and their PDF, and the orders beside them, because an order is a
purchase and an invoice is a document about one. Nachrichten — the portal
requests in the customer's own words, the mail they sent, the mail we
sent. Schreiben — last, because it is what you do after reading.
The tab is in the query string, like Settings and Integrations: a reload,
a bookmark and the back button all land where the operator was, and a link
can point at the tab that matters.
And templates, under Betrieb → E-Mail-Vorlagen. Most customer questions
are the same five questions, and typing the answer again every time is how
two customers get told two different things about one subject. A template
is INSERTED into the compose field with the customer's own details filled
in — never sent as it stands, because the last two sentences always belong
to the person asking. What goes out is what the operator saw and edited.
Three decisions worth naming. {{contact}} takes the contact person where
one is on record and the company name otherwise, because "Guten Tag
Beispiel GmbH" is the line that gives away a mail written by a database.
{{amount}} is the gross figure — what the price sheet quoted them and what
their bank statement says. And a typo comes out as {{kunde}}, standing
there in the field where the operator sees it, rather than as an empty
string that produces "Guten Tag ,". A placeholder with no value for this
customer stays empty; nothing is invented.
Placeholders are named in English because they are identifiers, not prose:
a template has to survive somebody switching the console's language. The
list the page documents is the renderer's own, so a token added in one
place appears in the other without being written down twice.
Blade's echo tag ends at the first }} it finds, which turned a literal
placeholder in the documentation into compiled nonsense. Assembled in PHP
now, and caught by the test that renders the page rather than by somebody
opening it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
e376b571be |
Read the support mailbox into the console
tests / pest (push) Failing after 7m59s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
No, not an API token: mailcow's API administers domains and mailboxes — it does not read mail. Reading a mailbox is IMAP whoever runs the server, so that is what this is. Host, user and folder are console settings; the password is a vault entry, like every other credential that opens something. IMAP by hand rather than a dependency. Four verbs are needed — LOGIN, SELECT, SEARCH UNSEEN, FETCH — and the parsing that follows is the real work either way. It is bounded by what the console shows: a sender, a subject, the words a person wrote, and the NAMES of anything attached. Real mail from real people arrives encoded, so it handles RFC 2047 subjects (every German subject with an umlaut in it), quoted-printable and base64 bodies, Windows charsets, and multipart — text wins over HTML, because a console is not a mail client and the words are in the text part. A message it cannot make sense of is skipped and logged, never guessed at: a garbled question in front of an operator is worse than one they go and read in the mailbox, where it still is. ATTACHMENTS ARE NOT STORED. Only their names and sizes, so an operator knows something was attached and can go and look if it matters. Keeping whatever a stranger chooses to send would make this application a malware store with a web interface in front of it. Whose a mail is, is decided by the sender's ADDRESS and by nothing else — not by a name in the subject, not by anything in the body. A mail is easy to write and this decision attaches a stranger's words to a real customer's file; there is a test that forges the name and the subject and still gets nothing. An address nobody recognises stays unassigned, shown FIRST and never hidden: that is a new enquiry, or a customer writing from their private account, and it is the mail that must not be missed. An operator can place it by hand from the same row. Nothing is deleted on the mail server, ever. Messages are flagged seen once the row is safely written — and only then, because flagging first and failing after would lose the message with no second copy anywhere. Filing one away is this console's own state; the mailbox is untouched. Two fixes on the way past: the customer page printed "customers.status.active" and "support.status.open" — a lang key with no file behind it renders as itself and Laravel says nothing — and the same page is now covered by a test proving each list is scoped to the customer in the URL, which was reported as "unterhalb steht immer das selbe". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0e25fe88d4 |
Keep a register of what was sent, and answer the customer from here
Three complaints, one shape: the console knew things and could not act on them, so the work happened somewhere else and left no trace. The header said "4 Hinweis(e)" and led nowhere. Whoever read it had to know that the list was a card further down the same page, and then go looking for the host or the failed run by hand. The count is a link now, every notice carries the page that shows the thing it is about, and the plural is a word rather than a bracket. Nothing recorded what this installation had sent. "Hat der Kunde die Zugangsdaten je bekommen, und wann?" was answered on the mail server — a different machine, a different program, and nothing an operator can put in front of somebody who says nothing ever arrived. Every mail now writes a row: when, to whom, which mailable, and the customer it belongs to. Written from MessageSent, so a row means the transport accepted it; that is the strongest thing an application can honestly claim, and a column called "delivered" would be pretending otherwise. Verification and reset mails are left out on purpose — they go to unconfirmed addresses and anybody who can type into a form can send them, so recording them would hand the register to whoever wants to fill it. And the customer list was the end of the road. There is a page behind it now: what they bought, what they asked in their own words, what we sent them, and a box to write the next message. It goes out from the support mailbox and is recorded on the way — with its body, because an operator typed it and "what exactly did I write to them in March" is a question the mail server's log cannot answer either. Answering a request from here closes it, which is the reason to answer from here at all: answering from a mail client leaves it open for ever, because nothing tells it otherwise. Found on the way, by a test that sent a real mail rather than asserting it was queued: resources/views/mail/reset-password.blade.php was missing its closing </x-mail.layout>. The component's contents were never terminated, the layout's own @if ran off the end of the file, and the mail raised a Blade syntax error instead of rendering. Password reset has therefore never delivered a link since it was built. Closed, plus a test that renders it and one that counts opening against closing tags across every mail view. Reading INCOMING mail in the console is not in this: it needs the mailbox polled over IMAP, which is a credential and a dependency. Requests filed through the portal appear here in full; a customer who writes by mail instead still has to be read in the mail client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7265799881 |
Quote what a person pays, and tab the settings page
tests / pest (push) Failing after 7m50s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Prices. The sheet quoted net, which is the wrong number for one of its two readers: a private customer has no way to add 20 % in their head and no business being surprised by it at checkout. The gross figure is now the big one, with the net underneath for a company's books, and the same rule applies to the module prices further down — a sheet that quotes the package with VAT and the module without it is a sheet whose numbers cannot be added up. The rate is the one on the Finance page, the same one an invoice uses; a percentage written into the template would be the second source that makes the two disagree. "zzgl. einmaliger Einrichtung" had run for months without ever naming a figure, which leaves a visitor knowing only that there is one. It is a field on the Finance page now and prints with the price. Zero removes the sentence — and the comparison card that listed the fee as the honest downside of the offer — rather than admitting to a cost that is not charged. Layout. Four packages in 1120px left each card 202px of text, which is what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the cards look pressed against each other. The pricing section is wider than the rest of the page (only it: the reading columns elsewhere are narrow on purpose, a price sheet is four columns that must be comparable at a glance), the gap between cards is larger, and the delivery line is short enough to fit with whitespace-nowrap to keep it that way. Navigation. VPN moves from Betrieb to System: the tunnel is not a task of the day, it is how the console reaches the estate at all. Settings. The page had grown to three headed sections in one column with the operator's own password somewhere in the middle. It is tabbed now, and two-factor enrolment is one of the tabs instead of a menu entry of its own — it is a setting, and it was the one thing an operator had to leave this page to change about their own access. Same component, no second copy of the enrolment logic; it still answers at its own route because RequireOperatorTwoFactor sends an operator there who may not open anything else yet, this page included. The open tab lives in the query string, so a reload, a bookmark and the back button all land where the operator was. That matters here more than elsewhere: the installation tab refreshes itself while an update runs, and a tab kept only in the component would drop them back to the first one mid-deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ec8675861e |
Take the order, park it, and say when it will be delivered
tests / pest (push) Failing after 7m44s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.
So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.
Then say so, in the same words on both sides of the payment:
- The price sheet marks each package "wird sofort ausgeliefert" or
"Bereitstellung in 2-3 Werktagen", read from free capacity rather
than written into the page.
- The customer's own overview says their cloud is being prepared while
it is parked, instead of a stepper standing still for two days under
"wird eingerichtet" — which reads as broken.
- The console front page shows the same per-package answer, so an
operator can see what visitors are being promised.
And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.
The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.
Two things this shook out:
- `current_step` is an INDEX into the pipeline, not the step's key, and
it is cast to integer. `where('current_step', 'reserve_resources')`
reads as correct and matches step 0 of EVERY pipeline instead. Both
lookups go through HostCapacity::parkedRuns() now, which resolves the
index and filters by pipeline.
- The auction feed sends `hdd_hr` as a list, not a string. Casting it
printed "Array" into the console and raised a warning that took the
page down with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
4e3beca759 |
Close the domain page to packages that may not have one
The rule was decided in CustomDomainAccess but nothing asked it on the way in: /domain answered every customer on every package, so the entry package could set a domain it is not sold and cannot keep. The guard sits in mount(), not on the route. A Livewire component answers POSTs to /livewire/update by itself, so a route-only check would have left save() and the rest reachable to exactly the customers it was meant to stop. The sidebar asks the same question through a gate rather than a second copy of the rule — a customer holds no permissions at all (R21 puts all seventeen on the operator guard), so can() had nothing to filter on and the tab was shown to everyone, leading straight into the 403 the page now raises. Customer::forUser() carries the resolution the Livewire concern used to own, because the gate has to answer outside any component and a second copy of its email fallback is how the two would drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ea643b5e73 |
Prove a custom domain before serving it, and keep proving it
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two things. ── The update screen, still opening twice ─────────────────────────────────── Reported again on 1.3.9, and the cause was not the one fixed in 1.3.8. The agent consumes the request file BEFORE it resolves the release — deliberately, because update.sh may kill the shell and a request left in place would loop — and writes `state: running` only once it has decided to go ahead. In between, the request is gone and the status does not say running yet, so the endpoint honestly answers "nothing is running". The watcher read that as "the run has finished" and reloaded the page: overlay on the click, gone a poll later, 503 after it. The overlay now closes only once the server has BOTH confirmed a run and then stopped reporting it. Before the confirmation, silence means the agent has not got there yet. Bounded at twenty polls so a request the agent refuses does not leave the console covered forever. ── Custom domains, proven and re-proven ───────────────────────────────────── `custom_domain` was a free-text field and everything downstream believed it: the proxy served it, the certificate was issued for it, Nextcloud trusted it. Anyone who pointed any hostname at the platform got somebody else's files under their own name. The proof is a TXT record at _clupilot-challenge.<domain> holding a token only this instance has. Nothing is served until it has been read. Every reader now goes through Instance::address(), which is the one place the decision is made — `custom_domain ?: subdomain` was the hole, written out four times. It is re-read every night at 03:40, because a token checked once can be taken straight back out and a domain that later lapses keeps resolving here. Three consecutive misses before a live domain is withdrawn: one failed lookup is a nameserver having a bad minute, and withdrawing takes a working Nextcloud off its own address. The domain and its token stay on the row so the customer can put the record back rather than start over. Changing the domain mints a NEW token. Reusing it would let somebody who once verified example.com claim any other domain later without touching its DNS — the old record is still sitting there and only the value is compared. The domain is changeable at any time, and removable. Fixing it once set was considered and rejected: adding or moving an address is a proxy entry, a certificate and one line in trusted_domains. 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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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. |
|
|
|
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. |
|
|
|
4211f3dfab | Give the mailboxes a page, and the support sender its own capability | |
|
|
21d8dc310a |
One definition of the navigation, and the country instead of the building
The breadcrumb said Übersicht on every page. The sidebar and the breadcrumb each built their own copy of the structure, so the layout had no way to know which entry was current and fell back to the first one. Navigation::portal() and ::console() are now the single definition, and currentLabel() matches on the route name — which matters for the console, whose PATH changes between host-bound and fallback mode while its route names do not. The datacentre name is out of everything a customer sees, for the third time and now at the source rather than in a template. Falkenstein is how an operator places an instance; a customer's processing record names the jurisdiction, and putting the building in customer copy means editing that copy every time the estate grows. Also swept the views onto the new scale: --text-faint is 2.8:1 and a decoration token, but it was carrying table headers, hints and timestamps — text people have to read. All of it moved to --muted, which is 5.0:1 and passes AA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |