Compare commits
2 Commits
dfbf85ef33
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ce08287641 | |||
| 15bff15cad |
@@ -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()`).
|
||||
@@ -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
|
||||
|
||||
+6
-3
@@ -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/"
|
||||
@@ -12,7 +12,8 @@
|
||||
"php": "^8.5",
|
||||
"lunarphp/lunar": "1.3.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
"laravel/tinker": "^3.0",
|
||||
"symfony/yaml": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
@@ -27,7 +28,9 @@
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Modules\\Core\\Providers\\CoreServiceProvider"
|
||||
"Modules\\Core\\Providers\\CoreServiceProvider",
|
||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||
"Modules\\Core\\Providers\\CustomerServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Auto-create Customer for User
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When enabled, creating a User automatically creates and attaches a bare
|
||||
| Customer record (storefront passwordless-signup convention). Shops that
|
||||
| drive the relationship the other way around (creating a Customer first,
|
||||
| then a User for portal login) should set this to false.
|
||||
|
|
||||
*/
|
||||
|
||||
'auto_create_customer_for_user' => true,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('lunar_customers', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('name')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('name')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('lunar_customers', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
+423
@@ -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 <package>` 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.
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Stoic CMS — in-place editor script
|
||||
*
|
||||
* Vendor this file into your website's asset pipeline.
|
||||
*
|
||||
* Configuration (set before this script runs):
|
||||
* window.STOIC_HOST = "https://your-cms-domain.com"; // default: same origin
|
||||
*
|
||||
* Usage on your HTML elements:
|
||||
* data-stoic="schema/slug" open editor for this entry
|
||||
* data-stoic="schema/slug#field" open editor and focus a specific field
|
||||
* data-stoic="singleton#field" open editor for a singleton schema
|
||||
*
|
||||
* SSO: if STOIC_SSO_SECRET is configured on the server, the admin link
|
||||
* will generate a signed token so the user lands directly in the CMS.
|
||||
* Set window.STOIC_SSO_TOKEN before loading this script:
|
||||
* window.STOIC_SSO_TOKEN = "<?= $ssoToken ?>";
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const HOST = (window.STOIC_HOST || "").replace(/\/$/, "");
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
let editMode = false;
|
||||
let activePane = null;
|
||||
let messageListener = null;
|
||||
|
||||
// ── Toolbar ──────────────────────────────────────────────────────────────
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "stoic-bar";
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "stoic-edit-toggle";
|
||||
btn.setAttribute("aria-label", "Toggle edit mode");
|
||||
btn.innerHTML =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Edit';
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
editMode ? exitEditMode() : enterEditMode();
|
||||
});
|
||||
|
||||
const adminLink = document.createElement("a");
|
||||
adminLink.className = "stoic-admin-link";
|
||||
adminLink.target = "_blank";
|
||||
adminLink.rel = "noopener noreferrer";
|
||||
adminLink.setAttribute("aria-label", "Open CMS admin");
|
||||
adminLink.innerHTML =
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
|
||||
|
||||
// If a pre-generated SSO token is provided, use the SSO endpoint directly.
|
||||
// Otherwise link to the standard login page.
|
||||
if (window.STOIC_SSO_TOKEN) {
|
||||
adminLink.href = HOST + "/cms/sso?" + window.STOIC_SSO_TOKEN;
|
||||
} else {
|
||||
adminLink.href = HOST;
|
||||
}
|
||||
|
||||
bar.appendChild(btn);
|
||||
bar.appendChild(adminLink);
|
||||
|
||||
// ── Edit mode ────────────────────────────────────────────────────────────
|
||||
|
||||
function enterEditMode() {
|
||||
editMode = true;
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll("[data-stoic]").forEach(function (el) {
|
||||
el.classList.add("stoic-target");
|
||||
el.addEventListener("click", onTargetClick, true);
|
||||
});
|
||||
}
|
||||
|
||||
function exitEditMode() {
|
||||
editMode = false;
|
||||
btn.classList.remove("active");
|
||||
document.querySelectorAll("[data-stoic]").forEach(function (el) {
|
||||
el.classList.remove("stoic-target", "stoic-target--active");
|
||||
el.removeEventListener("click", onTargetClick, true);
|
||||
});
|
||||
closePane();
|
||||
}
|
||||
|
||||
function onTargetClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
document
|
||||
.querySelectorAll(".stoic-target--active")
|
||||
.forEach(function (el) {
|
||||
el.classList.remove("stoic-target--active");
|
||||
});
|
||||
this.classList.add("stoic-target--active");
|
||||
|
||||
const attr = this.getAttribute("data-stoic");
|
||||
const hashIdx = attr.indexOf("#");
|
||||
const path = hashIdx === -1 ? attr : attr.slice(0, hashIdx);
|
||||
const field = hashIdx === -1 ? "" : attr.slice(hashIdx + 1);
|
||||
openPane(path, field);
|
||||
}
|
||||
|
||||
// ── Side pane ────────────────────────────────────────────────────────────
|
||||
|
||||
function openPane(path, field) {
|
||||
closePane();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "stoic-overlay";
|
||||
overlay.addEventListener("click", closePane);
|
||||
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "stoic-pane";
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "stoic-pane-close";
|
||||
closeBtn.setAttribute("aria-label", "Close");
|
||||
closeBtn.innerHTML =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
closeBtn.addEventListener("click", closePane);
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "stoic-pane-iframe";
|
||||
let src = HOST + "/cms/embed/" + path;
|
||||
if (field) src += "?focus=" + encodeURIComponent(field);
|
||||
iframe.src = src;
|
||||
|
||||
pane.appendChild(closeBtn);
|
||||
pane.appendChild(iframe);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(pane);
|
||||
activePane = { pane: pane, overlay: overlay };
|
||||
|
||||
messageListener = function (e) {
|
||||
if (e.data && e.data.type === "stoic:saved") {
|
||||
onSaved();
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", messageListener);
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
pane.classList.add("open");
|
||||
overlay.classList.add("open");
|
||||
});
|
||||
}
|
||||
|
||||
function closePane() {
|
||||
if (!activePane) return;
|
||||
var pane = activePane.pane;
|
||||
var overlay = activePane.overlay;
|
||||
|
||||
pane.classList.remove("open");
|
||||
overlay.classList.remove("open");
|
||||
|
||||
setTimeout(function () {
|
||||
pane.remove();
|
||||
overlay.remove();
|
||||
}, 220);
|
||||
|
||||
if (messageListener) {
|
||||
window.removeEventListener("message", messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll(".stoic-target--active")
|
||||
.forEach(function (el) {
|
||||
el.classList.remove("stoic-target--active");
|
||||
});
|
||||
|
||||
activePane = null;
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
var activeEl = document.querySelector(".stoic-target--active");
|
||||
if (activeEl) {
|
||||
activeEl.dispatchEvent(
|
||||
new CustomEvent("stoic:saved", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
closePane();
|
||||
}
|
||||
|
||||
// ── Styles ───────────────────────────────────────────────────────────────
|
||||
|
||||
var style = document.createElement("style");
|
||||
style.textContent = [
|
||||
".stoic-bar{position:fixed;bottom:1.25rem;right:1.25rem;z-index:2147483646;display:inline-flex;align-items:center;gap:0.375rem;background:#1a1a2e;border-radius:9999px;padding:0.25rem 0.25rem 0.25rem 0.5rem;box-shadow:0 2px 12px rgba(0,0,0,.35);}",
|
||||
".stoic-edit-toggle{display:inline-flex;align-items:center;gap:0.4rem;padding:0.35rem 0.75rem;border:none;border-radius:9999px;background:transparent;color:#fff;font-size:0.8rem;font-weight:600;font-family:system-ui,sans-serif;cursor:pointer;transition:background .15s;}",
|
||||
".stoic-edit-toggle:hover{background:rgba(255,255,255,.1);}",
|
||||
".stoic-edit-toggle.active{background:#4f46e5;}",
|
||||
".stoic-admin-link{display:inline-flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;border-radius:50%;color:rgba(255,255,255,.6);text-decoration:none;transition:color .15s,background .15s;}",
|
||||
".stoic-admin-link:hover{color:#fff;background:rgba(255,255,255,.1);}",
|
||||
".stoic-target{outline:2px dashed rgba(79,70,229,.5)!important;outline-offset:2px!important;cursor:pointer!important;transition:outline-color .15s;}",
|
||||
".stoic-target:hover{outline-color:rgba(79,70,229,.9)!important;}",
|
||||
".stoic-target--active{outline:2px solid #4f46e5!important;outline-offset:2px!important;}",
|
||||
".stoic-overlay{position:fixed;inset:0;z-index:2147483644;background:rgba(0,0,0,.25);opacity:0;transition:opacity .2s;}",
|
||||
".stoic-overlay.open{opacity:1;}",
|
||||
".stoic-pane{position:fixed;top:0;right:0;bottom:0;z-index:2147483645;width:min(480px,100vw);background:#fff;box-shadow:-4px 0 24px rgba(0,0,0,.18);transform:translateX(100%);transition:transform .22s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;}",
|
||||
".stoic-pane.open{transform:translateX(0);}",
|
||||
".stoic-pane-close{position:absolute;top:.75rem;right:.75rem;z-index:1;width:1.75rem;height:1.75rem;border:none;border-radius:50%;background:rgba(0,0,0,.07);color:#555;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;}",
|
||||
".stoic-pane-close:hover{background:rgba(0,0,0,.14);}",
|
||||
".stoic-pane-iframe{flex:1;width:100%;border:none;}",
|
||||
].join("");
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
document.head.appendChild(style);
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.body.appendChild(bar);
|
||||
});
|
||||
} else {
|
||||
document.body.appendChild(bar);
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
<img
|
||||
src="{{ \Modules\Core\Stoic\Stoic::image($src, $preset) }}"
|
||||
@if($srcset)
|
||||
srcset="{{ $srcset }}"
|
||||
sizes="{{ $sizesAttr }}"
|
||||
@endif
|
||||
@if($width !== null) width="{{ $width }}" @endif
|
||||
@if($height !== null) height="{{ $height }}" @endif
|
||||
alt="{{ $alt }}"
|
||||
loading="{{ $loading }}"
|
||||
fetchpriority="{{ $fetchpriority }}"
|
||||
{{ $attributes }}
|
||||
/>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Events;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class UserCreated
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Authenticatable $user,
|
||||
) {}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
class CustomerOtpMail extends Mailable
|
||||
class UserOtpMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
@@ -20,6 +20,6 @@ class CustomerOtpMail extends Mailable
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'core::auth.mail.customer-otp');
|
||||
return new Content(view: 'core::auth.mail.user-otp');
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Lunar\Models\Contracts\Customer;
|
||||
use Lunar\Facades\ModelManifest;
|
||||
use Modules\Core\Auth\Mail\CustomerOtpMail;
|
||||
|
||||
class CustomerOtpService
|
||||
{
|
||||
private const EXPIRY_MINUTES = 10;
|
||||
private const CODE_LENGTH = 6;
|
||||
|
||||
public function generateAndSend(string $email): bool
|
||||
{
|
||||
$customer = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Modules\Core\Auth\Mail\UserOtpMail;
|
||||
|
||||
class UserOtpService
|
||||
{
|
||||
private const EXPIRY_MINUTES = 10;
|
||||
private const CODE_LENGTH = 6;
|
||||
|
||||
public function generateAndSend(string $email): bool
|
||||
{
|
||||
$model = config('auth.providers.users.model');
|
||||
$user = $model::firstOrCreate(['email' => $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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -24,8 +24,8 @@ class CorePlugin implements Plugin
|
||||
{
|
||||
$panel->path('boboko')
|
||||
->brandName('Boboko')
|
||||
->brandLogo(asset('logos/core/boboko-logo.svg'))
|
||||
->darkModeBrandLogo(asset('logos/core/boboko-logo-white.svg'))
|
||||
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
||||
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
||||
->login(Login::class)
|
||||
->navigationItems([
|
||||
NavigationItem::make("Lucent")
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Customer\Listeners;
|
||||
|
||||
use Lunar\Facades\ModelManifest;
|
||||
use Lunar\Models\Contracts\Customer as CustomerContract;
|
||||
use Modules\Core\Auth\Events\UserCreated;
|
||||
|
||||
class CreateCustomerForUser
|
||||
{
|
||||
public function handle(UserCreated $event): void
|
||||
{
|
||||
if (! config('core.auto_create_customer_for_user')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$model = ModelManifest::get(CustomerContract::class);
|
||||
|
||||
$customer = $model::create([]);
|
||||
|
||||
$customer->users()->attach($event->user);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Customer\RelationManagers;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lunar\Admin\Events\CustomerUserEdited;
|
||||
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\UserRelationManager as BaseUserRelationManager;
|
||||
|
||||
class UserRelationManager extends BaseUserRelationManager
|
||||
{
|
||||
public function getDefaultTable(Table $table): Table
|
||||
{
|
||||
return $table->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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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,15 +11,24 @@ 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__ . '/../../resources/logos' => public_path('logos/core'),
|
||||
__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'),
|
||||
], 'core-assets');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Stoic;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Stoic
|
||||
{
|
||||
public static array $config = [];
|
||||
public static array $presets = [];
|
||||
public static string $thumbs_path;
|
||||
|
||||
private static function loadConfig(): void
|
||||
{
|
||||
if (!empty(static::$config)) {
|
||||
return;
|
||||
}
|
||||
$config = Yaml::parseFile(resource_path("stoic/stoic_config.yml"));
|
||||
static::$config = $config;
|
||||
static::$thumbs_path = $config["thumbs_path"];
|
||||
static::$presets = $config["presets"];
|
||||
}
|
||||
|
||||
public static function presets(): array
|
||||
{
|
||||
static::loadConfig();
|
||||
return static::$presets;
|
||||
}
|
||||
|
||||
// Returns the public URL for a stoic image at a given preset.
|
||||
public static function image(string $filename, string $preset): string
|
||||
{
|
||||
static::loadConfig();
|
||||
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext === "svg") {
|
||||
$rel = static::thumbsRelPath() . "images/" . $filename;
|
||||
return Storage::disk("public")->url($rel);
|
||||
}
|
||||
|
||||
$webp = substr($filename, 0, -strlen($ext)) . "webp";
|
||||
$rel = static::thumbsRelPath() . "presets/" . $preset . "/" . $webp;
|
||||
|
||||
return Storage::disk("public")->url($rel);
|
||||
}
|
||||
|
||||
// Generates the full srcset string for all configured presets.
|
||||
public static function srcset(string $filename): string
|
||||
{
|
||||
static::loadConfig();
|
||||
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if ($ext === "svg") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return collect(static::$presets)
|
||||
->map(
|
||||
fn($p) => static::image($filename, $p["code"]) .
|
||||
" " .
|
||||
$p["width"] .
|
||||
"w",
|
||||
)
|
||||
->implode(", ");
|
||||
}
|
||||
|
||||
// Returns [width, height] for a given preset from the sidecar JSON.
|
||||
public static function dimensions(string $filename, string $preset): array
|
||||
{
|
||||
static::loadConfig();
|
||||
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if ($ext === "svg") {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$rel = static::thumbsRelPath() . "images/" . $filename . ".json";
|
||||
$path = Storage::disk("public")->path($rel);
|
||||
$meta = json_decode(@file_get_contents($path), true);
|
||||
|
||||
$p = $meta["presets"][$preset] ?? null;
|
||||
if (!$p) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [(int) $p["w"], (int) $p["h"]];
|
||||
}
|
||||
|
||||
// Path of the thumbs directory relative to the public storage disk root.
|
||||
// Uses a fixed marker so it works regardless of the app's base path (local vs Docker).
|
||||
private static function thumbsRelPath(): string
|
||||
{
|
||||
$marker = "/storage/app/public/";
|
||||
$pos = strpos(static::$thumbs_path, $marker);
|
||||
|
||||
if ($pos !== false) {
|
||||
return substr(static::$thumbs_path, $pos + strlen($marker));
|
||||
}
|
||||
|
||||
return ltrim(static::$thumbs_path, "/");
|
||||
}
|
||||
|
||||
public static function token(string $email): string
|
||||
{
|
||||
$ttl = 60;
|
||||
$payload = base64_encode(
|
||||
json_encode([
|
||||
"email" => $email,
|
||||
"exp" => time() + $ttl,
|
||||
]),
|
||||
);
|
||||
|
||||
$sig = hash_hmac(
|
||||
"sha256",
|
||||
$payload,
|
||||
config("services.stoic.sso_secret"),
|
||||
);
|
||||
|
||||
$token = "t=" . urlencode($payload) . "&s=" . $sig;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function hasMany(
|
||||
string $relation,
|
||||
array $slugs,
|
||||
string $prefix,
|
||||
) {
|
||||
$slugs = array_map(
|
||||
fn($i) => str_replace($prefix . "/", "", $i),
|
||||
$slugs,
|
||||
);
|
||||
|
||||
return $relation::whereIn("slug", $slugs)->get();
|
||||
}
|
||||
|
||||
public static function hasOne(
|
||||
string $relation,
|
||||
string $slug,
|
||||
string $prefix,
|
||||
) {
|
||||
$slug = str_replace($prefix . "/", "", $slug);
|
||||
return $relation::firstWhere("slug", $slug);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user