Commit Graph

18 Commits (feature/host-bootstrap)

Author SHA1 Message Date
nexxo 5883989552 Give settings the shape a settings page actually has
Four attempts, and this is what was wrong with the last one: full-width
panels stacked down the page, each carrying its own title INSIDE it. Every
group had two frames — the title bar and the border — so the page read as
a column of long boxes with nothing saying where one topic ended and the
next began, and a phone number got a field a thousand pixels wide.

The shape now:

- **A section list on the left**, sticky, so the sections stay reachable
  however far a form runs. Below `lg` it becomes a scrolling strip of
  chips, because a vertical list on a telephone is four rows of nothing.
  Under it, who is signed in — on a shared office machine that is a real
  question, and a settings page is where it gets asked.
- **A content column of 820px**, not the whole 1240 shell.
- **The heading OUTSIDE the card it belongs to.** A section is a heading, a
  sentence, and then a card of rows. That single move is what stops them
  reading as boxes: the border now frames the fields, not the topic.
- **What cannot be undone is last, in a card whose border says so** — the
  deletion deadlines and the close button together, at the foot of the
  contract section, in danger colours.

The rows are unchanged (label left, control right) and so is every binding;
what changed is the frame around them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:59:16 +02:00
nexxo 2a97d76b2d Rebuild the settings page out of panels and rows
**The withdrawal was invisible in two different ways.** It rendered only
while the fourteen days were still running, so the moment they expired the
whole block vanished — indistinguishable from a feature nobody built. And
the account it was looked for on has no contract at all: no package, no
subscription, nothing to withdraw from, so there was never anything for it
to be about.

It is now a row for every CONSUMER who has a contract, open or not: while
it runs, the deadline and the button; afterwards, the sentence saying why
it is over. A business never sees it — WithdrawalRight answers that, and
Settings::withdraw() refuses them again on the server. And an account with
no package says so and offers the packages, instead of a dead sentence in
a box.

**The page is built out of two pieces now.** x-ui.panel is a group: a card
with a header that names it. x-ui.row is a setting inside it: what it is
on the left, the control on the right, dividers between rows. That is the
shape every settings page worth copying uses, and it is the shape that
uses the width — a 280px label column with the control beside it fills the
line, where a stack of full-width inputs leaves two thirds of it empty and
a grid of cards of different heights walks down the screen as a staircase.

All four tabs are rebuilt on it, so the page reads as one designed thing
rather than four pages that happen to share a tab bar. Below `sm` the rows
stack, because on a telephone a label belongs above its field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:38:57 +02:00
nexxo 18193a731d Stop asking for a signature the law does not want, and lay out the tab
**The owner is right about the agreement.** Art. 28 wants a CONTRACT, not
a ceremony — and a contract is concluded by incorporating the agreement
into the terms the customer accepts at checkout, which is exactly how
every hoster they have bought from does it. Nothing in the regulation asks
for a second, separate click. What it does ask is that the agreement is in
writing (electronic form included, Art. 28(9)), that it is the version in
force, and that the customer can obtain it.

So: the terms now say the agreement is part of the contract and needs no
separate signing, and name where it is. The card states that rather than
flagging the customer as outstanding — the warning chip is gone. The
button stays, reworded to "Zusätzlich bestätigen": a practice or a firm
that gets audited often wants an explicit record, and it costs a click.
The console's counter says how many confirmed rather than how many are
"open", because none of them are.

**The contract tab was a staircase** — a short card, a wide one spanning
both columns, then a short one again. It is one column of full-width
sections now, each with the same shape: a header row carrying the state
and its action, then the body. Nothing tracks sideways any more.

The package section states its own state in the header instead of a
paragraph in the body, the withdrawal sits under it as a row rather than a
second card, and the agreement's four files each have a button — reading
and keeping, agreement and measures, which the single "Herunterladen"
could not express.

Also: the generated version no longer carries "Aus dem Repository erzeugt
(clupilot:publish-dpa)" in the console's list. That is the command line,
not information.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:55 +02:00
nexxo 52aacddc56 Store the agreement where the web process can actually read it
Every link on the agreement page answered 404 — the download visibly, the
two inline ones just as dead — and nothing anywhere said why. The files
were there, whole and correct.

An artisan run inside a container is root; PHP-FPM is www-data. Laravel's
local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out
as root-only. The web process could not enter it, `Storage::exists()`
answered false, and the routes did exactly what they were told: abort 404.
No exception, no log line, a page full of links to nothing.

Both writers — the command and the console's upload form — now store with
"public" visibility, which is the file MODE and nothing to do with the web:
0755/0644 instead of 0700/0600. The disk's root is outside the document
root either way, and the two routes still check who is asking. The command
also warns if a file it just wrote is not readable, because the alternative
is finding out from a customer.

