diff --git a/FIX-500-NOTES.md b/FIX-500-NOTES.md new file mode 100644 index 00000000..37273637 --- /dev/null +++ b/FIX-500-NOTES.md @@ -0,0 +1,20 @@ +# 500 fix notes + +This build removes Flux form inputs from the login/register auth forms because the current supplied Flux/Livewire stack can compile internal Flux input views before `$attributes` exists, causing: + +`Undefined variable $attributes` + +The auth forms keep the mobile-first Tailwind styling and use normal HTML inputs. Run: + +```bash +composer dump-autoload +php artisan optimize:clear +php artisan view:clear +php artisan migrate --seed +npm run build +``` + +Default platform admin: + +- Email: mark.gregory@mpba.co.uk +- Password: 987400Kmh diff --git a/INSTALL-WMS.md b/INSTALL-WMS.md new file mode 100644 index 00000000..1101212e --- /dev/null +++ b/INSTALL-WMS.md @@ -0,0 +1,59 @@ +# WMS modular install + +## Local ServBay / PostgreSQL install + +```bash +cp .env.example .env +php artisan key:generate +php artisan migrate:fresh --seed +npm install +npm run build +php artisan optimize:clear +``` + +Admin login: + +```text +Email: mark.gregory@mpba.co.uk +Password: 987400Kmh +``` + +## Main pages + +```text +/dashboard Tenant WMS dashboard +/warehouses Tenant warehouse management +/warehouse-rules Warehouse validation rules +/admin MPBA system administration dashboard +/admin/companies Tenant company management +/admin/licenses Licence keys +/admin/payments Payments and Stripe override records +/api/internal/companies HMAC signed internal API endpoint +``` + +## Production install + +Use PostgreSQL 18, Redis Sentinel, Laravel Octane, Horizon and HAProxy. Keep database and Redis HA native on Ubuntu. Use Docker only for stateless Laravel app/worker containers. + +```bash +composer install --no-dev --prefer-dist --optimize-autoloader +npm ci +npm run build +php artisan migrate --force +php artisan db:seed --force +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan event:cache +php artisan queue:restart +php artisan octane:reload +``` + +## Test command + +```bash +composer install +php artisan test +``` + +The archive does not include `vendor/` or `node_modules/`. diff --git a/Modules/InternalApi/Providers/InternalApiServiceProvider.php b/Modules/InternalApi/Providers/InternalApiServiceProvider.php new file mode 100644 index 00000000..88c5c793 --- /dev/null +++ b/Modules/InternalApi/Providers/InternalApiServiceProvider.php @@ -0,0 +1,19 @@ +prefix('api/internal')->group(__DIR__ . '/../routes/api.php'); + app('router')->aliasMiddleware('internal.signed', VerifyInternalApiSignature::class); + } +} diff --git a/Modules/InternalApi/app/Http/Controllers/CompanySummaryController.php b/Modules/InternalApi/app/Http/Controllers/CompanySummaryController.php new file mode 100644 index 00000000..cdb39d47 --- /dev/null +++ b/Modules/InternalApi/app/Http/Controllers/CompanySummaryController.php @@ -0,0 +1,19 @@ +json([ + 'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->orderBy('name')->get(), + ]); + } +} diff --git a/Modules/InternalApi/app/Http/Middleware/VerifyInternalApiSignature.php b/Modules/InternalApi/app/Http/Middleware/VerifyInternalApiSignature.php new file mode 100644 index 00000000..5a46a8a4 --- /dev/null +++ b/Modules/InternalApi/app/Http/Middleware/VerifyInternalApiSignature.php @@ -0,0 +1,28 @@ +header('X-WMS-Timestamp'); + $signature = (string) $request->header('X-WMS-Signature'); + + abort_if($secret === '' || $timestamp === '' || $signature === '', 401); + abort_if(abs(now()->timestamp - strtotime($timestamp)) > 300, 401, 'Expired internal API signature.'); + + $expected = hash_hmac('sha256', $timestamp . '.' . $request->getContent(), $secret); + abort_unless(hash_equals($expected, $signature), 401, 'Invalid internal API signature.'); + + return $next($request); + } +} diff --git a/Modules/InternalApi/module.json b/Modules/InternalApi/module.json new file mode 100644 index 00000000..c3e01f01 --- /dev/null +++ b/Modules/InternalApi/module.json @@ -0,0 +1 @@ +{"name":"InternalApi","alias":"internal-api","description":"Authenticated internal API between separated WMS applications.","keywords":["api","hmac","integration"],"priority":2,"providers":["Modules\\InternalApi\\Providers\\InternalApiServiceProvider"],"files":[]} diff --git a/Modules/InternalApi/routes/api.php b/Modules/InternalApi/routes/api.php new file mode 100644 index 00000000..b45db6b8 --- /dev/null +++ b/Modules/InternalApi/routes/api.php @@ -0,0 +1,8 @@ +group(function (): void { + Route::get('/companies', CompanySummaryController::class)->name('internal.companies.index'); +}); diff --git a/Modules/InternalApi/tests/Feature/InternalApiSignatureTest.php b/Modules/InternalApi/tests/Feature/InternalApiSignatureTest.php new file mode 100644 index 00000000..6afa4ae9 --- /dev/null +++ b/Modules/InternalApi/tests/Feature/InternalApiSignatureTest.php @@ -0,0 +1,29 @@ +getJson('/api/internal/companies')->assertUnauthorized(); + } + + public function test_internal_api_accepts_valid_signature(): void + { + config(['platform.internal_api_key' => 'testing-secret']); + $timestamp = now()->toIso8601String(); + $body = ''; + $signature = hash_hmac('sha256', $timestamp . '.' . $body, 'testing-secret'); + + $this->withHeaders([ + 'X-WMS-Timestamp' => $timestamp, + 'X-WMS-Signature' => $signature, + ])->getJson('/api/internal/companies')->assertOk(); + } +} diff --git a/Modules/Platform/Providers/PlatformServiceProvider.php b/Modules/Platform/Providers/PlatformServiceProvider.php new file mode 100644 index 00000000..4b6e7271 --- /dev/null +++ b/Modules/Platform/Providers/PlatformServiceProvider.php @@ -0,0 +1,36 @@ +mergeConfigFrom(__DIR__ . '/../config/platform.php', 'platform'); + } + + /** + * Bootstrap module services. + */ + public function boot(): void + { + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'platform'); + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + + Route::middleware('web')->group(__DIR__ . '/../routes/web.php'); + + app('router')->aliasMiddleware('tenant.assigned', EnsureTenantAssigned::class); + app('router')->aliasMiddleware('tenant.licensed', EnsureLicensedTenant::class); + app('router')->aliasMiddleware('platform.admin', EnsurePlatformAdmin::class); + } +} diff --git a/Modules/Platform/app/Http/Controllers/CompanyController.php b/Modules/Platform/app/Http/Controllers/CompanyController.php new file mode 100644 index 00000000..63d4f326 --- /dev/null +++ b/Modules/Platform/app/Http/Controllers/CompanyController.php @@ -0,0 +1,41 @@ + Company::withCount(['users', 'warehouses', 'licenseKeys'])->orderBy('name')->paginate(20), + ]); + } + + /** + * Create a company tenant. + */ + public function store(Request $request) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['nullable', 'email', 'max:255'], + 'status' => ['required', 'in:active,suspended'], + ]); + + Company::create([ + ...$data, + 'slug' => Str::slug($data['name']), + 'timezone' => 'Europe/London', + ]); + + return back()->with('status', 'Company created.'); + } +} diff --git a/Modules/Platform/app/Http/Controllers/LicenseKeyController.php b/Modules/Platform/app/Http/Controllers/LicenseKeyController.php new file mode 100644 index 00000000..3e8abf0e --- /dev/null +++ b/Modules/Platform/app/Http/Controllers/LicenseKeyController.php @@ -0,0 +1,41 @@ + Company::orderBy('name')->get(), + 'licenses' => LicenseKey::with('company')->latest()->paginate(20), + 'plainKey' => session('plain_license_key'), + ]); + } + + /** + * Issue a new licence key. + */ + public function store(Request $request) + { + $data = $request->validate([ + 'company_id' => ['required', 'exists:companies,id'], + 'label' => ['required', 'string', 'max:255'], + 'plan' => ['required', 'in:standard,professional,enterprise'], + 'max_users' => ['required', 'integer', 'min:1', 'max:10000'], + ]); + + $company = Company::findOrFail($data['company_id']); + $plainKey = LicenseKey::issueFor($company, $data['label'], $data['plan'], (int) $data['max_users']); + + return back()->with('plain_license_key', $plainKey)->with('status', 'Licence key issued. Copy it now.'); + } +} diff --git a/Modules/Platform/app/Http/Controllers/PaymentController.php b/Modules/Platform/app/Http/Controllers/PaymentController.php new file mode 100644 index 00000000..4103960c --- /dev/null +++ b/Modules/Platform/app/Http/Controllers/PaymentController.php @@ -0,0 +1,44 @@ + Company::orderBy('name')->get(), + 'payments' => Payment::with('company')->latest()->paginate(20), + ]); + } + + /** + * Add a manual payment or admin override record. + */ + public function store(Request $request) + { + $data = $request->validate([ + 'company_id' => ['required', 'exists:companies,id'], + 'amount' => ['required', 'integer', 'min:0'], + 'currency' => ['required', 'string', 'size:3'], + 'status' => ['required', 'in:pending,paid,failed,refunded,override'], + 'description' => ['nullable', 'string', 'max:255'], + ]); + + Payment::create([ + ...$data, + 'paid_at' => in_array($data['status'], ['paid', 'override'], true) ? now() : null, + 'metadata' => ['source' => 'manual_admin'], + ]); + + return back()->with('status', 'Payment record created.'); + } +} diff --git a/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php b/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php new file mode 100644 index 00000000..f04786ec --- /dev/null +++ b/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php @@ -0,0 +1,27 @@ + Company::count(), + 'activeCompaniesCount' => Company::where('status', 'active')->count(), + 'licensesCount' => LicenseKey::count(), + 'paymentsTotal' => Payment::where('status', 'paid')->sum('amount'), + 'warehousesCount' => Warehouse::count(), + 'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest()->limit(8)->get(), + ]); + } +} diff --git a/Modules/Platform/app/Http/Controllers/StripeWebhookController.php b/Modules/Platform/app/Http/Controllers/StripeWebhookController.php new file mode 100644 index 00000000..388bba5f --- /dev/null +++ b/Modules/Platform/app/Http/Controllers/StripeWebhookController.php @@ -0,0 +1,42 @@ +all(); + $eventType = data_get($payload, 'type'); + + if ($eventType === 'payment_intent.succeeded') { + $intent = data_get($payload, 'data.object', []); + $company = Company::where('stripe_customer_id', data_get($intent, 'customer'))->first(); + + if ($company) { + Payment::updateOrCreate( + ['stripe_payment_intent_id' => data_get($intent, 'id')], + [ + 'company_id' => $company->id, + 'amount' => (int) data_get($intent, 'amount_received', 0), + 'currency' => (string) data_get($intent, 'currency', 'gbp'), + 'status' => 'paid', + 'description' => 'Stripe payment received', + 'paid_at' => now(), + 'metadata' => $intent, + ] + ); + } + } + + return response()->json(['received' => true]); + } +} diff --git a/Modules/Platform/app/Http/Middleware/EnsureLicensedTenant.php b/Modules/Platform/app/Http/Middleware/EnsureLicensedTenant.php new file mode 100644 index 00000000..f7e6966b --- /dev/null +++ b/Modules/Platform/app/Http/Middleware/EnsureLicensedTenant.php @@ -0,0 +1,29 @@ +user(); + $company = $user?->company; + + abort_if(! $company, 403, 'No company is assigned to this account.'); + + if ($user->isMpbaPlatformAdmin()) { + return $next($request); + } + + abort_unless($company->hasActiveLicense(), 402, 'Payment Required'); + + return $next($request); + } +} diff --git a/Modules/Platform/app/Http/Middleware/EnsurePlatformAdmin.php b/Modules/Platform/app/Http/Middleware/EnsurePlatformAdmin.php new file mode 100644 index 00000000..86982032 --- /dev/null +++ b/Modules/Platform/app/Http/Middleware/EnsurePlatformAdmin.php @@ -0,0 +1,20 @@ +user()?->isMpbaPlatformAdmin(), 403); + + return $next($request); + } +} diff --git a/Modules/Platform/app/Http/Middleware/EnsureTenantAssigned.php b/Modules/Platform/app/Http/Middleware/EnsureTenantAssigned.php new file mode 100644 index 00000000..74165ea1 --- /dev/null +++ b/Modules/Platform/app/Http/Middleware/EnsureTenantAssigned.php @@ -0,0 +1,22 @@ +user(); + + abort_if(! $user || ! $user->company_id, 403, 'No company is assigned to this account.'); + + return $next($request); + } +} diff --git a/Modules/Platform/app/Models/Company.php b/Modules/Platform/app/Models/Company.php new file mode 100644 index 00000000..e263e19d --- /dev/null +++ b/Modules/Platform/app/Models/Company.php @@ -0,0 +1,73 @@ + 'array', + 'is_system_company' => 'boolean', + ]; + + protected static function booted(): void + { + static::creating(function (Company $company): void { + $company->uuid ??= (string) Str::uuid(); + $company->slug ??= Str::slug($company->name); + $company->status ??= 'active'; + $company->timezone ??= 'Europe/London'; + }); + } + + public function users(): HasMany + { + return $this->hasMany(\App\Models\User::class); + } + + public function licenseKeys(): HasMany + { + return $this->hasMany(LicenseKey::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function warehouses(): HasMany + { + return $this->hasMany(Warehouse::class); + } + + public function hasActiveLicense(): bool + { + if ($this->is_system_company) { + return true; + } + + return $this->licenseKeys() + ->where('is_active', true) + ->where(function ($query): void { + $query->whereNull('starts_at')->orWhere('starts_at', '<=', now()); + }) + ->where(function ($query): void { + $query->whereNull('expires_at')->orWhere('expires_at', '>=', now()); + }) + ->whereNull('revoked_at') + ->exists(); + } +} diff --git a/Modules/Platform/app/Models/LicenseKey.php b/Modules/Platform/app/Models/LicenseKey.php new file mode 100644 index 00000000..fc1ce73a --- /dev/null +++ b/Modules/Platform/app/Models/LicenseKey.php @@ -0,0 +1,47 @@ + 'boolean', + 'starts_at' => 'datetime', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public static function issueFor(Company $company, string $label, string $plan = 'standard', int $maxUsers = 10): string + { + $plainKey = 'WMS-' . Str::upper(Str::random(32)); + + self::create([ + 'company_id' => $company->id, + 'key_hash' => hash('sha256', $plainKey), + 'key_prefix' => Str::substr($plainKey, 0, 12), + 'label' => $label, + 'plan' => $plan, + 'max_users' => $maxUsers, + 'is_active' => true, + 'starts_at' => now(), + 'expires_at' => now()->addYear(), + ]); + + return $plainKey; + } +} diff --git a/Modules/Platform/app/Models/Payment.php b/Modules/Platform/app/Models/Payment.php new file mode 100644 index 00000000..056cc7ca --- /dev/null +++ b/Modules/Platform/app/Models/Payment.php @@ -0,0 +1,25 @@ + 'integer', + 'paid_at' => 'datetime', + 'metadata' => 'array', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } +} diff --git a/Modules/Platform/config/platform.php b/Modules/Platform/config/platform.php new file mode 100644 index 00000000..ed3125b1 --- /dev/null +++ b/Modules/Platform/config/platform.php @@ -0,0 +1,12 @@ + env('WMS_SYSTEM_COMPANY_SLUG', 'mpba-limited'), + 'system_admin_email' => env('WMS_SYSTEM_ADMIN_EMAIL', 'mark.gregory@mpba.co.uk'), + 'internal_api_key' => env('WMS_INTERNAL_API_KEY', 'local-dev-change-me'), + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], +]; diff --git a/Modules/Platform/database/migrations/2026_06_10_000000_create_platform_tables.php b/Modules/Platform/database/migrations/2026_06_10_000000_create_platform_tables.php new file mode 100644 index 00000000..9e951459 --- /dev/null +++ b/Modules/Platform/database/migrations/2026_06_10_000000_create_platform_tables.php @@ -0,0 +1,80 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('status')->default('active')->index(); + $table->string('timezone')->default('Europe/London'); + $table->boolean('is_system_company')->default(false)->index(); + $table->string('stripe_customer_id')->nullable()->index(); + $table->string('billing_email')->nullable(); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::table('users', function (Blueprint $table): void { + $table->foreignId('company_id')->nullable()->after('id')->constrained('companies')->nullOnDelete(); + $table->boolean('is_platform_admin')->default(false)->after('password'); + $table->boolean('is_company_admin')->default(false)->after('is_platform_admin'); + $table->string('status')->default('active')->after('is_company_admin')->index(); + }); + + Schema::create('license_keys', function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->string('key_hash', 64)->unique(); + $table->string('key_prefix', 16)->index(); + $table->string('label'); + $table->string('plan')->default('standard')->index(); + $table->unsignedInteger('max_users')->default(10); + $table->boolean('is_active')->default(true)->index(); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable()->index(); + $table->timestamps(); + }); + + Schema::create('payments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->string('stripe_payment_intent_id')->nullable()->unique(); + $table->string('stripe_invoice_id')->nullable()->index(); + $table->unsignedInteger('amount')->default(0); + $table->string('currency', 3)->default('gbp'); + $table->string('status')->default('pending')->index(); + $table->string('description')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + Schema::dropIfExists('license_keys'); + Schema::table('users', function (Blueprint $table): void { + $table->dropConstrainedForeignId('company_id'); + $table->dropColumn(['is_platform_admin', 'is_company_admin', 'status']); + }); + Schema::dropIfExists('companies'); + } +}; diff --git a/Modules/Platform/database/seeders/PlatformSeeder.php b/Modules/Platform/database/seeders/PlatformSeeder.php new file mode 100644 index 00000000..23cb64e5 --- /dev/null +++ b/Modules/Platform/database/seeders/PlatformSeeder.php @@ -0,0 +1,55 @@ + 'mpba-limited'], + [ + 'uuid' => (string) Str::uuid(), + 'name' => 'MPBA Limited', + 'status' => 'active', + 'timezone' => 'Europe/London', + 'is_system_company' => true, + 'billing_email' => 'mark.gregory@mpba.co.uk', + ] + ); + + User::updateOrCreate( + ['email' => 'mark.gregory@mpba.co.uk'], + [ + 'company_id' => $company->id, + 'name' => 'Mark Gregory', + 'password' => Hash::make('987400Kmh'), + 'is_platform_admin' => true, + 'is_company_admin' => true, + 'status' => 'active', + 'email_verified_at' => now(), + ] + ); + + $warehouse = Warehouse::updateOrCreate( + ['company_id' => $company->id, 'code' => 'MPBA-MAIN'], + ['name' => 'MPBA Main Warehouse', 'status' => 'active', 'timezone' => 'Europe/London'] + ); + + WarehouseValidationRule::updateOrCreate( + ['warehouse_id' => $warehouse->id, 'rule_key' => 'stock.location.required'], + ['name' => 'Stock location required', 'severity' => 'error', 'is_active' => true, 'configuration' => ['required' => true]] + ); + } +} diff --git a/Modules/Platform/module.json b/Modules/Platform/module.json new file mode 100644 index 00000000..54df319b --- /dev/null +++ b/Modules/Platform/module.json @@ -0,0 +1 @@ +{"name":"Platform","alias":"platform","description":"System administration, tenants, licences, Stripe payments and tenant guards.","keywords":["wms","multi-tenant","licensing"],"priority":0,"providers":["Modules\\Platform\\Providers\\PlatformServiceProvider"],"files":[]} diff --git a/Modules/Platform/resources/views/companies/index.blade.php b/Modules/Platform/resources/views/companies/index.blade.php new file mode 100644 index 00000000..6c4c5e50 --- /dev/null +++ b/Modules/Platform/resources/views/companies/index.blade.php @@ -0,0 +1,21 @@ + +
+
+

