Skip to content

Panel: auth, 2FA, extension runtime, first-party sections (spec 0049, slices 3-8)#2553

Draft
glennjacobs wants to merge 55 commits into
2.xfrom
feature/0049-panel-scaffold
Draft

Panel: auth, 2FA, extension runtime, first-party sections (spec 0049, slices 3-8)#2553
glennjacobs wants to merge 55 commits into
2.xfrom
feature/0049-panel-scaffold

Conversation

@glennjacobs

Copy link
Copy Markdown
Contributor

Summary

Continues the lunarphp/panel package on top of #2551 (slices 1-2). This PR covers spec 0049's remaining slices:

  • Staff authentication: login/logout, rate limiting, session handling
  • Two-factor authentication: TOTP (Filament-compatible secret/recovery-code semantics) plus an automatic emailed 6-digit code fallback for staff without an authenticator app configured — every staff login now requires a second factor
  • Client-side extension runtime (window.LunarPanel): page/component/translation registries consumed by add-on bundles
  • First-party Customers and Settings/Channels sections, dogfooding the extension mechanism
  • Design-prototype-faithful auth UI (Login, 2FA challenge) including fonts, hero image, icon states
  • WHATS-NEW.md entry documenting the new package for downstream consumers
  • Full 16-locale translation parity and Pest coverage throughout

Spec: specs/0049-inertia-panel.md (status: implemented, all slices checked off).

Test plan

  • vendor/bin/pest --testsuite panel --parallel — 160 passed
  • vendor/bin/pest --testsuite core --parallel — 944 passed, 1 skipped
  • vendor/bin/pint --dirty --format agent — pass
  • Manual QA in the host app: login, TOTP challenge, email-code fallback (including resend), recovery-code path

🤖 Generated with Claude Code

glennjacobs and others added 30 commits July 10, 2026 14:31
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ntics

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…totype

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the panel::auth lang group (English canonical + 15 real
translations) that Tasks 6-9 controllers/pages will consume, plus a
key-parity test guarding all locales against drift.
…enge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ice 3 shipped

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Panel build assets under packages/panel/public/build were force-added
in a prior commit based on a mistaken assumption that committing built
assets was an established convention from slice 1. It never was: no
prior commit (slices 1-9) tracked anything there, and the package's
own .gitignore already excludes the path. Untrack the three files;
they remain on disk as untracked local build output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
window.LunarPanel registry (page/component/layout/translation registration
with a pre-boot pending queue), Inertia page resolution that checks the
runtime registry before local pages and retries after DOMContentLoaded on
hard refresh, a PanelSlot.vue renderer for PHP-registered slot zones, and
the add-on Vite plugin + published .d.ts that let add-on bundles compile
as IIFEs sharing the panel's Vue instance.
Adds PanelLayout.vue with a collapsible sidebar (desktop) / drawer (mobile)
rendering the navigation and settingsNavigation Inertia props, a top bar with
a light/dark/system theme toggle (useTheme composable), and generalizes
useLang to support arbitrary namespaces while keeping useAuthLang for the
existing auth pages. Wires panel::nav translations into the shared Inertia
lang prop and wraps Dashboard.vue in the new layout.
Self-contained SettingsShell layout driven by the settingsNavigation
Inertia prop, plus a ChannelsSection with index/create/edit/delete
routes and controllers. Handle auto-slugs via the Channel model
mutator; delete is blocked when hasOrderHistory() is true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sers/activity tabs

Registers a Sales section with customer CRUD, nested address CRUD, and
storefront user link/unlink, plus page-local Vue UI (no shared DataTable
or layout dependency, per concurrent-agent boundaries).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Registers SalesSection and ChannelsSection in PanelServiceProvider::boot()
so Customers and Channels appear in navigation and their routes load.

Fixes an ambiguous `id` column SQL error in CustomerEditController's
users() query — the customer_user pivot table has its own `id` column,
so unqualified column selection collided with the joined users table.
Faithful TypeScript conversion of the Reka UI-based modal/tooltip
primitives from lunar-v2-ui, preserving exact Tailwind classes,
animation classes, and Reka component structure.
Faithful TypeScript port of the three components from lunar-v2-ui,
adapting router-link/vue-router usage to Inertia's Link/router and
Pagination's contract to Laravel's paginator meta shape.
Faithful TypeScript conversions of the prototype's Reka UI form/nav
components, following the established withDefaults/script-setup
conventions used by Button, TextInput, FieldLabel, Checkbox, Icon.
…ageEmpty from prototype

