12 Commits (main)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
fed4acf31c |
Accept terms instead of a start date, and fix the sender no server accepts
**The test mail was rejected: "553 5.7.1 Sender address rejected: not owned by user no-reply@…".** Every purpose mailbox here has its own SMTP account, and a mail server lets an account send only from the address it owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and sendNow() then ignores the mailer the mailable asked for, so a mail addressed support@ went out over the no-reply@ login. It sends through the mailable's own mailer now, and the cloud-ready preview — the one mailable with no mailbox at all — takes the provisioning mailbox like the real notification does. Writing the test for that found the same bug in production code: **InvoiceMail and OrderConfirmationMail set their From to billing@ and never named a mailer**, so both went out over mail.default's no-reply@ login. On this installation no customer had ever received an invoice mail or an order confirmation; they rendered perfectly, queued without complaint and were refused at the door. MailSenderOwnershipTest now scans for the mismatch: a mail that takes a mailbox From must use that mailbox's mailer. **The box on the order page accepts the terms now**, not an immediate start. It used to carry the whole FAGG §16 sentence, which read like a choice between "now" and "in fourteen days" — and there is no second option. The terms are what regulate the sale, so they had to exist: resources/views/legal/terms.blade.php replaces the placeholder with fourteen sections written from what this software actually does — the delivery, the capacity queue, the full refund on withdrawal, the cancellation at period end, the deletion deadlines. The company data comes from CompanyProfile, so the page and the invoices cannot drift. No availability figure and no liability cap has been invented. No order goes through without it: the button is unusable until the box is ticked and says why, and CheckoutController still refuses server-side — the browser half refuses nothing. The request field is `terms_accepted`; the Stripe metadata key stays `immediate_start`, because a session opened before a deploy is paid after it and the webhook would find nothing under a new name. **"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one. That was the whole of what we said, so the answer looked like yes. The second rule now exists and is stated: PruneDormantAccounts removes a confirmed account after a year when it never had a package — no customer record at all, which is where every order, contract and seven-year invoice hangs. A fortnight's notice goes out first, once, and `dormant_warned_at` is what permits the deletion: an account whose warning never went out is never removed. Signing in resets the clock, measured off the device rows because users has no last_login_at. Both deadlines are said in the portal settings, on the verification page, and in the terms — each reading the number off the command that enforces it. Also: the wordmark scan matched any element whose text merely BEGINS with the company name, which a paragraph of terms does. It looks for the lockup form now (no whitespace after the tag), which is what it was always about. The seven checkout tests in the parallel session's files were posting the old field name and now post the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2321854967 |
Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
"Abmelden und neu registrieren" was advice that could not work. Signing out frees nothing: the address is still held by the unique index, so registering again with the address somebody meant fails — and every abandoned attempt stayed in `users` for ever. Reported exactly that way, and it was right. The button discards the account instead. Only an UNCONFIRMED one, and only one with nothing behind it: the webhook creates a customer's login from a paid checkout, so a user can sit on this page unconfirmed while already having a contract, and deleting that is data loss dressed up as a convenience. It refuses and says where to go. A confirmed account is closed, not discarded — that is ConfirmCloseAccount, with an invoice history behind it. R23: confirmed in a modal. The modal mutates nothing; it dispatches the event the page listens for, so the checks stay in the one place they already are rather than being duplicated where they could drift. And nothing has to be discarded by hand. clupilot:prune-unverified removes registrations nobody confirmed after five days — long enough for somebody who signed up on a Friday and found the mail in a spam folder on Monday, short enough that a typo does not hold the correct address for a month. It skips a confirmed account whatever its age, and any account with a customer record behind it, matched on user_id AND on the address, because either link means somebody is a customer. Every removal is logged with the address: a line that disappears is what somebody asks about later, and a count answers nothing. The deadline is said on the page as well as in the mail. Somebody who never received the mail is looking at the page, and the deadline is the reason they do not have to do anything about an attempt they abandon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6d7c2bdf35 |
Ask whether they are a consumer, and let one change their mind
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Three things the product owed its customers and did not have. **Who is on the other side.** There was no consumer/business flag anywhere, and `vat_id` was standing in for one — which it cannot: a business without a VAT number is an ordinary small business, and a consumer with one does not exist. It is asked at sign-up now, correctable in the portal, and NULL where nobody has been asked. Unknown is read as CONSUMER everywhere it decides a right, because that mistake costs us a refund while the other one takes a statutory right away from somebody who has it. Reverse charge asks the recorded type instead of the number: an explicit consumer is charged the domestic rate whatever `vat_id` says — previously they were not, and anyone could zero their own VAT by getting a number verified. An unrecorded type still falls back to the verified number, so no contract that is already running changes rate. **The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only, at every door: the window is stamped on the contract when it is concluded, the customer exercises it from the portal, an operator records one that arrived by telephone or post, and both go through one action that refuses a business customer on the server rather than by hiding a card. The money follows the paperwork rather than being computed beside it. The invoice is cancelled by a Storno with its own gapless number — nothing is ever edited or deleted — a new invoice states the pro-rata value of the service actually delivered (FAGG §16, by days over the term paid for), and the refund is exactly the difference between the two documents. Where the consumer never expressly asked for the service to begin at once, they owe nothing and the whole amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send the money twice. The service ends through EndInstanceService and the `cancellation_scheduled` machinery that was already there. **The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller in the interface at all, so a customer could book a recurring charge in two clicks and had no way to stop it. It is on the module card now, with the date it runs until and a way back while the cancellation is still pending — and putting a module back settles nothing, because the term it was cancelled for was paid for in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3a4324fb6f |
Give people a way back in, and put the URL in English
tests / pest (push) Failing after 7m54s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
── The hole ──────────────────────────────────────────────────────────────── There was no password reset. Fortify's feature was commented out, so there was no link on the sign-in form, no page and no route — a customer who forgot their password was locked out of their own cloud until somebody opened a shell. On a product whose selling point is that you can ring somebody, that is a support call a week and an embarrassing one. Two pages of ours under Fortify's route names (R1/R2), because with views off Fortify registers only the POST endpoints. The mail is a Mailable in this product's design rather than the framework's MailMessage: somebody who has just been locked out is exactly the person a phishing mail is aimed at, and a message that looks nothing like the rest of our post is one they cannot check — ours carries the footer that names our domains. Three decisions worth stating, each with a test: The answer is identical whether the address is known or not. "No account with that address" turns the form into a way of finding out who is a customer. The reset kills every other session. Whoever knew the old password may still be signed in somewhere, and a reset that leaves them there has fixed nothing. It does NOT sign the visitor in. The link arrived by email, and a mailbox somebody else can read would otherwise be a session somebody else gets. ── /sicherheit → /security ───────────────────────────────────────────────── R13: paths are English. Mine was not. The old path stays as a permanent redirect because it has already gone out in mail footers. ── One wordmark ──────────────────────────────────────────────────────────── "Sometimes it says Cloud and sometimes it does not" — correct. It was on the footer, the placeholder and the maintenance screen, and absent from the header, the sidebar and the sign-in plate. The product is called CluPilot Cloud; that is the name on the invoices and in every mail, so it is now the name everywhere. The console sidebar takes the small size so the lockup, the ADMIN badge and the close button still share one line (R18). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f8874f32ea |
Put customer instances on their own domain, and say which domains are ours
tests / pest (push) Failing after 8m1s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
── The zone ──────────────────────────────────────────────────────────────── A fresh install now addresses customer instances under clupilot.cloud rather than clupilot.com. Not branding: a Nextcloud is third-party software that strangers sign in to, and on the same registrable domain as the portal it shares cookie scope, document.domain and CAA with it. The same reason googleusercontent.com and vercel.app exist. Existing installations keep whatever the console says — the zone has been a setting all along. `clupilot:check-zone <zone>` reports what a change would touch before anything is touched: the DNS records, the monitoring targets, the certificates and Nextcloud's trusted_domains all carry the old name and the setting reaches none of them. With no instances in service it says so and stops. Changing it later is a migration with an outage, and the command says that too. ── Which addresses are ours ──────────────────────────────────────────────── A new public page at /sicherheit: the domains that belong to us, the rule for reading an address bar, what we never do, and — the part that matters — what to do if you have already typed your password into a copy of our sign-in form. Change it, end every other session, change it wherever else you use it, tell us. In that order. Reachable without an account, deliberately: somebody who has just given their password away is signed in nowhere, and the page they need cannot be behind the thing they lost. Linked from the three places a person is when the question comes up: every mail footer (the mail is the vector), the sign-in form (this is the page a phishing kit copies, so the real one says which host you are on), and the site footer. The domain list is DERIVED from configuration and keyed by what each domain is for. A list typed into a page would eventually tell a customer that a phishing domain is one of ours, or that one of ours is not — the same two-sources-of-truth failure that had the price sheet promising an address on a domain the company does not own. The operator console is deliberately absent from it: naming it on a public page tells an attacker where to aim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
17a6fabcc2 |
One wordmark, one typeface, and the address the server actually issues
tests / pest (push) Failing after 11m28s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
Reported as "logo and font are different everywhere" — maintenance page, customer panel, console, homepage. They were. Counting them up: the site header set the name in ink at -0.02em, the sidebar split it into "Clu" plus an orange "Pilot", the sign-in plate set it white at a third size, the customer two-factor page set it in MONO with no mark at all, the error pages and the placeholder each inlined their own — and the maintenance screen had no wordmark whatsoever. Nine surfaces, seven ideas of the brand. There is one now: resources/views/components/ui/brand.blade.php. Mark plus wordmark in one piece, in ink, with an optional muted "Cloud". No orange half — that made the accent a permanent fixture instead of something used sparingly. The four places that cannot use a Blade component (the two self-contained pages, the error layout, the mail layout) inline it and are held to the same shape by a test. The typeface. The console loads IBM Plex through Vite; the 503 page has no stylesheet at all, so the maintenance screen — the one page shown when somebody is already worried — was rendering in the system sans. It declares the fonts itself now. And the bold weight was missing from public/fonts entirely: the placeholder and the error pages both asked for ibm-plex-sans-latin-700-normal.woff2, which was not there. A @font-face pointing at nothing fails silently, which is why nobody noticed. The error pages carried the same two faults the placeholder shipped with: an empty gradient tile where the mark belongs, and `gap` on an inline-flex row spacing "Clu", "Pilot" and "Cloud" apart as three separate words. And the price sheet promised "Adresse auf clupilot.cloud" — a domain the company does not own — while provisioning was issuing addresses under whatever CLUPILOT_DNS_ZONE says. It is assembled from that setting now, the same reason the prices are read from the catalogue rather than typed into the page. welcome.blade.php removed: Laravel's leftover, reachable from no route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
3f318c3f6a | Give the console a front door of its own | |
|
|
6376f5e9fa |
One design system for every surface
The console was a second product. admin-tokens.css held a complete dark set — its own surfaces, its own orange, its own status triad — so every shared component had to work in two worlds, and the two halves of the application drifted apart in ways nobody could see from inside either one. That file now carries density and nothing else: smaller type, tighter rows, tighter corners. Colour comes from one place. The tokens themselves are rebuilt around what the redesign settled on: - Radii scaled to object size (9/11/16/22) instead of one value everywhere, which is what made the interface read as a design-system specification rather than as a product. - Warm, broad, low shadows. A neutral grey drop shadow belongs to a different design language and reads as cold on this ground. - --accent-press, which several rules already referenced and nothing defined. An undefined var() does not make a browser skip the declaration; it computes to , and for background-color that is transparent — the accent button lost its fill on hover and left white text on white. - No serif. A serif headline is what made every page read as a document, which is the impression the whole redesign exists to remove. --font-serif now points at the sans so nothing breaks while call sites are cleaned up. The two shells are one shell with two configurations, and both finally have a mobile navigation: the sidebar used to simply disappear below 900px, leaving no way to move around the application at all on a phone. The customer dashboard is bound to real records — instance, seats, traffic, backups, contract — instead of the fixtures it shipped with. Where something is not measured, storage consumption, it says what was contractually agreed rather than inventing a figure. This is the sheet a customer forwards to their auditor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b844ff377d |
Rebuild the public site as a document, and read prices from the catalogue
tests / pest (push) Successful in 7m30s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
The marketing page was a generic centred hero in the system font stack: it neither used the design system the console is built on nor looked like the product it sells. Rebuilt around what CluPilot actually promises — security you can produce evidence for — so the page reads as a controlled technical document: a specification plate instead of a hero image, numbered sections, an audit register with rhythm and proof per measure, and a price sheet rather than floating cards. IBM Plex Serif joins Sans and Mono as the display voice, self hosted as static files so the public page renders even without the Vite build. The prices were written into the page by hand, and the page and the catalogue had already drifted apart on three of four plans: the site advertised 249 € for a plan the checkout charges 399 € for. The sheet now reads the catalogue, like every other caller — including the currency, which is configurable and was also hard-coded here. The catalogue fails loudly on purpose; a public website must not. A broken or overlapping catalogue is caught in the controller alone: the page still renders, the sheet is replaced by "on request", and nobody is quoted a number the checkout would not honour. The sign-in and registration panels shared a copy-pasted orange gradient. They now share one component and the same ink plate as the site, so the two halves of the product no longer look like two companies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
88957f8f57 |
feat(auth): enterprise split login + signup (Fortify registration) via frontend-design
- login/register redesigned as a split brand-panel + clean form (Apple-ish enterprise look), cross-linked; brand panel uses accent gradient + tokens - Fortify registration enabled; /register full-page Livewire + RegisterTest Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
254a7d46a0 |
feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify): - laravel/fortify with TOTP two-factor + recovery codes; User uses TwoFactorAuthenticatable; 2FA credentials hidden from serialization. - Views off — pages are full-page class-based Livewire components (R1/R2); Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login + 2FA only (no public register/reset/passkeys). Seeder gated to local/testing. Component kit (Blade, token-based, a11y): - button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine, auto-advance/paste, -safe submit), progress-stepper, nav-item, icon (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu). Screens (localized DE/EN, R16): - Login (form -> login.store), Two-factor challenge (OTP + recovery fallback), Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures, activity). Routes English (R13). Tests + verification: - Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render). - R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and the authenticated /dashboard all HTTP 200 with ZERO console errors; login flow verified end-to-end. - Test isolation fixed (force test env over injected .env). - Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |