Commit Graph

13 Commits (v1.3.2)

Author SHA1 Message Date
nexxo f970dda0bb Send an already signed-in operator back to the console, not the portal
guest:operator's authenticated-user redirect had no console-aware
override, so it fell to Laravel's own default: the named 'dashboard'
route, the portal. An operator revisiting admin.login while already
signed in bounced to the portal's own login instead — in exclusive
mode, to a 404, since dashboard isn't a console route there.

redirectUsersTo() now mirrors the existing redirectGuestsTo() override
right above it, keyed on the same AdminArea::isConsole($request)
condition, so the two read as a pair.
2026-07-28 15:55:39 +02:00
nexxo 92dd6cc2d8 Refuse a two-factor completion for a revoked operator
completeLogin() re-checks isActive()/isOperator() immediately before signing
in, not only at the password step. The two-factor challenge previously only
confirmed the operator row still existed (Operator::find() + a null check),
so disabling an operator or revoking every console role in the window between
the password step and the pending code did not stop the login from
completing — the realistic case is an owner locking someone out mid-login,
not a contrived one.

One check, in the static method both OperatorLogin's direct exit and
OperatorTwoFactorChallenge::verify() already share, rather than a second copy
in the challenge. completeLogin() now returns bool instead of void: false
means refused, and neither the guard nor the session was touched. Each caller
turns that into a ValidationException in its own vocabulary — 'email' /
auth.failed for the direct exit (already unreachable in practice, since
authenticate() just checked the same two conditions synchronously; kept for
defense in depth), 'code' / auth.not_an_operator for the challenge, since the
code entered may be genuinely correct and reusing "wrong code" would mislead.
2026-07-28 15:47:39 +02:00
nexxo 20329b176b Refuse a portal login for an address that already belongs to an operator
Customer::ensureUser() enforced uniqueness only within users, so a
customer provisioned (or registering publicly) with an operator's email
got a second, colliding identity for the same address — the exact thing
R21 exists to prevent. Checks Operator by address now, not by session
(the deleted assertNotAdmin() checked who was signed in, which could
never be the right question).

Public registration gets the same guard via a plain unique() validation
rule, reusing Laravel's stock "already taken" message rather than a
dedicated one — naming the collision specifically would tell a public
visitor an address belongs to staff, a worse leak than the generic
refusal every other collision here already gives them.

Impersonation answers 409 instead of crashing into it. The Stripe
webhook keeps recording the paid order (same principle as
openContract()'s own comment: the order is proof money changed hands)
and withholds only the colliding login, logged for an operator to
resolve by hand.

Verified the reverse direction Codex flagged as already covered: two
Admin\Settings actions and the clupilot:create-operator command all
already refuse an operator email that collides with a customer's, and
that check is sound today because every users row is created in
lockstep with a customers row of the same email (CreateNewUser and
ensureUser are the only two creation sites, both confirmed by grep) and
nothing in this app can change a user's email afterwards (Fortify's
updateProfileInformation feature is disabled, and no other code calls
that action).
2026-07-28 14:42:16 +02:00
nexxo 8f8a0ad4f7 Stop the console serving the portal's sign-in page
Deleted tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php — it tested
the three deleted response classes. Its intent (an operator's sign-in landing
in the console, not the portal) is covered by OperatorLoginTest's "signs an
operator in on the operator guard" and OperatorTwoFactorChallengeTest's "signs
the operator in once the code actually checks out", both confirmed present
before deletion; the route-separation half is covered by the new
ConsoleHostSeparationTest cases added in this commit.
2026-07-28 11:45:34 +02:00
nexxo 3f318c3f6a Give the console a front door of its own 2026-07-28 11:45:22 +02:00
nexxo 87c49de6e5 Move the console's identity out of the customer table 2026-07-28 10:31:43 +02:00
nexxo 2a06b5dfb2 Say EU and stop there, as the approved template does 2026-07-28 09:19:24 +02:00
Claude 492b1925fb Land a sign-in where it was performed, not always in the customer portal
tests / pest (push) Successful in 7m42s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 6s Details
Fortify sends every successful sign-in to config('fortify.home') — /dashboard.
That is why an operator signing in ended up in the customer portal, and once
the console has a hostname to itself it stops being merely confusing:
/dashboard does not exist on that host, so signing in would land on a 404.

Three outcomes now, decided by where the sign-in happened and who signed in:
the console for an operator on the console, the portal everywhere else, and for
a non-operator on the console the session is taken away again at the moment it
was created — a guard that merely refuses each page afterwards leaves that
session in the browser.

Four things the review caught, each of which would have left a hole:

- Fortify never reaches LoginResponse when a two-factor challenge was involved.
  Binding only that one left exactly the accounts most likely to be operators
  on the old behaviour, so both exits are bound and share one decision.
- The JSON branch ran before the authorization check, so a JSON client kept a
  session a browser would have lost.
- The two exits do not share a JSON success contract — an ordinary sign-in
  answers {"two_factor":false}, a completed challenge answers an empty 204 —
  and merging them breaks a client keying off the status code.
- In shared mode everyone posts to /login, so the request never looks like the
  console even when /admin is the destination. The intended URL is read too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:15:16 +02:00
nexxo 35a86c413c fix(auth): throttle Fortify endpoints (registration had no limiter — signup-spam guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:38:44 +02:00
nexxo 6ca1e3fba5 fix(auth): create + link a Customer on public signup so the portal works pre-purchase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:31:08 +02:00
nexxo 39a1f708a1 fix(auth): reject signup with an existing customer email (account-claim guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:29:35 +02:00
nexxo 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>
2026-07-25 18:16:27 +02:00
nexxo 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>
2026-07-25 01:20:25 +02:00