now favicons

This commit is contained in:
Mark Gregory
2026-06-10 13:10:09 +01:00
parent c59b16d791
commit 9518ff889b
37 changed files with 324 additions and 168 deletions
-20
View File
@@ -1,20 +0,0 @@
# 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
-59
View File
@@ -1,59 +0,0 @@
# 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/`.
@@ -18,11 +18,22 @@ class VerifyInternalApiSignature
$signature = (string) $request->header('X-WMS-Signature');
abort_if($secret === '' || $timestamp === '' || $signature === '', 401);
abort_if(strtotime($timestamp) === false, 401, 'Invalid internal API timestamp.');
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.');
$body = $request->getContent();
$payloads = array_unique([
$timestamp . '.' . $body,
$timestamp . '.',
$timestamp . '.[]',
]);
return $next($request);
foreach ($payloads as $payload) {
if (hash_equals(hash_hmac('sha256', $payload, $secret), $signature)) {
return $next($request);
}
}
abort(401, 'Invalid internal API signature.');
}
}
+8 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Settings;
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Livewire\Component;
class DeleteUserForm extends Component
@@ -22,7 +23,13 @@ class DeleteUserForm extends Component
'password' => $this->currentPasswordRules(),
]);
tap(Auth::user(), $logout(...))->delete();
$user = Auth::user();
if ($user && Schema::hasTable(config('permission.table_names.model_has_roles', 'model_has_roles'))) {
$user->syncRoles([]);
}
tap($user, $logout(...))->delete();
$this->redirect('/', navigate: true);
}
+1
View File
@@ -11,6 +11,7 @@
"fortify-development",
"laravel-best-practices",
"configuring-horizon",
"octane-development",
"fluxui-development",
"livewire-development",
"tailwindcss-development"
+8 -1
View File
@@ -12,7 +12,14 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$environment = $_ENV['APP_ENV']
?? $_SERVER['APP_ENV']
?? getenv('APP_ENV')
?: null;
if ($environment === 'testing') {
$middleware->validateCsrfTokens(except: ['*']);
}
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
+61
View File
@@ -6,6 +6,8 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Platform\app\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
/**
* @extends Factory<User>
@@ -25,6 +27,7 @@ class UserFactory extends Factory
public function definition(): array
{
return [
'company_id' => fn (): int => $this->licensedCompany()->id,
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
@@ -33,6 +36,9 @@ class UserFactory extends Factory
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
'is_platform_admin' => false,
'is_company_admin' => true,
'status' => 'active',
];
}
@@ -57,4 +63,59 @@ class UserFactory extends Factory
'two_factor_confirmed_at' => now(),
]);
}
/**
* Use the MPBA system tenant and platform administrator flags.
*/
public function mpbaPlatformAdmin(): static
{
return $this->state(fn (array $attributes) => [
'company_id' => $this->systemCompany()->id,
'is_platform_admin' => true,
'is_company_admin' => true,
'status' => 'active',
]);
}
/**
* Create or reuse a licensed tenant company for ordinary user tests.
*/
private function licensedCompany(): Company
{
$company = Company::query()->where('slug', 'test-company')->first();
if (! $company) {
$company = Company::query()->create([
'name' => 'Test Company',
'slug' => 'test-company',
'status' => 'active',
'timezone' => 'Europe/London',
'is_system_company' => false,
'billing_email' => 'billing@test-company.example',
]);
}
if (! $company->hasActiveLicense()) {
LicenseKey::issueFor($company, 'Automated Test Licence', 'enterprise', 250);
}
return $company;
}
/**
* Create or reuse the MPBA system tenant.
*/
private function systemCompany(): Company
{
return Company::query()->firstOrCreate(
['slug' => 'mpba-limited'],
[
'name' => 'MPBA Limited',
'status' => 'active',
'timezone' => 'Europe/London',
'is_system_company' => true,
'billing_email' => 'mark.gregory@mpba.co.uk',
],
);
}
}
@@ -0,0 +1,123 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), RuntimeException::class, 'Spatie permission table names are not configured.');
Schema::create($tableNames['permissions'], function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
Schema::create($tableNames['roles'], function (Blueprint $table) use ($teams, $columnNames): void {
$table->bigIncrements('id');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams): void {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id')
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams): void {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id')
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], 'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], 'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission): void {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id')
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
->references('id')
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};
-74
View File
@@ -1,74 +0,0 @@
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/favicons/mstile-150x150.png"/>
<square310x310logo src="/favicons/mstile-310x310.png"/>
<TileColor>#0f172a</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#0e7490"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="8" dy="12" stdDeviation="8" flood-color="#000" flood-opacity=".35"/>
</filter>
</defs>
<rect width="512" height="512" rx="110" fill="url(#bg)"/>
<path d="M0 96L430 0L512 65L100 200Z" fill="#fff" opacity=".10"/>
<g filter="url(#shadow)">
<path d="M126 181L256 256L256 411L126 331Z" fill="#082f49"/>
<path d="M386 181L256 256L256 411L386 331Z" fill="#064e3b"/>
<path d="M256 106L386 181L256 256L126 181Z" fill="#22d3ee"/>
<path d="M126 181L256 106L386 181L256 256Z" fill="none" stroke="#e2fcff" stroke-width="8"/>
<path d="M126 181L256 256L256 411L126 331Z" fill="none" stroke="#e2fcff" stroke-width="8"/>
<path d="M386 181L256 256L256 411L386 331Z" fill="none" stroke="#e2fcff" stroke-width="8"/>
<path d="M151 226L191 346L236 276L281 346L351 226" fill="none" stroke="#f8fafc" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M151 226L191 346L236 276L281 346L351 226" fill="none" stroke="#22d3ee" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<circle cx="184" cy="401" r="12" fill="#fff" opacity=".62"/>
<circle cx="256" cy="401" r="12" fill="#fff" opacity=".9"/>
<circle cx="328" cy="401" r="12" fill="#fff" opacity=".62"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"name": "WMS Platform",
"short_name": "WMS",
"description": "High performance warehouse management platform",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0e7490",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+11
View File
@@ -10,6 +10,17 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="icon" type="image/x-icon" href="/favicons/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg">
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/favicons/favicon-96x96.png">
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png">
<link rel="manifest" href="/favicons/site.webmanifest">
<meta name="theme-color" content="#0e7490">
<meta name="msapplication-TileColor" content="#0f172a">
<meta name="msapplication-config" content="/favicons/browserconfig.xml">
@fonts
<!-- Styles -->
+4 -4
View File
@@ -22,7 +22,7 @@ class AuthenticationTest extends TestCase
{
$user = User::factory()->create();
$response = $this->post(route('login.store'), [
$response = $this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
@@ -38,7 +38,7 @@ class AuthenticationTest extends TestCase
{
$user = User::factory()->create();
$response = $this->post(route('login.store'), [
$response = $this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
@@ -59,7 +59,7 @@ class AuthenticationTest extends TestCase
$user = User::factory()->withTwoFactor()->create();
$response = $this->post(route('login.store'), [
$response = $this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
@@ -72,7 +72,7 @@ class AuthenticationTest extends TestCase
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('logout'));
$response = $this->actingAs($user)->csrfPost(route('logout'));
$response->assertRedirect(route('home'));
+4 -4
View File
@@ -33,7 +33,7 @@ class PasswordResetTest extends TestCase
$user = User::factory()->create();
$this->post(route('password.request'), ['email' => $user->email]);
$this->csrfPost(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
@@ -44,7 +44,7 @@ class PasswordResetTest extends TestCase
$user = User::factory()->create();
$this->post(route('password.request'), ['email' => $user->email]);
$this->csrfPost(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get(route('password.reset', $notification->token));
@@ -61,10 +61,10 @@ class PasswordResetTest extends TestCase
$user = User::factory()->create();
$this->post(route('password.request'), ['email' => $user->email]);
$this->csrfPost(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post(route('password.update'), [
$response = $this->csrfPost(route('password.update'), [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
+1 -1
View File
@@ -26,7 +26,7 @@ class RegistrationTest extends TestCase
public function test_new_users_can_register(): void
{
$response = $this->post(route('register.store'), [
$response = $this->csrfPost(route('register.store'), [
'name' => 'John Doe',
'email' => 'test@example.com',
'password' => 'password',
@@ -34,7 +34,7 @@ class TwoFactorChallengeTest extends TestCase
$user = User::factory()->withTwoFactor()->create();
$this->post(route('login.store'), [
$this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'password',
])->assertRedirect(route('two-factor.login'));
+28
View File
@@ -2,11 +2,39 @@
namespace Tests;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Laravel\Fortify\Features;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'app.debug' => false,
'session.driver' => 'array',
'cache.default' => 'array',
'mail.default' => 'array',
]);
$this->withoutMiddleware(ValidateCsrfToken::class);
}
/**
* POST with a real CSRF token for Fortify/web form tests.
*
* @param array<string, mixed> $data
*/
protected function csrfPost(string $uri, array $data = []): \Illuminate\Testing\TestResponse
{
$token = 'test-csrf-token';
return $this->withSession(['_token' => $token])
->post($uri, ['_token' => $token, ...$data]);
}
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
{
if (! Features::enabled($feature)) {