Companies

+
+ @csrf + + + + +
+
+
+ + + @foreach($companies as $company)@endforeach +
NameUsersWarehousesLicencesStatus
{{ $company->name }}{{ $company->users_count }}{{ $company->warehouses_count }}{{ $company->license_keys_count }}{{ $company->is_system_company ? 'System' : $company->status }}
+
+ {{ $companies->links() }} +
+
diff --git a/Modules/Platform/resources/views/dashboard/index.blade.php b/Modules/Platform/resources/views/dashboard/index.blade.php new file mode 100644 index 00000000..fed522b5 --- /dev/null +++ b/Modules/Platform/resources/views/dashboard/index.blade.php @@ -0,0 +1,60 @@ + +
+
+
+
+

MPBA WMS

+

Platform administration

+

Manage tenant companies, licences, payments, warehouses and internal API operations from one resilient control plane.

+
+
+

System status

+

Operational

+
+
+
+ +
+ @foreach ([['Companies', $companiesCount], ['Active', $activeCompaniesCount], ['Warehouses', $warehousesCount], ['Licences', $licensesCount], ['Paid £', number_format($paymentsTotal / 100, 2)]] as [$label, $value]) +
+

{{ $label }}

+

{{ $value }}

+
+ @endforeach +
+ +
+
+
+