The download itself now looks like one: the `download` attribute on both
anchors, and in the console a bordered control rather than a third link in
a row of two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:19:39 +02:00
nexxo d04ea712c7 Write the processing agreement and its annex, and let it be downloaded
The mechanism shipped without a document. Both are here now, generated
from this repository rather than uploaded — so the company is named from
CompanyProfile exactly as the invoices name it, the sub-processor list is
the three places customer data really leaves this application, and the
deletion deadlines are read off the commands that enforce them. A wording
change is a diff somebody can review, not a file that appears.

`clupilot:publish-dpa 1.0` renders both to PDF (TCPDF, the engine the
invoices already use), stores them on the private disk and puts them in
force; `--draft` stops short of that. The console's upload form is
untouched: a lawyer's revision arrives as a PDF and becomes a version like
any other.

**The annex says what this installation does, not what it would like to.**
Every measure in it was checked against the code first — the isolation is
one VM per customer because that is what provisioning builds, the backup
is a daily snapshot job at 02:00 because that is what RegisterBackup
creates, the deletion deadlines are the two prune commands. Claims the
system does not currently keep are NOT in the document, and they are named
in the handover instead. A TOM that promises more than the machine does is
the document an auditor reads before looking.

**Downloading** now carries the version in the filename —
CluPilot-AV-Vertrag-1.0.pdf — so "which fassung did I agree to" is
answerable from a downloads folder months later without opening anything.
Both sides get the button; inline stays the default, because a contract is
read before it is filed.

Placeholder register data is left off the document rather than printed: a
"FN 000000a" on a contract looks like a real number and is not one.

Also: the wordmark scan tripped on a paragraph whose first word is the
company name, for the second time. It now looks for the name as the whole
CONTENT of an element, which is what a lockup is, rather than for the name
after a tag, which is also how a sentence starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:12:30 +02:00
nexxo 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>
2026-07-30 16:56:57 +02:00
nexxo 1056dddc62 Take the address by field, fix the customer type, ask why they leave
**The billing address was one textarea**, so what landed in it was
whatever somebody typed: a postcode on the street line, a city with no
postcode, no country at all. It is four fields now — street, postcode,
town, country — in the customer's settings and in the console's customer
editor, which had its own free-text copy of the same field and would have
had an operator's correction silently reverted the next time the customer
saved.

`customers.billing_address` is neither dropped nor parsed: guessing which
line of an existing block is the street would put a postcode where a
street belongs on a document nobody can correct afterwards. It becomes the
COMPOSED form, rewritten from the fields on every save, so IssueInvoice
and everything else that reads it keep working, and a record nobody has
re-saved keeps the block it always had.

**The customer type is fixed once it is on record.** It decides the
fourteen-day right of withdrawal, and a business able to set itself to
"Privatperson" on this page is a business able to withdraw from a contract
it may not withdraw from. Registration asks the question; this page
reports the answer, and saveProfile refuses to answer it a second time —
in the component, not by hiding a radio, because a form that hides a
control has never stopped anybody who can post to /livewire/update. A
record created before the question existed may still be given one, once.

The panel is a fact rather than a form now, which is also what was odd
about it, and the sentence about who has the right of withdrawal is gone.

**Cancelling asks why.** A reason from a fixed list, required, with a note
beside it that "Sonstiges" cannot go without — the one fact worth having
about a departure, asked at the only moment the customer is looking at the
question. It lands on the contract (`cancel_reason`, `cancel_reason_note`).

And where the fourteen days are still running, the dialogue offers the
WITHDRAWAL as a way out of itself rather than as one more reason inside
it: withdrawing ends the service the same day and sends the whole amount
back, cancelling keeps the term that was paid for, and somebody entitled
to the first should not lose it by taking the second unasked. A business
is never offered it — WithdrawalRight answers that, not the template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:41:32 +02:00
nexxo 1d4e98eb06 Rebuild the customer's settings page as four tabs
It was one column 768 pixels wide inside a 1240-pixel shell — a third of
the window empty beside it — holding everything a customer might ever
change: the company address, their password, two-factor, the devices they
are signed in on, the branding of their cloud, the contract, and the
button that closes the account. Two thousand pixels of scroll, and the way
to a particular thing was knowing how far down it lived.

Four tabs, by what somebody came to do: their details, security, the
appearance of their cloud, the contract. The same tab bar the console
uses, so one vocabulary holds on both sides of the login, and the choice
lives in the query string — `#[Url(history: true)]`, no `except`, so the
parameter is there from the first render and a link to this page can name
the part it means. An unknown tab out of the address falls back to the
first rather than rendering a settings page with no section on it.

The width is used now. Inside a tab the short cards sit side by side
instead of each taking a full row to hold three lines: the customer type
beside the company form (it is one question, and beside the VAT field it
was mistaken for it), the password beside two-factor, the logo beside the
colours, the contract beside the closing of the account.

Also: the subtitle listed the old sections by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:25:10 +02:00
nexxo 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>
2026-07-30 15:22:35 +02:00
nexxo 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>
2026-07-29 21:06:06 +02:00
nexxo 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>
2026-07-28 23:38:17 +02:00
nexxo 973fcb3f2d Replace native confirm() dialogs with the app's own modal pattern
tests / pest (push) Failing after 7m14s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 19:34:27 +02:00
nexxo bd779f00e5 Show times on the operator's clock, keep storing them in UTC
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Successful in 6s Details
The console announced an update for "spätestens um 15:21" while the clock on
the wall said 17:21. Not a formatting slip in one string: fourteen views and
four components printed database values straight out, so every absolute time in
the product was two hours out all summer and one hour out all winter.