Faithful TypeScript ports of the static Vue SPA prototype's layout/status
components, adapted to the real Address model and spatie/activitylog shapes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rewrite PanelLayout/SettingsShell to match the prototype's Nav/NavBody/
SettingsNav/SettingsNavBody structure: collapsible aside with floating
toggle button, ⌘\ keybind, dialog-backed mobile drawer, contextual
sub-nav shown only for the active section, and a UserMenu footer
(theme picker + sign out) wired to real auth/theme data instead of
prototype mock data. Collapse/drawer state persists via a new
useNavState composable, mirroring useTheme's localStorage pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the plain table/native confirm/native select on the Channels
list and edit pages with DataTable, ConfirmDialog, Select, Toggle and
StatusBadge, matching the real prototype (ChannelsList.vue /
ChannelEdit.vue) now that the ported component library exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the rushed native-table/native-select/native-confirm Customers
pages with the real ported component library: DataTable + Pagination +
Select for the index, Tabs + AddressCard + ActivityTimeline +
ConfirmDialog + Combobox for the edit screen, matching CustomersList.vue
and CustomerDetail.vue from the design prototype. Backend contracts are
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
glennjacobs and others added 25 commits July 10, 2026 14:31
…15 locales

The en/nav.php lang file gained settings, toggle_theme, and
toggle_sidebar keys but only the English file was updated, breaking the
house rule that every locale must mirror English keys. Adds genuine,
professionally-toned translations for all 15 non-English locales and a
Pest test asserting key parity, mirroring the existing auth.php pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Introduces CreateCustomer, UpdateCustomer, DeleteCustomer,
CreateCustomerAddress, UpdateCustomerAddress, DeleteCustomerAddress,
LinkCustomerUser and UnlinkCustomerUser action/contract pairs, bound in
ActionServiceProvider, so the panel's customer controllers can be
wired to the core action-layer convention instead of touching Eloquent
directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Introduces CreateChannel, UpdateChannel, and DeleteChannel actions (with
CreatesChannel/UpdatesChannel/DeletesChannel contracts) so channel
create/update/delete goes through the action-seam convention instead of
ad-hoc Eloquent calls. Both create and update clear the `default` flag on
every other channel in the same operation when the incoming channel becomes
default. Delete refuses to remove a channel with order history, raising
ChannelActionException instead of silently no-oping. Adds a missing
`default => boolean` cast to the Channel model so the flag behaves
consistently with sibling models (Currency, Location, TaxZone, Url).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers boot ordering and pending-callback draining, the extension
component registry's warn-once-per-missing-name behaviour, translation
merging, page resolution fallbacks (registry -> local glob ->
DOMContentLoaded retry), and PanelSlot's priority-ordered slot
rendering. Extracts app.ts's inline page-resolution logic into
runtime/pageResolver.ts so it's unit-testable without a real Inertia
app or import.meta.glob; app.ts's runtime behaviour is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Route Channels create/update/delete through CreatesChannel, UpdatesChannel,
and DeletesChannel instead of direct Eloquent CRUD. Delete now catches
ChannelActionException from DeletesChannel and flashes an error instead of
duplicating the hasOrderHistory() guard. Fixes a latent bug where omitting
status on update nulled out the NOT NULL status column.

Adds comprehensive Feature coverage for index, create, edit, update, and
delete (including the order-history delete guard), where none existed before.
…ensions

- CustomerCreateController, CustomerEditController, CustomerAddressController
  and CustomerUserController now delegate mutations to the Customers action
  contracts instead of direct Eloquent calls.
- CustomerUserController turns LinksCustomerUser's ModelNotFoundException into
  a validation error on the email field.
- Fix a latent bug: the panel read/wrote a non-existent `contact_mail` column
  on addresses; the real column is `contact_email` (core migration + factory
  already used it). Renamed throughout the address controller/edit page.
- Fix a second latent bug: filtering the customer index by group threw an
  "ambiguous column" SQL error because the whereHas closure queried an
  unqualified `id`.
- CustomerIndexController now consults PanelManager::resolveExtensions()
  for customers.index — merges extension columns into the columns prop,
  applies extension search/column query hooks before pagination.
- Edit.vue renders the customers.edit:main:after slot zone via PanelSlot.
- Add fixture add-on (Fixtures/Customers) plus feature tests proving the
  slot and table-extension wiring is exercised end-to-end through the real
  routes, not just unit-tested against the resolver.
- Add full Pest coverage for Customers index/create/edit/address/user CRUD,
  previously untested.