Recent companies

+

Tenant isolation is enforced by company ownership on every warehouse record.

+
+ Manage companies +
+
+ + + + + + + + + + + + @foreach ($companies as $company) + + + + + + + + @endforeach + +
CompanyUsersWarehousesLicencesStatus
{{ $company->name }}{{ $company->users_count }}{{ $company->warehouses_count }}{{ $company->license_keys_count }}{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
+
+
+
+
diff --git a/Modules/Platform/resources/views/licenses/index.blade.php b/Modules/Platform/resources/views/licenses/index.blade.php new file mode 100644 index 00000000..b5bf9f8e --- /dev/null +++ b/Modules/Platform/resources/views/licenses/index.blade.php @@ -0,0 +1,18 @@ + +
+ @if($plainKey)
New key: {{ $plainKey }}
@endif +
+

Licence keys

+
+ @csrf + + + + + +
+
+
@foreach($licenses as $license)@endforeach
CompanyLabelPlanPrefixExpires
{{ $license->company->name }}{{ $license->label }}{{ $license->plan }}{{ $license->key_prefix }}{{ optional($license->expires_at)->toDateString() }}
+ {{ $licenses->links() }} +
+
diff --git a/Modules/Platform/resources/views/partials.blade.php b/Modules/Platform/resources/views/partials.blade.php new file mode 100644 index 00000000..64239e28 --- /dev/null +++ b/Modules/Platform/resources/views/partials.blade.php @@ -0,0 +1,7 @@ +@props(['title' => 'WMS Platform']) + +
+
+ {{ $slot }} +
+
diff --git a/Modules/Platform/resources/views/payments/index.blade.php b/Modules/Platform/resources/views/payments/index.blade.php new file mode 100644 index 00000000..d13ef94a --- /dev/null +++ b/Modules/Platform/resources/views/payments/index.blade.php @@ -0,0 +1,18 @@ + +
+
+

Payments and overrides

+
+ @csrf + + + + + + +
+
+
@foreach($payments as $payment)@endforeach
CompanyAmountStatusDescription
{{ $payment->company->name }}{{ strtoupper($payment->currency) }} {{ number_format($payment->amount / 100, 2) }}{{ $payment->status }}{{ $payment->description }}
+ {{ $payments->links() }} +
+
diff --git a/Modules/Platform/routes/web.php b/Modules/Platform/routes/web.php new file mode 100644 index 00000000..979c3df9 --- /dev/null +++ b/Modules/Platform/routes/web.php @@ -0,0 +1,23 @@ +prefix('admin') + ->name('platform.') + ->group(function (): void { + Route::get('/', PlatformDashboardController::class)->name('dashboard'); + Route::get('/companies', [CompanyController::class, 'index'])->name('companies.index'); + Route::post('/companies', [CompanyController::class, 'store'])->name('companies.store'); + Route::get('/licenses', [LicenseKeyController::class, 'index'])->name('licenses.index'); + Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store'); + Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index'); + Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store'); + }); + +Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook'); diff --git a/Modules/Platform/tests/Feature/LicenseKeyTest.php b/Modules/Platform/tests/Feature/LicenseKeyTest.php new file mode 100644 index 00000000..7b1452b5 --- /dev/null +++ b/Modules/Platform/tests/Feature/LicenseKeyTest.php @@ -0,0 +1,26 @@ + 'Tenant A', 'slug' => 'tenant-a']); + + $plainKey = LicenseKey::issueFor($company, 'Production licence', 'enterprise', 50); + $license = LicenseKey::firstOrFail(); + + $this->assertStringStartsWith('WMS-', $plainKey); + $this->assertNotSame($plainKey, $license->key_hash); + $this->assertSame(hash('sha256', $plainKey), $license->key_hash); + $this->assertTrue($company->fresh()->hasActiveLicense()); + } +} diff --git a/Modules/Platform/tests/Feature/PlatformAccessTest.php b/Modules/Platform/tests/Feature/PlatformAccessTest.php new file mode 100644 index 00000000..1fc86723 --- /dev/null +++ b/Modules/Platform/tests/Feature/PlatformAccessTest.php @@ -0,0 +1,50 @@ + 'MPBA Limited', + 'slug' => 'mpba-limited', + 'is_system_company' => true, + 'status' => 'active', + ]); + + $user = User::factory()->create([ + 'company_id' => $company->id, + 'email' => 'mark.gregory@mpba.co.uk', + 'is_platform_admin' => true, + 'is_company_admin' => true, + ]); + + $this->actingAs($user) + ->get('/admin') + ->assertOk() + ->assertSee('Platform administration'); + } + + public function test_non_system_company_requires_license(): void + { + $company = Company::create([ + 'name' => 'Customer Limited', + 'slug' => 'customer-limited', + 'status' => 'active', + ]); + + $user = User::factory()->create(['company_id' => $company->id]); + + $this->actingAs($user) + ->get('/dashboard') + ->assertStatus(402); + } +} diff --git a/Modules/Warehouse/Providers/WarehouseServiceProvider.php b/Modules/Warehouse/Providers/WarehouseServiceProvider.php new file mode 100644 index 00000000..4df6a522 --- /dev/null +++ b/Modules/Warehouse/Providers/WarehouseServiceProvider.php @@ -0,0 +1,19 @@ +loadViewsFrom(__DIR__ . '/../resources/views', 'warehouse'); + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + Route::middleware('web')->group(__DIR__ . '/../routes/web.php'); + } +} diff --git a/Modules/Warehouse/app/Http/Controllers/WarehouseController.php b/Modules/Warehouse/app/Http/Controllers/WarehouseController.php new file mode 100644 index 00000000..37898b60 --- /dev/null +++ b/Modules/Warehouse/app/Http/Controllers/WarehouseController.php @@ -0,0 +1,44 @@ + Warehouse::withCount('validationRules') + ->where('company_id', auth()->user()->company_id) + ->orderBy('name') + ->paginate(20), + ]); + } + + /** + * Create a tenant warehouse. + */ + public function store(Request $request) + { + $data = $request->validate([ + 'code' => ['required', 'string', 'max:50'], + 'name' => ['required', 'string', 'max:255'], + 'address' => ['nullable', 'string', 'max:2000'], + ]); + + Warehouse::create([ + ...$data, + 'company_id' => $request->user()->company_id, + 'status' => 'active', + 'timezone' => $request->user()->company->timezone ?? 'Europe/London', + ]); + + return back()->with('status', 'Warehouse created.'); + } +} diff --git a/Modules/Warehouse/app/Http/Controllers/WarehouseDashboardController.php b/Modules/Warehouse/app/Http/Controllers/WarehouseDashboardController.php new file mode 100644 index 00000000..4f034fc6 --- /dev/null +++ b/Modules/Warehouse/app/Http/Controllers/WarehouseDashboardController.php @@ -0,0 +1,24 @@ +user()->company_id; + + return view('warehouse::warehouses.dashboard', [ + 'warehousesCount' => Warehouse::where('company_id', $companyId)->count(), + 'rulesCount' => WarehouseValidationRule::whereHas('warehouse', fn ($query) => $query->where('company_id', $companyId))->count(), + 'warehouses' => Warehouse::withCount('validationRules')->where('company_id', $companyId)->orderBy('name')->get(), + ]); + } +} diff --git a/Modules/Warehouse/app/Http/Controllers/WarehouseValidationRuleController.php b/Modules/Warehouse/app/Http/Controllers/WarehouseValidationRuleController.php new file mode 100644 index 00000000..239f11d4 --- /dev/null +++ b/Modules/Warehouse/app/Http/Controllers/WarehouseValidationRuleController.php @@ -0,0 +1,56 @@ +user()->company_id; + + return view('warehouse::rules.index', [ + 'warehouses' => Warehouse::where('company_id', $companyId)->orderBy('name')->get(), + 'rules' => WarehouseValidationRule::with('warehouse') + ->whereHas('warehouse', fn ($query) => $query->where('company_id', $companyId)) + ->latest() + ->paginate(20), + ]); + } + + /** + * Create a validation rule for a warehouse. + */ + public function store(Request $request) + { + $companyId = $request->user()->company_id; + + $data = $request->validate([ + 'warehouse_id' => ['required', 'exists:warehouses,id'], + 'name' => ['required', 'string', 'max:255'], + 'rule_key' => ['required', 'string', 'max:100'], + 'severity' => ['required', 'in:info,warning,error,blocker'], + 'configuration' => ['nullable', 'json'], + ]); + + $warehouse = Warehouse::where('company_id', $companyId)->findOrFail($data['warehouse_id']); + + WarehouseValidationRule::create([ + 'warehouse_id' => $warehouse->id, + 'name' => $data['name'], + 'rule_key' => $data['rule_key'], + 'severity' => $data['severity'], + 'is_active' => true, + 'configuration' => $data['configuration'] ? json_decode($data['configuration'], true) : null, + ]); + + return back()->with('status', 'Validation rule created.'); + } +} diff --git a/Modules/Warehouse/app/Models/Warehouse.php b/Modules/Warehouse/app/Models/Warehouse.php new file mode 100644 index 00000000..47f1b37c --- /dev/null +++ b/Modules/Warehouse/app/Models/Warehouse.php @@ -0,0 +1,37 @@ + 'array']; + + protected static function booted(): void + { + static::creating(function (Warehouse $warehouse): void { + $warehouse->uuid ??= (string) Str::uuid(); + $warehouse->status ??= 'active'; + $warehouse->timezone ??= 'Europe/London'; + }); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function validationRules(): HasMany + { + return $this->hasMany(WarehouseValidationRule::class); + } +} diff --git a/Modules/Warehouse/app/Models/WarehouseValidationRule.php b/Modules/Warehouse/app/Models/WarehouseValidationRule.php new file mode 100644 index 00000000..e3f93363 --- /dev/null +++ b/Modules/Warehouse/app/Models/WarehouseValidationRule.php @@ -0,0 +1,23 @@ + 'boolean', + 'configuration' => 'array', + ]; + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } +} diff --git a/Modules/Warehouse/database/migrations/2026_06_10_000100_create_warehouse_tables.php b/Modules/Warehouse/database/migrations/2026_06_10_000100_create_warehouse_tables.php new file mode 100644 index 00000000..4ac38e36 --- /dev/null +++ b/Modules/Warehouse/database/migrations/2026_06_10_000100_create_warehouse_tables.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->string('code'); + $table->string('name'); + $table->string('status')->default('active')->index(); + $table->text('address')->nullable(); + $table->string('timezone')->default('Europe/London'); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->unique(['company_id', 'code']); + }); + + Schema::create('warehouse_validation_rules', function (Blueprint $table): void { + $table->id(); + $table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->string('name'); + $table->string('rule_key')->index(); + $table->string('severity')->default('error'); + $table->boolean('is_active')->default(true)->index(); + $table->json('configuration')->nullable(); + $table->timestamps(); + $table->unique(['warehouse_id', 'rule_key']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('warehouse_validation_rules'); + Schema::dropIfExists('warehouses'); + } +}; diff --git a/Modules/Warehouse/module.json b/Modules/Warehouse/module.json new file mode 100644 index 00000000..485ace0a --- /dev/null +++ b/Modules/Warehouse/module.json @@ -0,0 +1 @@ +{"name":"Warehouse","alias":"warehouse","description":"Warehouse management, warehouse-specific validation rules and operational dashboard.","keywords":["wms","warehouse"],"priority":1,"providers":["Modules\\Warehouse\\Providers\\WarehouseServiceProvider"],"files":[]} diff --git a/Modules/Warehouse/resources/views/rules/index.blade.php b/Modules/Warehouse/resources/views/rules/index.blade.php new file mode 100644 index 00000000..6a9049fb --- /dev/null +++ b/Modules/Warehouse/resources/views/rules/index.blade.php @@ -0,0 +1,18 @@ + +
+
+

Warehouse validation rules

+
+ @csrf + + + + + + +
+
+
@foreach($rules as $rule)@endforeach
WarehouseRuleKeySeverity
{{ $rule->warehouse->name }}{{ $rule->name }}{{ $rule->rule_key }}{{ $rule->severity }}
+ {{ $rules->links() }} +
+
diff --git a/Modules/Warehouse/resources/views/warehouses/dashboard.blade.php b/Modules/Warehouse/resources/views/warehouses/dashboard.blade.php new file mode 100644 index 00000000..ee7902c0 --- /dev/null +++ b/Modules/Warehouse/resources/views/warehouses/dashboard.blade.php @@ -0,0 +1,27 @@ + +
+
+
+
+

Warehouse management

+

{{ auth()->user()->company->name }}

+

High-performance tenant-isolated warehouse operations with per-warehouse validation and licence-aware access.

+
+
+

Warehouses

{{ $warehousesCount }}

+

Rules

{{ $rulesCount }}

+
+
+
+
+ @forelse($warehouses as $warehouse) +
+

{{ $warehouse->code }}

{{ $warehouse->name }}

{{ $warehouse->status }}
+

{{ $warehouse->validation_rules_count }} validation rules configured.

+
+ @empty +

Create your first warehouse

Warehouses are isolated to your company tenant.

Add warehouse
+ @endforelse +
+
+
diff --git a/Modules/Warehouse/resources/views/warehouses/index.blade.php b/Modules/Warehouse/resources/views/warehouses/index.blade.php new file mode 100644 index 00000000..5cf645fd --- /dev/null +++ b/Modules/Warehouse/resources/views/warehouses/index.blade.php @@ -0,0 +1,20 @@ + +
+
+

Warehouses

+
+ @csrf + + + + +
+
+
+ @foreach($warehouses as $warehouse) +

{{ $warehouse->code }}

{{ $warehouse->name }}

{{ $warehouse->validation_rules_count }} rules

+ @endforeach +
+ {{ $warehouses->links() }} +
+
diff --git a/Modules/Warehouse/routes/web.php b/Modules/Warehouse/routes/web.php new file mode 100644 index 00000000..4a54c4fb --- /dev/null +++ b/Modules/Warehouse/routes/web.php @@ -0,0 +1,15 @@ +group(function (): void { + Route::get('/dashboard', WarehouseDashboardController::class)->name('dashboard'); + Route::get('/warehouses', [WarehouseController::class, 'index'])->name('warehouses.index'); + Route::post('/warehouses', [WarehouseController::class, 'store'])->name('warehouses.store'); + Route::get('/warehouse-rules', [WarehouseValidationRuleController::class, 'index'])->name('warehouse-rules.index'); + Route::post('/warehouse-rules', [WarehouseValidationRuleController::class, 'store'])->name('warehouse-rules.store'); + }); diff --git a/Modules/Warehouse/tests/Feature/WarehouseIsolationTest.php b/Modules/Warehouse/tests/Feature/WarehouseIsolationTest.php new file mode 100644 index 00000000..08aec28b --- /dev/null +++ b/Modules/Warehouse/tests/Feature/WarehouseIsolationTest.php @@ -0,0 +1,32 @@ + 'A Ltd', 'slug' => 'a-ltd']); + $companyB = Company::create(['name' => 'B Ltd', 'slug' => 'b-ltd']); + LicenseKey::issueFor($companyA, 'A licence'); + + $user = User::factory()->create(['company_id' => $companyA->id]); + Warehouse::create(['company_id' => $companyA->id, 'code' => 'A1', 'name' => 'A Warehouse']); + Warehouse::create(['company_id' => $companyB->id, 'code' => 'B1', 'name' => 'B Warehouse']); + + $this->actingAs($user) + ->get('/warehouses') + ->assertOk() + ->assertSee('A Warehouse') + ->assertDontSee('B Warehouse'); + } +} diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 3c7c00c8..0119989a 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -5,29 +5,47 @@ namespace App\Actions\Fortify; use App\Concerns\PasswordValidationRules; use App\Concerns\ProfileValidationRules; use App\Models\User; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Laravel\Fortify\Contracts\CreatesNewUsers; +use Modules\Platform\app\Models\Company; class CreateNewUser implements CreatesNewUsers { use PasswordValidationRules, ProfileValidationRules; /** - * Validate and create a newly registered user. + * Validate and create a newly registered user with an isolated company tenant. * - * @param array $input + * @param array $input */ public function create(array $input): User { Validator::make($input, [ ...$this->profileRules(), 'password' => $this->passwordRules(), + 'company_name' => ['nullable', 'string', 'max:255'], ])->validate(); - return User::create([ - 'name' => $input['name'], - 'email' => $input['email'], - 'password' => $input['password'], - ]); + return DB::transaction(function () use ($input): User { + $companyName = $input['company_name'] ?? ($input['name'] . ' Company'); + + $company = Company::create([ + 'name' => $companyName, + 'slug' => Str::slug($companyName . '-' . Str::random(6)), + 'status' => 'active', + 'timezone' => 'Europe/London', + ]); + + return User::create([ + 'company_id' => $company->id, + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => $input['password'], + 'is_company_admin' => true, + 'status' => 'active', + ]); + }); } } diff --git a/app/Models/Company.php b/app/Models/Company.php new file mode 100644 index 00000000..f62e33f2 --- /dev/null +++ b/app/Models/Company.php @@ -0,0 +1,7 @@ + */ - use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + + protected $fillable = [ + 'company_id', 'name', 'email', 'password', 'is_platform_admin', 'is_company_admin', 'status', + ]; + + protected $hidden = ['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token']; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'is_platform_admin' => 'boolean', + 'is_company_admin' => 'boolean', ]; } - /** - * Get the user's initials - */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + public function initials(): string { return Str::of($this->name) ->explode(' ') ->take(2) - ->map(fn ($word) => Str::substr($word, 0, 1)) + ->map(fn ($word) => Str::substr((string) $word, 0, 1)) ->implode(''); } + + public function isMpbaPlatformAdmin(): bool + { + return $this->is_platform_admin && $this->company?->is_system_company === true; + } } diff --git a/app/Models/Warehouse.php b/app/Models/Warehouse.php new file mode 100644 index 00000000..75d7ea6a --- /dev/null +++ b/app/Models/Warehouse.php @@ -0,0 +1,7 @@ + 'Modules', + 'paths' => [ + 'modules' => base_path('Modules'), + 'assets' => public_path('modules'), + 'migration' => base_path('database/migrations'), + ], + 'scan' => [ + 'enabled' => true, + 'paths' => [base_path('Modules')], + ], + 'composer' => [ + 'vendor' => 'mpba', + 'author' => ['name' => 'MPBA Limited', 'email' => 'mark.gregory@mpba.co.uk'], + ], +]; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8b..50515a67 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,9 +2,9 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Modules\Platform\database\seeders\PlatformSeeder; class DatabaseSeeder extends Seeder { @@ -15,11 +15,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $this->call(PlatformSeeder::class); } } diff --git a/docs-ha-production.md b/docs-ha-production.md new file mode 100644 index 00000000..934bca9a --- /dev/null +++ b/docs-ha-production.md @@ -0,0 +1,74 @@ +# WMS production baseline + +This project is organised as a modular Laravel 13 WMS suite using nwidart-style modules. + +## Modules + +- `Modules/Platform` - companies, MPBA system administration, licence keys, Stripe payment records and tenant/licence middleware. +- `Modules/Warehouse` - tenant-owned warehouses and warehouse-specific validation rules. +- `Modules/InternalApi` - HMAC-authenticated API for secure communication between separated core/admin applications. + +## PostgreSQL + +Use PostgreSQL 18 in production with HAProxy in front of Patroni-managed primary/replica nodes. + +```env +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=6432 +DB_DATABASE=wms_platform +DB_USERNAME=wms_app +DB_PASSWORD=change_me +``` + +## Redis + +Use Redis Sentinel for cache, queues and sessions. + +```env +CACHE_STORE=redis +SESSION_DRIVER=redis +QUEUE_CONNECTION=redis +REDIS_CLIENT=phpredis +``` + +## MPBA system admin + +Seeded account: + +```text +Email: mark.gregory@mpba.co.uk +Password: 987400Kmh +Company: MPBA Limited +``` + +MPBA Limited is marked as `is_system_company = true`; MPBA platform admins bypass licence checks. + +## Internal API signing + +Internal callers must send: + +```text +X-WMS-Timestamp: ISO8601 timestamp +X-WMS-Signature: hash_hmac('sha256', timestamp + '.' + body, WMS_INTERNAL_API_KEY) +``` + +Requests older than 5 minutes are rejected. + +## Deployment sequence + +```bash +composer install --no-dev --prefer-dist --optimize-autoloader +npm ci +npm run build +php artisan migrate --force +php artisan db:seed --force +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan event:cache +php artisan queue:restart +php artisan octane:reload +``` + +Use rolling deployments behind HAProxy for zero downtime. diff --git a/modules_statuses.json b/modules_statuses.json new file mode 100644 index 00000000..5c160715 --- /dev/null +++ b/modules_statuses.json @@ -0,0 +1,5 @@ +{ + "Platform": true, + "Warehouse": true, + "InternalApi": true +} diff --git a/phpunit.xml b/phpunit.xml index e7f0a48d..fdb8c5e0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,11 +10,13 @@ tests/Feature + Modules/*/tests/Feature app + Modules diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index 6db2170a..43da5a5b 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -11,22 +11,50 @@ - + {{ __('Dashboard') }} + + + {{ __('Warehouses') }} + + + + {{ __('Validation Rules') }} + + + @if(auth()->user()?->isMpbaPlatformAdmin()) + + + {{ __('Admin Dashboard') }} + + + + {{ __('Companies') }} + + + + {{ __('Licences') }} + + + + {{ __('Payments') }} + + + @endif - - {{ __('Repository') }} + + {{ __('Health Check') }} - - {{ __('Documentation') }} + + {{ __('Platform Admin') }} diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 9cb91a66..52424d21 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -2,7 +2,6 @@
- @@ -10,50 +9,36 @@
@csrf - - - - -
- - - @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif +
+ + + @error('email')