Two of those views looked as though they had handled it. They called

    ->timezone(config('app.timezone'))

which reads like "convert to local time" and, app.timezone being the STORAGE
zone that must stay UTC, converts to UTC — a no-op wearing the costume of a
fix, which is worse than no call at all because it stops the next person
looking. That exact string is now banned by test.

Worse than the labels were two FORMS. Maintenance windows and plan versions
filled their datetime-local fields from UTC and parsed what came back as UTC. A
datetime-local field carries no offset — it is the digits a person reads off
their own clock — so an operator typing 21:00 scheduled a window for 23:00
their time, and nothing anywhere said so. App\Support\LocalTime now holds
toField() and fromField() side by side, because getting one end right is not
half a fix, it is a fresh bug.

The mechanism is one Carbon macro, ->local(). It copies before converting:
Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp
would rewrite the model attribute as a side effect and anything comparing or
saving it afterwards would be an hour or two out. Both Carbon classes get the
same body — Carbon keeps macros in one global table, so the second registration
replaces the first for every Carbon class, and two different bodies meant the
immutable version, safe for itself, silently became the mutable one's
implementation too. My own test caught that.

The existing tests had not caught any of this because they built their expected
values with the same wrong call the views used — a test that recomputes the
implementation asserts nothing. They now assert the wall clock, and the queued
update additionally asserts that the UTC time is NOT shown.

Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest:
no Blade or Livewire component may format an absolute time without ->local(),
the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are
checked, ->local() must not mutate, and a form field must round-trip to the
same instant.

APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:32:21 +02:00
nexxo 435a202fdd Match the panel to the approved template, measured rather than assumed
tests / pest (push) Successful in 8m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.

That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.

Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.

The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.

Also here, from the same round of review:

- The chart's blue border was Tailwind's own `ring` utility colliding with a
  component class of the same name. Renamed to `.metric-ring`. This was
  dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
  to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
  every seat that is not the owner, with the owner refused in the action itself
  and not merely hidden in the markup.

DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.

Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:08:05 +02:00
nexxo 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>
2026-07-27 16:09:39 +02:00
Claude f6b9181ed8 Give customers two-factor, and stop every button on the settings page reacting at once
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 6s Details
Clicking Save made every button on the console's settings page appear to fire.
Seven `wire:loading.attr="disabled"` had no `wire:target`, and without one
Livewire applies the loading state to ANY request on the component — so one save
put all of them into their disabled state simultaneously. Each names its own
action now.

Two-factor for customers. Fortify's endpoints already existed; only the screen
was missing. Setting up requires re-entering the password first, and every
action re-checks that server-side rather than relying on the button not being on
screen — a Livewire action is reachable by anyone who can post to
/livewire/update. The confirmation marker is Laravel's own session key, so it
and the framework's password.confirm middleware mean the same thing rather than
drifting apart.

The secret never enters a Livewire property. Component state travels to the
browser and back in the snapshot; the QR image is derived from the secret, the
secret is not in it — and there is a test that says so.

The status page moves to the ROOT of its hostname: status.clupilot.com/status
says the same word twice. That needed the domain-bound `/` registered BEFORE the
landing page, because Laravel takes the first match and the landing route is
host-agnostic — so the status host would have served the marketing site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:48:34 +02:00
Claude 935c9ae6ac Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
Three gaps, all of the "half-built" kind.

Nobody could change a password. Fortify's updatePasswords feature was switched
off, so an account created with a generated password kept it until someone
opened a shell on the server — including the owner's own. Both settings pages
have the form now, sharing one concern, because two copies of a password rule is
how one of them ends up weaker. It goes through Fortify's own action rather than
hashing here, and the current password is required: an unlocked machine should
not be two keystrokes away from locking its owner out.

The datacenter edit form had no active switch. The column and the scope existed,
and the list page has a toggle — but the form you open to change the thing did
not offer the one lifecycle action a location actually needs. Switching one off
now says what it does NOT do: existing hosts keep running. Once, on the actual
transition, and as the only message — the generic "saved" toast would otherwise
replace it, and an already-inactive location would have announced its own
deactivation every time its name was edited.

The staff table's actions cell is empty for your own row, because you cannot
revoke yourself. With a one-person team the column was therefore always blank
and read as broken rather than as a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:49:07 +02:00
nexxo df10448e5d feat(portal): settings page — company profile, branding (logo+colors), cancel package, close account
- customers gain profile + branding + closed_at; instances gain cancellation
  fields; branding resolver (NULL -> CluPilot defaults) snapshotted into the
  provisioning run context
- cancel package: term-end, irreversible, typed-confirm modal (R5)
- close account: guarded (no active package), typed-confirm modal (R5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:36:52 +02:00