From ce0828764182e65b92fb618fd95cf46a622806c2 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 3 Jul 2026 02:15:31 +0300 Subject: [PATCH] Feat: Moving things, updating OTP, cleaning stuff up --- CHANGELOG.md | 25 ++ README.md | 2 + composer.json | 6 +- config/core.php | 19 + ...26_07_02_000001_add_otp_to_users_table.php | 23 + ...02_drop_otp_from_lunar_customers_table.php | 23 + ..._000001_drop_password_from_users_table.php | 30 ++ ...0002_make_name_nullable_on_users_table.php | 22 + ...ames_nullable_on_lunar_customers_table.php | 24 + docs/modules.md | 423 ++++++++++++++++++ ...tomer-otp.blade.php => user-otp.blade.php} | 0 resources/views/ui/stoic-image.blade.php | 33 ++ src/Auth/Events/UserCreated.php | 12 + .../{CustomerOtpMail.php => UserOtpMail.php} | 4 +- src/Auth/Services/CustomerOtpService.php | 61 --- src/Auth/Services/UserOtpService.php | 48 ++ .../Extensions/CustomerResourceExtension.php | 10 +- .../Listeners/CreateCustomerForUser.php | 23 + src/Customer/Models/Customer.php | 13 +- .../RelationManagers/UserRelationManager.php | 34 ++ src/Providers/AuthServiceProvider.php | 31 -- src/Providers/CoreServiceProvider.php | 11 +- src/Providers/CustomerServiceProvider.php | 12 +- 23 files changed, 770 insertions(+), 119 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 config/core.php create mode 100644 database/migrations/2026_07_02_000001_add_otp_to_users_table.php create mode 100644 database/migrations/2026_07_02_000002_drop_otp_from_lunar_customers_table.php create mode 100644 database/migrations/2026_07_03_000001_drop_password_from_users_table.php create mode 100644 database/migrations/2026_07_03_000002_make_name_nullable_on_users_table.php create mode 100644 database/migrations/2026_07_03_000003_make_names_nullable_on_lunar_customers_table.php create mode 100644 docs/modules.md rename resources/views/auth/mail/{customer-otp.blade.php => user-otp.blade.php} (100%) create mode 100644 resources/views/ui/stoic-image.blade.php create mode 100644 src/Auth/Events/UserCreated.php rename src/Auth/Mail/{CustomerOtpMail.php => UserOtpMail.php} (81%) delete mode 100644 src/Auth/Services/CustomerOtpService.php create mode 100644 src/Auth/Services/UserOtpService.php create mode 100644 src/Customer/Listeners/CreateCustomerForUser.php create mode 100644 src/Customer/RelationManagers/UserRelationManager.php diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c5b3f45 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.0.1] - 2026-07-03 + +First release. + +### Added +- OTP-based authentication built around `User` instead of `Customer` (`UserOtpService`, `UserOtpMail`), replacing the earlier customer-scoped OTP flow. +- `UserCreated` event with a `CreateCustomerForUser` listener to provision a Lunar customer automatically when a user is created. +- `UserRelationManager` for managing users from the customer resource in the panel. +- Stoic image UI component (`resources/views/ui/stoic-image.blade.php`) and its YAML-driven config/service (see `Stoic::class`). +- `config/core.php` for module-level configuration. +- `AuthServiceProvider` and `CustomerServiceProvider` now register alongside `CoreServiceProvider`. +- Migrations: add OTP to `users`, drop OTP from Lunar `customers`, drop `password` from `users`, make `name` nullable on `users` and Lunar `customers`. +- `docs/modules.md` documenting module structure. + +### Removed +- `CustomerOtpMail` and `CustomerOtpService`, superseded by the user-based OTP flow. + +### Dependencies +- Added explicit `symfony/yaml` requirement (used directly by `Stoic::loadConfig()`). diff --git a/README.md b/README.md index 3228e5b..45a8329 100644 --- a/README.md +++ b/README.md @@ -122,3 +122,5 @@ php artisan migrate - [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow - [`docs/activity-log.md`](docs/activity-log.md) — Activity logging - [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference +- [`docs/notifications.md`](docs/notifications.md) — Notification registry +- [`docs/modules.md`](docs/modules.md) — Module architecture, Customer/User pairing, provider registration pitfalls diff --git a/composer.json b/composer.json index 471a1e7..2d6764d 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "1.0.0", + "version": "0.0.1", "autoload": { "psr-4": { "Modules\\Core\\": "src/" @@ -28,7 +28,9 @@ "extra": { "laravel": { "providers": [ - "Modules\\Core\\Providers\\CoreServiceProvider" + "Modules\\Core\\Providers\\CoreServiceProvider", + "Modules\\Core\\Providers\\AuthServiceProvider", + "Modules\\Core\\Providers\\CustomerServiceProvider" ] } }, diff --git a/config/core.php b/config/core.php new file mode 100644 index 0000000..5e0f027 --- /dev/null +++ b/config/core.php @@ -0,0 +1,19 @@ + true, + +]; diff --git a/database/migrations/2026_07_02_000001_add_otp_to_users_table.php b/database/migrations/2026_07_02_000001_add_otp_to_users_table.php new file mode 100644 index 0000000..568109c --- /dev/null +++ b/database/migrations/2026_07_02_000001_add_otp_to_users_table.php @@ -0,0 +1,23 @@ +string('otp_code', 6)->nullable()->after('password'); + $table->timestamp('otp_expires_at')->nullable()->after('otp_code'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['otp_code', 'otp_expires_at']); + }); + } +}; diff --git a/database/migrations/2026_07_02_000002_drop_otp_from_lunar_customers_table.php b/database/migrations/2026_07_02_000002_drop_otp_from_lunar_customers_table.php new file mode 100644 index 0000000..afb72d5 --- /dev/null +++ b/database/migrations/2026_07_02_000002_drop_otp_from_lunar_customers_table.php @@ -0,0 +1,23 @@ +dropColumn(['otp_code', 'otp_expires_at']); + }); + } + + public function down(): void + { + Schema::table('lunar_customers', function (Blueprint $table) { + $table->string('otp_code', 6)->nullable()->after('meta'); + $table->timestamp('otp_expires_at')->nullable()->after('otp_code'); + }); + } +}; diff --git a/database/migrations/2026_07_03_000001_drop_password_from_users_table.php b/database/migrations/2026_07_03_000001_drop_password_from_users_table.php new file mode 100644 index 0000000..f4259c7 --- /dev/null +++ b/database/migrations/2026_07_03_000001_drop_password_from_users_table.php @@ -0,0 +1,30 @@ +dropColumn('password'); + }); + + Schema::dropIfExists('password_reset_tokens'); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('password')->after('email_verified_at'); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } +}; diff --git a/database/migrations/2026_07_03_000002_make_name_nullable_on_users_table.php b/database/migrations/2026_07_03_000002_make_name_nullable_on_users_table.php new file mode 100644 index 0000000..cb46915 --- /dev/null +++ b/database/migrations/2026_07_03_000002_make_name_nullable_on_users_table.php @@ -0,0 +1,22 @@ +string('name')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('name')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_07_03_000003_make_names_nullable_on_lunar_customers_table.php b/database/migrations/2026_07_03_000003_make_names_nullable_on_lunar_customers_table.php new file mode 100644 index 0000000..5fd5c1a --- /dev/null +++ b/database/migrations/2026_07_03_000003_make_names_nullable_on_lunar_customers_table.php @@ -0,0 +1,24 @@ +string('first_name')->nullable()->change(); + $table->string('last_name')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('lunar_customers', function (Blueprint $table) { + $table->string('first_name')->nullable(false)->change(); + $table->string('last_name')->nullable(false)->change(); + }); + } +}; diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..2a8433f --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,423 @@ +# Module Architecture + +This project uses standalone Composer packages for shared functionality, pulled into each e-shop app as regular dependencies. `boboko/core` (this repo) is the foundational module — every e-shop requires it. Other modules (e.g. `boboko/appointments`) require `boboko/core` in turn and layer additional, optional functionality on top. + +Unlike an in-repo `modules/` directory, each module here lives in **its own repository** from day one. An app pulls it in via Composer, either from a `path` repository (local development, symlinked) or a `vcs`/Packagist reference (plain dev and production). + +--- + +## Directory Structure + +A module repository (e.g. `boboko-core`) is a self-contained Composer package: + +``` +boboko-core/ (this repo) + composer.json + config/ + core.php + database/migrations/ + resources/ + views/ + logos/ + js/ + docs/ + src/ + Auth/ + Events/ + Extensions/ + Filament/Pages/ + Mail/ + Models/ + Services/ + Customer/ + Extensions/ + Listeners/ + Models/ + RelationManagers/ + Command/ + Providers/ + CoreServiceProvider.php ← entry point, registers itself + config/migrations/assets + AuthServiceProvider.php ← staff/admin auth wiring not covered elsewhere + CustomerServiceProvider.php ← ModelManifest + Customer↔User event wiring + Stoic/ +``` + +The e-shop app (`boboko-test`) consumes it as `vendor/boboko/core`: + +``` +boboko-test/ + app/ + Models/ + Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer + User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated + Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff + Lunar/ + Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider) + Pages/ ← app's own Create/Edit page overrides (event dispatch timing, prefill, etc.) + Providers/ + AppServiceProvider.php + PanelServiceProvider.php ← all LunarPanel wiring for this app + EventServiceProvider.php ← all Event::listen registrations for this app + config/ + core.php ← published from boboko-core, per-app overrides (e.g. auto_create_customer_for_user) + modules/ + Appointments/ ← a second module, symlinked/required the same way as core +``` + +Within a module, organise by concern rather than by type (`Auth/`, `Customer/`, not `Models/`, `Services/` at the top level). + +--- + +## Pulling In a Module + +**In the app's root `composer.json`:** + +```json +"require": { + "boboko/core": "^1.0" +}, +"repositories": [ + { + "type": "vcs", + "url": "https://code.radical-elements.com/boboko/core.git" + } +] +``` + +This is the **default** — a fresh `composer install` never depends on a local checkout existing. + +**When developing the module itself alongside the app**, swap the `repositories` entry for a `path` repo pointing at a sibling checkout: + +```json +"repositories": [ + { + "type": "path", + "url": "../boboko-core", + "options": { "symlink": true } + } +] +``` + +This is a **manual, deliberate swap** of `composer.json` — not automated, since Composer has no clean built-in mechanism to merge an additional repository on top of the main list per-environment (`composer.local.json` is not auto-merged in Composer 2.x; `COMPOSER_REPOSITORIES` does not merge either — both were tested and ruled out). Swap it back to `vcs` before committing, so CI/prod builds never require `../boboko-core` to exist. + +### Docker Compose: the local-core mount + +`docker-compose.dev.yml` does **not** mount `../boboko-core` by default — normal app development never needs the sibling checkout present. A separate override file adds just that mount: + +``` +docker-compose.core-dev.yml ← adds ../boboko-core:/var/www/boboko-core to app/queue/scheduler +``` + +```bash +# normal app dev +docker compose -f docker-compose.yml -f docker-compose.dev.yml up + +# developing core locally (also requires the composer.json path-repo swap above) +docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.core-dev.yml up +# or: bin/dc-core.sh up +``` + +Docker Compose merges `volumes:` lists additively across `-f` files, so the override only needs to declare the one extra mount per service. + +--- + +## Creating a New Module + +**1. Create the repository and `composer.json`:** + +```json +{ + "name": "boboko/your-module", + "description": "What it does", + "type": "library", + "version": "1.0.0", + "require": { + "boboko/core": "^1.0" + }, + "autoload": { + "psr-4": { + "Modules\\YourModule\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Modules\\YourModule\\Providers\\YourModuleServiceProvider" + ] + } + } +} +``` + +**2. Create the service provider:** + +```php +namespace Modules\YourModule\Providers; + +use Illuminate\Support\ServiceProvider; + +class YourModuleServiceProvider extends ServiceProvider +{ + public function register(): void {} + + public function boot(): void + { + $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'your-module'); + $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + } +} +``` + +**Register every provider the module needs in `extra.laravel.providers`.** A module can define more than one provider (e.g. `CoreServiceProvider`, `AuthServiceProvider`, `CustomerServiceProvider`) — Composer's package auto-discovery only boots providers actually listed here. A provider class that exists but isn't listed silently never runs; this has bitten this project before (see "Provider Registration Pitfalls" below). + +**3. Require it in the app's `composer.json`**, same as any other module (see "Pulling In a Module" above). + +Then run `composer update boboko/your-module`. + +--- + +## Provider Registration Pitfalls + +Composer's Laravel package auto-discovery only boots providers explicitly listed in `extra.laravel.providers`. It is easy to write a second/third provider in a module (e.g. splitting concerns into `AuthServiceProvider`, `CustomerServiceProvider`) and forget to add it to that list — the class compiles fine, autoloads fine, and simply never boots. Symptoms are subtle: `ModelManifest::replace()` never runs, event listeners never attach, and nothing errors — code just silently does nothing. + +**Before assuming a provider's `boot()` logic is live, check `vendor/composer/installed.json`'s `extra.laravel.providers` for the package**, not just `composer.json` (a local `path`-repo package's cached `extra` metadata does not refresh on file edits alone — `composer dump-autoload` is not enough; a full `composer update ` is required to re-read a changed `composer.json`, and lock-file version conflicts can block that update). + +### Duplicate registration between module and app + +When a module ships a provider that overlaps with app-level Filament wiring (e.g. both independently call `LunarPanel::extensions([...])` for the same resource, or both register the same model-created listener), only one should survive. Prefer whichever is **proven to run** in production (usually the app-level `PanelServiceProvider`/`CorePlugin` wiring, since that's what's actually referenced from the panel registration) and strip the duplicate from the module provider — don't leave both, since duplicate `created`/`updated` listeners fire twice (e.g. sending two invite emails). + +--- + +## Extending the Staff Model via Traits + +Modules never define their own `Staff` model. Instead, each module ships a trait with its behaviour, and `app/Models/Staff.php` opts in to whatever the app needs. + +**1. Define a trait in your module:** + +```php +namespace Modules\YourModule\Concerns; + +trait HasYourFeature +{ + public function yourRelationship(): HasMany + { + return $this->hasMany(YourModel::class); + } +} +``` + +**2. Use it in `app/Models/Staff.php`:** + +```php +namespace App\Models; + +use Modules\Core\Auth\Models\Staff as CoreStaff; +use Zap\Models\Concerns\HasSchedules; +use Modules\YourModule\Concerns\HasYourFeature; + +class Staff extends CoreStaff +{ + use HasSchedules; + use HasYourFeature; +} +``` + +The app decides which traits to compose — modules have no knowledge of each other. + +--- + +## Model Inheritance Chain + +``` +Lunar\Admin\Models\Staff ← Lunar base (auth, roles, Filament) + └── Modules\Core\Auth\Models\Staff ← OTP fields + └── App\Models\Staff ← app model, composes all traits + +Lunar\Models\Customer ← Lunar base (buyer, addresses, orders) + └── Modules\Core\Customer\Models\Customer ← full_name accessor, title suppressed + └── App\Models\Customer ← app model, emails()/phones(), Elorus fields +``` + +Modules never extend each other's `Staff`/`Customer`. If a module needs staff or customer behaviour, it ships a trait, or (for core-generic behaviour) it lives directly in the module's own model, which the app then extends. + +**`Customer` is not Authenticatable.** Auth is handled entirely by `App\Models\User` (see "Customer/User Pairing" below) — this changed during this project; earlier revisions made `Customer` implement `Authenticatable` directly, which has since been reverted. + +--- + +## Customer/User Pairing + +Two independent Laravel models exist per person: `App\Models\User` (the only `Authenticatable` entity, `web` guard) and `App\Models\Customer` (Lunar's buyer record — addresses, orders, no login). They are linked via Lunar's own `customer_user` pivot table (`$customer->users()` / `$user->customers()` / `$user->latestCustomer()`), not a direct foreign key — this is Lunar's native relationship shape, not something this project invented. + +**Which direction creates which depends on the app:** + +- **Generic e-shop flow (core's default, `config('core.auto_create_customer_for_user')`, default `true`):** a storefront visitor supplies only an email (Shopify-style passwordless). `Modules\Core\Auth\Services\UserOtpService::generateAndSend()` finds-or-creates the `User`. A genuinely new `User` fires `Modules\Core\Auth\Events\UserCreated`, handled by `Modules\Core\Customer\Listeners\CreateCustomerForUser`, which creates a bare `Customer` (no name yet — nothing was collected at signup) and attaches it via the pivot. +- **This app's flow (`boboko-test`, flag set to `false` in its published `config/core.php`):** staff create a `Customer` directly (often pre-filled from an appointment booking) with real business data (name, email, VAT, etc.). `App\Events\CustomerCreated` (dispatched manually — see "Event Timing" below) is handled by `App\Listeners\CreateUserForCustomerListener`, which finds-or-creates the paired `User` by the customer's primary email. + +**A shop opts out of core's default cascade by publishing and editing `config/core.php`:** + +```bash +php artisan vendor:publish --tag=core-config +``` + +```php +// config/core.php +'auto_create_customer_for_user' => false, +``` + +Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once. + +--- + +## Event Timing: Filament Repeaters and Model Events + +**Do not dispatch domain events from a model's `$dispatchesEvents` (`created`/`updated`) when the event's listeners depend on related data saved via a Filament relationship `Repeater`** (e.g. `Repeater::make('emails')->relationship('emails')`). Filament's `CreateRecord`/`EditRecord` pages save the base record first (firing Eloquent's `created`/`updated` immediately), then save repeater-managed relations in a separate step afterward. A listener that reads `$customer->primaryEmail()` inside a `CustomerCreated`/`CustomerUpdated` handler triggered this way will silently see no email — no error, just a no-op. + +**Fix:** dispatch the event manually from the Filament page's `afterCreate()`/`afterSave()` hook instead, once relations are guaranteed persisted: + +```php +// app/Lunar/Pages/CreateCustomer.php +class CreateCustomer extends BaseCreateRecord +{ + protected function afterCreate(): void + { + /** @var \App\Models\Customer $customer */ + $customer = $this->record; + + event(new CustomerCreated($customer)); + } +} +``` + +```php +// app/Lunar/Pages/EditCustomer.php +class EditCustomer extends BaseEditCustomer // extends Lunar's own EditCustomer, not BaseEditRecord directly +{ + public function afterSave(): void + { + parent::afterSave(); // preserves Lunar's own afterSave() behaviour (e.g. sync_with_search) + + event(new CustomerUpdated($this->record)); + } +} +``` + +Remove the corresponding entry from the model's `$dispatchesEvents` array so the event only fires once, from the page. + +Both pages must be wired into the resource's `extendPages()`: + +```php +public function extendPages(array $pages): array +{ + $pages['create'] = CreateCustomer::route('/create'); + $pages['edit'] = EditCustomer::route('/{record}/edit'); + + return $pages; +} +``` + +--- + +## Splitting Service Providers + +When a service provider grows beyond a handful of responsibilities, split it into focused sub-providers and register **each one individually** in `extra.laravel.providers` (see "Provider Registration Pitfalls" above — registering only the entry-point provider silently drops the others). + +**Each sub-provider owns one concern:** + +```php +// AuthServiceProvider — staff auth wiring not already covered by the app's own CorePlugin/PanelServiceProvider +class AuthServiceProvider extends ServiceProvider +{ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->app->booted(fn () => $this->commands([CreateAdminCommand::class])); + } + } +} + +// CustomerServiceProvider — customer model replacement + Customer/User event wiring +class CustomerServiceProvider extends ServiceProvider +{ + public function boot(): void + { + ModelManifest::replace(LunarCustomer::class, Customer::class); + Event::listen(UserCreated::class, CreateCustomerForUser::class); + } +} +``` + +**Rule of thumb:** if a provider has more than one `use` group (e.g. Lunar imports alongside mail imports alongside event imports), it is doing too much. If two providers (module and app) would both register the same `LunarPanel::extensions()` call or the same model-event listener, one of them is redundant — see "Provider Registration Pitfalls." + +--- + +## Filament Panel Integration + +All `LunarPanel::*` calls that must run before `LunarPanel::register()` (which snapshots `getRelations()` and locks in Livewire component registrations) live in the app's `PanelServiceProvider::register()`: + +```php +// PanelServiceProvider::register() +LunarPanel::extensions([...]); // must come before register() +LunarPanel::panel(fn ($panel) => ...); +LunarPanel::disableTwoFactorAuth(); +LunarPanel::register(); // snapshots everything above +``` + +Module-level extensions belong in the module's own sub-provider **only if the app isn't already the one wiring that resource's extension** — check `PanelServiceProvider` first. The rule: if removing the module would break the configuration, it belongs in the module; if the app already owns that wiring (common for `CustomerResource`/`StaffResource` in this project), the module's own attempt to also wire it is dead weight or a duplicate-registration bug. + +--- + +## Overriding Lunar Relation Managers + +Lunar resources ship with their own relation managers (e.g. `CustomerResource` has `AddressRelationManager`, `UserRelationManager`). To replace or remove one: + +**1. Create your relation manager extending the one you want to replace** (only if you need to keep it, with changes): + +```php +// app/Lunar/RelationManagers/AddressRelationManager.php +class AddressRelationManager extends BaseAddressRelationManager +{ + public function getDefaultTable(Table $table): Table { ... } +} +``` + +**2. In the resource extension's `getRelations()`, swap or filter it out:** + +```php +class CustomerResourceExtension extends BaseExtension +{ + public function getRelations(array $relations): array + { + $relations = array_map( + fn ($relation) => $relation == BaseAddressRelationManager::class + ? AddressRelationManager::class + : $relation, + array_filter( + $relations, + fn ($relation) => $relation != BaseUserRelationManager::class, // drop entirely + ) + ); + + return $relations; + } +} +``` + +**Always compare against the Lunar base class** (`Lunar\Admin\...\AddressRelationManager`), not an intermediate class from a module. If a module has already swapped it and your extension compares against the module's class, the swap silently fails when boot order changes. + +**3. Register the extension in `PanelServiceProvider::register()` before `LunarPanel::register()`** — extensions registered later (e.g. in `boot()`) are too late; Livewire component names are already locked in. + +--- + +## Layering Module and App Configuration + +`LunarPanel::extensions()` **merges** — calling it twice for the same resource registers both extensions. They run in registration order, each receiving the result of the previous one. **Only do this deliberately** — if both a module's provider and the app's `PanelServiceProvider` register an extension for the same resource without intending to layer, you get duplicate behaviour (see "Provider Registration Pitfalls"). + +```php +// CustomerServiceProvider::boot() — Core's fields +LunarPanel::extensions([CustomerResource::class => CoreCustomerResourceExtension::class]); + +// PanelServiceProvider::register() — App's fields, applied on top +LunarPanel::extensions([CustomerResource::class => AppCustomerResourceExtension::class]); +``` + +Both `extendForm()` methods run: Core's first, App's second. diff --git a/resources/views/auth/mail/customer-otp.blade.php b/resources/views/auth/mail/user-otp.blade.php similarity index 100% rename from resources/views/auth/mail/customer-otp.blade.php rename to resources/views/auth/mail/user-otp.blade.php diff --git a/resources/views/ui/stoic-image.blade.php b/resources/views/ui/stoic-image.blade.php new file mode 100644 index 0000000..d4f797d --- /dev/null +++ b/resources/views/ui/stoic-image.blade.php @@ -0,0 +1,33 @@ +@props([ + 'src', // Stoic filename string e.g. "hero.png" + 'preset' => 'medium', // which preset sets the src + default sizes hint + 'alt' => '', + 'loading' => 'lazy', + 'fetchpriority' => 'auto', + 'sizes' => null, // override when you know the render context + 'width' => null, + 'height' => null, +]) + +@php + $srcset = \Modules\Core\Stoic\Stoic::srcset($src); + $presetW = collect(\Modules\Core\Stoic\Stoic::presets())->firstWhere('code', $preset)['width'] ?? 800; + $sizesAttr = $sizes ?? "(min-width: 1024px) {$presetW}px, 100vw"; + [$width, $height] = $width !== null && $height !== null + ? [$width, $height] + : \Modules\Core\Stoic\Stoic::dimensions($src, $preset); +@endphp + +{{ $alt }} diff --git a/src/Auth/Events/UserCreated.php b/src/Auth/Events/UserCreated.php new file mode 100644 index 0000000..2cc1333 --- /dev/null +++ b/src/Auth/Events/UserCreated.php @@ -0,0 +1,12 @@ +findByEmail($email); - - if (! $customer) { - return false; - } - - $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); - - $customer->otp_code = $code; - $customer->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); - $customer->save(); - - Mail::to($email)->send(new CustomerOtpMail($customer->full_name, $code)); - - return true; - } - - public function validate(string $email, string $code): ?Customer - { - $customer = $this->findByEmail($email); - - if (! $customer) { - return null; - } - - if (! $customer->otp_expires_at || $customer->otp_code != $code || now()->isAfter($customer->otp_expires_at)) { - return null; - } - - $customer->otp_code = null; - $customer->otp_expires_at = null; - $customer->save(); - - return $customer; - } - - private function findByEmail(string $email): ?Customer - { - $model = ModelManifest::get(Customer::class); - - return $model::query() - ->whereHas('emails', fn ($query) => $query->where('email', $email)) - ->first(); - } -} diff --git a/src/Auth/Services/UserOtpService.php b/src/Auth/Services/UserOtpService.php new file mode 100644 index 0000000..b5a2b1d --- /dev/null +++ b/src/Auth/Services/UserOtpService.php @@ -0,0 +1,48 @@ + $email]); + + $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); + + $user->otp_code = $code; + $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); + $user->save(); + + Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code)); + + return true; + } + + public function validate(string $email, string $code) + { + $model = config('auth.providers.users.model'); + $user = $model::where('email', $email)->first(); + + if (! $user) { + return null; + } + + if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) { + return null; + } + + $user->otp_code = null; + $user->otp_expires_at = null; + $user->save(); + + return $user; + } +} diff --git a/src/Customer/Extensions/CustomerResourceExtension.php b/src/Customer/Extensions/CustomerResourceExtension.php index 8a959fa..f2e6ed1 100644 --- a/src/Customer/Extensions/CustomerResourceExtension.php +++ b/src/Customer/Extensions/CustomerResourceExtension.php @@ -3,17 +3,21 @@ namespace Modules\Core\Customer\Extensions; use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\AddressRelationManager as BaseAddressRelationManager; +use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\UserRelationManager as BaseUserRelationManager; use Lunar\Admin\Support\Extending\BaseExtension; use Modules\Core\Customer\RelationManagers\AddressRelationManager; +use Modules\Core\Customer\RelationManagers\UserRelationManager; class CustomerResourceExtension extends BaseExtension { public function getRelations(array $relations): array { return array_map( - fn ($relation) => $relation == BaseAddressRelationManager::class - ? AddressRelationManager::class - : $relation, + fn ($relation) => match ($relation) { + BaseAddressRelationManager::class => AddressRelationManager::class, + BaseUserRelationManager::class => UserRelationManager::class, + default => $relation, + }, $relations ); } diff --git a/src/Customer/Listeners/CreateCustomerForUser.php b/src/Customer/Listeners/CreateCustomerForUser.php new file mode 100644 index 0000000..64adf0d --- /dev/null +++ b/src/Customer/Listeners/CreateCustomerForUser.php @@ -0,0 +1,23 @@ +users()->attach($event->user); + } +} diff --git a/src/Customer/Models/Customer.php b/src/Customer/Models/Customer.php index 1be753e..86df58e 100644 --- a/src/Customer/Models/Customer.php +++ b/src/Customer/Models/Customer.php @@ -2,19 +2,8 @@ namespace Modules\Core\Customer\Models; -use Illuminate\Auth\Authenticatable; -use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; -use Illuminate\Foundation\Auth\Access\Authorizable; - -class Customer extends \Lunar\Models\Customer implements AuthenticatableContract +class Customer extends \Lunar\Models\Customer { - use Authenticatable; - use Authorizable; - - protected $casts = [ - 'otp_expires_at' => 'datetime', - ]; - public function getTitleAttribute(): ?string { return null; diff --git a/src/Customer/RelationManagers/UserRelationManager.php b/src/Customer/RelationManagers/UserRelationManager.php new file mode 100644 index 0000000..ff3f862 --- /dev/null +++ b/src/Customer/RelationManagers/UserRelationManager.php @@ -0,0 +1,34 @@ +columns([ + Tables\Columns\TextColumn::make('name') + ->label(__('lunarpanel::user.table.name.label')), + Tables\Columns\TextColumn::make('email') + ->label(__('lunarpanel::user.table.email.label')), + ])->actions([ + Tables\Actions\EditAction::make('edit') + ->after( + fn (Model $record) => CustomerUserEdited::dispatch($record) + ) + ->form([ + TextInput::make('email') + ->label(__('lunarpanel::user.form.email.label')) + ->required() + ->email(), + ]), + ]); + } +} diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index c1fea0b..e8c0f21 100644 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -2,44 +2,13 @@ namespace Modules\Core\Providers; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\ServiceProvider; -use Lunar\Admin\Filament\Resources\StaffResource; -use Lunar\Admin\Models\Staff as LunarStaff; -use Lunar\Admin\Support\Facades\LunarPanel; -use Lunar\Models\Customer as LunarCustomer; -use Modules\Core\Auth\Extensions\StaffResourceExtension; -use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Command\CreateAdminCommand; class AuthServiceProvider extends ServiceProvider { public function boot(): void { - LunarPanel::extensions([ - StaffResource::class => StaffResourceExtension::class, - ]); - - LunarStaff::addActivitylogExcept([ - 'otp_code', - 'otp_expires_at', - 'password', - 'remember_token', - 'email_verified_at', - 'two_factor_secret', - 'two_factor_recovery_codes', - 'two_factor_confirmed_at', - ]); - - LunarCustomer::addActivitylogExcept([ - 'otp_code', - 'otp_expires_at', - ]); - - LunarStaff::created(function (LunarStaff $staff) { - Mail::to($staff->email)->send(new InviteMail($staff->first_name)); - }); - if ($this->app->runningInConsole()) { $this->app->booted(fn () => $this->commands([CreateAdminCommand::class])); } diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 6898070..7807236 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -2,6 +2,7 @@ namespace Modules\Core\Providers; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\ExportCleanupCommand; @@ -10,13 +11,21 @@ use Modules\Core\Command\ImportCommand; class CoreServiceProvider extends ServiceProvider { - public function register(): void {} + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../../config/core.php', 'core'); + } public function boot(): void { $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); + Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->publishes([ + __DIR__ . '/../../config/core.php' => config_path('core.php'), + ], 'core-config'); + $this->publishes([ __DIR__ . '/../../resources/logos' => public_path('static/logos/core'), __DIR__ . '/../../resources/js/stoic_embed.js' => public_path('js/stoic_embed.js'), diff --git a/src/Providers/CustomerServiceProvider.php b/src/Providers/CustomerServiceProvider.php index bc72d3a..525006c 100644 --- a/src/Providers/CustomerServiceProvider.php +++ b/src/Providers/CustomerServiceProvider.php @@ -2,22 +2,20 @@ namespace Modules\Core\Providers; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use Lunar\Admin\Filament\Resources\CustomerResource; -use Lunar\Admin\Support\Facades\LunarPanel; use Lunar\Facades\ModelManifest; use Lunar\Models\Contracts\Customer as LunarCustomer; -use Modules\Core\Customer\Extensions\CustomerResourceExtension; +use Modules\Core\Auth\Events\UserCreated; +use Modules\Core\Customer\Listeners\CreateCustomerForUser; use Modules\Core\Customer\Models\Customer; class CustomerServiceProvider extends ServiceProvider { public function boot(): void { - LunarPanel::extensions([ - CustomerResource::class => CustomerResourceExtension::class, - ]); - ModelManifest::replace(LunarCustomer::class, Customer::class); + + Event::listen(UserCreated::class, CreateCustomerForUser::class); } }