{{ $message }}

@enderror
- - - -
- - {{ __('Log in') }} - +
+
+ + @if (Route::has('password.request')) + {{ __('Forgot your password?') }} + @endif +
+ + @error('password')

{{ $message }}

@enderror
+ + + + -
+
{{ __('Don\'t have an account?') }} - {{ __('Sign up') }} + {{ __('Sign up') }}
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php index 30ddb895..6a581eb7 100644 --- a/resources/views/livewire/auth/register.blade.php +++ b/resources/views/livewire/auth/register.blade.php @@ -2,68 +2,42 @@
-
@csrf - - - - - - - - - - - -
- - {{ __('Create account') }} - +
+ + + @error('name')

{{ $message }}

@enderror
+ +
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + + @error('password')

{{ $message }}

@enderror +
+ +
+ + +
+ + -
+
{{ __('Already have an account?') }} - {{ __('Log in') }} + {{ __('Log in') }}
diff --git a/routes/web.php b/routes/web.php index 12e932e9..1d6e0475 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,8 +4,4 @@ use Illuminate\Support\Facades\Route; Route::view('/', 'welcome')->name('home'); -Route::middleware(['auth', 'verified'])->group(function () { - Route::view('dashboard', 'dashboard')->name('dashboard'); -}); - require __DIR__.'/settings.php';