Proves the reference add-on's page, nav item, slot, and table extension
actually integrate with the real Customers pages, not just in isolation.
Wires LunarPanelExample into the monorepo's dev autoload so its own
service provider can be registered in tests, and expands the add-on
README into a full extension guide with real code snippets, the
zone-naming convention, and a troubleshooting section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds GET /panel/translations/{locale}, enumerating every panel::* lang
file for a locale and hashing their mtimes into a version string so the
frontend can cache-bust localStorage correctly. Registered outside the
authenticated route group so guest screens (login, 2FA, reset) can load
it too. Falls back to app.fallback_locale for unsupported locales.

Drops the interim 'lang' Inertia shared prop now that the endpoint
replaces it.
Installs vue-i18n and boots a global i18n instance in app.ts, loading
messages from the new translations endpoint. Caches the response in
localStorage under a version-hash-tagged key (with a version pointer
per locale) so repeat visits render synchronously with no network wait
or blank-string flash; a cold cache is awaited once before mounting.

registry.ts gains onTranslationsRegistered() so add-on-pushed messages
(via the existing registerTranslations()) merge into the same i18n
message store, replaying anything already registered to handle either
script-load order.

Every useLang()/useAuthLang() call site now uses vue-i18n's useI18n(),
with keys requalified to the namespace.key shape (e.g. sign_in_title ->
auth.sign_in_title). Removes the now-unused useLang.ts shim.
New EmailTwoFactor service issues a hashed, single-use six-digit code
(10 minute TTL) with a 30 second resend cooldown, backing the panel's
fallback second factor for staff without TOTP configured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AuthenticatedSessionController::store() no longer logs staff straight
in when TOTP isn't configured — every successful credential check now
stashes the pending login and redirects to the two-factor challenge,
which sends an emailed code instead of a TOTP prompt. The challenge
controller derives the mode (authenticator vs email) from the pending
user each request rather than trusting session state, exposes an
obfuscated email and resend cooldown as Inertia props, and adds a
resend action reusing the existing 5-attempt rate limiter.

Updates the login and password-reset tests that previously asserted
instant authentication for staff with no TOTP, since that path no
longer exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the email-mode variant to TwoFactorChallenge.vue: a mail icon
badge, an obfuscated-address subtitle, and a resend link with a
server-seeded countdown so a page refresh doesn't reset an active
cooldown. The recovery-code toggle now only renders in authenticator
mode, since email-mode staff have no TOTP enrolment to recover into.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds challenge_email_subtitle, resend_code, resend_in_seconds,
code_resent, and the two_factor_email_* notification keys to all 16
panel::auth locale files. The two keys interpolated client-side
(challenge_email_subtitle, resend_in_seconds) use vue-i18n's {name}
placeholder syntax; the notification-only keys keep Laravel's :name
syntax, matching how each is actually rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Avoids leaving a phantom resend cooldown and code hash behind when
mail delivery throws — nothing is cached unless the notification send
actually succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Skips the "Forgot password?" link in tab order (tabindex="-1") so Tab goes
straight from email to password. Autofocuses the email field on mount via
a new focus() exposed from TextInput, more reliable than the native
autofocus attribute across both full page loads and client-side Inertia
navigation to the login screen. Copyright footer now reads "LunarPHP Ltd"
instead of the panel display name.
…ye toggle

- Load Geist/Geist Mono via Google Fonts, matching the design prototype's
  index.html exactly, plus the prototype's body-level typography classes
  (13px base, 1.45 line height, cv11/ss01 font features) — previously
  "Geist" silently fell back to system fonts since it was never loaded.
- Port the prototype's hero image + caption onto the real Login page
  (same placeholder Unsplash asset the prototype uses as a dev stand-in;
  swap HERO_IMAGE for a real brand asset before shipping).
- Restore the forward-arrow icon on the Continue button.
- Password-reveal toggle now swaps eye/eyeOff icons to reflect state, not
  just the aria-label (new eyeOff icon added — the prototype itself never
  implemented this, so this is a usability addition beyond parity).
…ubmit

CodeInput auto-submits on the 6th digit, so the "Verify and sign in" button
was never actually clickable in that path — by the time it would become
enabled, the request had already fired. Now it only shows during the
recovery-code flow, where there's no auto-submit and a real click target
is needed; the code-entry path shows a small spinner + status text while
the auto-submitted request is in flight, nothing otherwise.
Covers the Inertia + Vue admin, its runtime extension mechanism, the
mandatory 2FA fallback, and install instructions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant