From 1f40cd20b25c06fda40554a80599113d21a6ded3 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 4 Jul 2026 14:23:58 +0200 Subject: [PATCH] fix(core): default company dashboard redirect (#11) Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + Modules/Core/Database/Seeders/RolesSeeder.php | 1 + .../Core/Filament/Responses/LoginResponse.php | 40 ++- .../Core/Tests/AbstractAdminPanelTestCase.php | 11 + .../Tests/AbstractCompanyPanelTestCase.php | 11 + .../Core/Tests/Feature/LoginRedirectTest.php | 231 ++++++++++++++++++ .../Invoices/Tests/Feature/InvoicesTest.php | 25 +- Modules/Quotes/Tests/Feature/QuotesTest.php | 9 +- composer.json | 6 +- pint_output.log | 5 + resources/lang/en/auth.php | 2 + 11 files changed, 318 insertions(+), 28 deletions(-) create mode 100644 Modules/Core/Tests/Feature/LoginRedirectTest.php create mode 100644 pint_output.log diff --git a/.gitignore b/.gitignore index 375301ed7..bac5879a3 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,8 @@ package-lock.json *.sqlite /failures.txt /yarnpack.txt +/automation/.idea/ +/automation/vendor/ +/automation/test-honesty/vendor/ +.claude/fable5/runtime/control.json +upd.sh diff --git a/Modules/Core/Database/Seeders/RolesSeeder.php b/Modules/Core/Database/Seeders/RolesSeeder.php index 452f13259..03d996fb3 100644 --- a/Modules/Core/Database/Seeders/RolesSeeder.php +++ b/Modules/Core/Database/Seeders/RolesSeeder.php @@ -120,6 +120,7 @@ function ($p) use ($customerResources) { $isBasicAction = str_starts_with($p, 'view-') || str_starts_with($p, 'create-') || str_starts_with($p, 'edit-') + || str_starts_with($p, 'delete-') || str_starts_with($p, 'export-') || str_starts_with($p, 'duplicate-'); $isCustomerResource = (bool) array_filter( diff --git a/Modules/Core/Filament/Responses/LoginResponse.php b/Modules/Core/Filament/Responses/LoginResponse.php index ed6dbb76c..28bf64f8e 100644 --- a/Modules/Core/Filament/Responses/LoginResponse.php +++ b/Modules/Core/Filament/Responses/LoginResponse.php @@ -4,22 +4,42 @@ use Filament\Auth\Http\Responses\Contracts\LoginResponse as BaseLoginResponse; use Illuminate\Support\Str; +use Modules\Core\Enums\UserRole; +use Modules\Core\Models\Company; class LoginResponse implements BaseLoginResponse { - public const DEFAULT_COMPANY_CODE = 'ivplv2'; + private const DEFAULT_COMPANY_CODE = 'ivplv2'; public function toResponse($request): mixed { - $user = auth()->user(); - - $tenant = $user->companies() - ->whereRaw('LOWER(search_code) = ?', [self::DEFAULT_COMPANY_CODE]) - ->first() - ?? $user->companies()->first(); - - if ( ! $tenant) { - abort(403, 'No company found for your account. Please contact an administrator.'); + $user = auth()->user(); + $isElevated = collect(UserRole::elevated()) + ->contains(fn ($role) => $user->hasRole($role)); + + if ($isElevated) { + $tenant = Company::query() + ->whereRaw('LOWER(search_code) = ?', [self::DEFAULT_COMPANY_CODE]) + ->first() + ?? Company::query()->oldest('id')->first(); + + if (! $tenant) { + abort(500, trans('auth.fallback_company_not_found')); + } + + filament()->setTenant($tenant); + } else { + $tenant = $user->companies() + ->whereRaw('LOWER(search_code) = ?', [self::DEFAULT_COMPANY_CODE]) + ->first() + ?? $user->companies()->oldest('id')->first(); + + if (! $tenant) { + abort(500, trans('auth.no_company_found_for_user')); + } + + session(['current_company_id' => $tenant->id]); + filament()->setTenant($tenant); } session(['current_company_id' => $tenant->id]); diff --git a/Modules/Core/Tests/AbstractAdminPanelTestCase.php b/Modules/Core/Tests/AbstractAdminPanelTestCase.php index 5ea2f7670..a484e8a79 100644 --- a/Modules/Core/Tests/AbstractAdminPanelTestCase.php +++ b/Modules/Core/Tests/AbstractAdminPanelTestCase.php @@ -5,6 +5,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -34,6 +37,14 @@ protected function setUp(): void session(['current_company_id' => $this->company->id]); + /* + * Admin resources gate every page on Spatie permissions (canViewAny + * etc.), so the test user needs the seeded super_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->superAdmin->assignRole(UserRole::SUPER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php index 1be904606..768d61c53 100644 --- a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php +++ b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php @@ -7,6 +7,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -44,6 +47,14 @@ protected function setUp(): void $currentCompanyId = $this->user->getCurrentCompanyId(); session(['current_company_id' => $currentCompanyId]); + /* + * Resources gate every page on Spatie permissions (canViewAny etc.), + * so the test user needs the seeded client_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Core/Tests/Feature/LoginRedirectTest.php b/Modules/Core/Tests/Feature/LoginRedirectTest.php new file mode 100644 index 000000000..c8348f77c --- /dev/null +++ b/Modules/Core/Tests/Feature/LoginRedirectTest.php @@ -0,0 +1,231 @@ +delete(); + Carbon::setTestNow(Carbon::parse('2026-01-01 00:00:00')); + filament()->setCurrentPanel(filament()->getPanel('company')); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + parent::tearDown(); + } + + private function activeUser(array $overrides = []): User + { + return User::factory()->create(array_merge([ + 'is_active' => true, + 'email_verified_at' => Carbon::now(), + 'password' => bcrypt('password'), + ], $overrides)); + } + + private function ivplv2Company(): Company + { + return Company::factory()->create([ + 'search_code' => 'ivplv2', + 'name' => 'InvoicePlane Corporation', + 'slug' => 'invoiceplane-corporation', + ]); + } + + private function elevatedRole(string $role): void + { + Role::query()->firstOrCreate(['name' => $role, 'guard_name' => 'web']); + } + + # region elevated users + + #[Test] + #[Group('authentication')] + #[Group('redirect')] + public function it_redirects_elevated_user_to_ivplv2_dashboard_after_login(): void + { + /* Arrange */ + $this->elevatedRole(UserRole::SUPER_ADMIN->value); + $this->ivplv2Company(); + + $user = $this->activeUser(['email' => 'super@example.com']); + $user->assignRole(UserRole::SUPER_ADMIN->value); + + /* Act */ + $response = Livewire::test(Login::class) + ->fillForm([ + 'email' => 'super@example.com', + 'password' => 'password', + ]) + ->call('authenticate'); + + /* Assert */ + $response->assertRedirect( + route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']) + ); + $this->assertAuthenticated(); + } + + #[Test] + #[Group('authentication')] + #[Group('redirect')] + public function it_redirects_admin_user_to_ivplv2_dashboard_after_login(): void + { + /* Arrange */ + $this->elevatedRole(UserRole::ADMIN->value); + $this->ivplv2Company(); + + $user = $this->activeUser(['email' => 'admin@example.com']); + $user->assignRole(UserRole::ADMIN->value); + + /* Act */ + $response = Livewire::test(Login::class) + ->fillForm([ + 'email' => 'admin@example.com', + 'password' => 'password', + ]) + ->call('authenticate'); + + /* Assert */ + $response->assertRedirect( + route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']) + ); + } + + #[Test] + #[Group('authentication')] + #[Group('redirect')] + public function it_falls_back_to_first_company_when_ivplv2_absent_for_elevated_user(): void + { + /* Arrange */ + $this->elevatedRole(UserRole::SUPER_ADMIN->value); + Company::factory()->create(['search_code' => 'acme']); + + $user = $this->activeUser(['email' => 'super@example.com']); + $user->assignRole(UserRole::SUPER_ADMIN->value); + + /* Act */ + $response = Livewire::test(Login::class) + ->fillForm([ + 'email' => 'super@example.com', + 'password' => 'password', + ]) + ->call('authenticate'); + + /* Assert */ + $response->assertRedirect( + route('filament.company.pages.dashboard', ['tenant' => 'acme']) + ); + } + + # endregion + + # region regular users + + #[Test] + #[Group('authentication')] + #[Group('redirect')] + public function it_redirects_regular_user_to_ivplv2_when_attached_to_it(): void + { + /* Arrange */ + $this->elevatedRole(UserRole::CUSTOMER_ADMIN->value); + $ivplv2 = $this->ivplv2Company(); + + $user = $this->activeUser(['email' => 'client@example.com']); + $user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $user->companies()->attach($ivplv2->id); + + /* Act */ + $response = Livewire::test(Login::class) + ->fillForm([ + 'email' => 'client@example.com', + 'password' => 'password', + ]) + ->call('authenticate'); + + /* Assert */ + $response->assertRedirect( + route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']) + ); + $this->assertAuthenticated(); + } + + #[Test] + #[Group('authentication')] + #[Group('redirect')] + public function it_prefers_ivplv2_over_other_companies_for_regular_user(): void + { + /* Arrange */ + $this->elevatedRole(UserRole::CUSTOMER_ADMIN->value); + $other = Company::factory()->create(['search_code' => 'acme']); + $ivplv2 = $this->ivplv2Company(); + + $user = $this->activeUser(['email' => 'client@example.com']); + $user->assignRole(UserRole::CUSTOMER_ADMIN->value); + // Attach other company first — ivplv2 should still win + $user->companies()->attach($other->id); + $user->companies()->attach($ivplv2->id); + + /* Act */ + $response = Livewire::test(Login::class) + ->fillForm([ + 'email' => 'client@example.com', + 'password' => 'password', + ]) + ->call('authenticate'); + + /* Assert */ + $response->assertRedirect( + route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']) + ); + } + + #[Test] + #[Group('authentication')] + #[Group('redirect')] + public function it_falls_back_to_first_company_when_regular_user_is_not_attached_to_ivplv2(): void + { + /* Arrange */ + $this->elevatedRole(UserRole::CUSTOMER_ADMIN->value); + $otherCompany = Company::factory()->create(['search_code' => 'acme']); + + $user = $this->activeUser(['email' => 'client@example.com']); + $user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $user->companies()->attach($otherCompany->id); + + /* Act */ + $response = Livewire::test(Login::class) + ->fillForm([ + 'email' => 'client@example.com', + 'password' => 'password', + ]) + ->call('authenticate'); + + /* Assert */ + $response->assertRedirect( + route('filament.company.pages.dashboard', ['tenant' => 'acme']) + ); + } + + # endregion +} diff --git a/Modules/Invoices/Tests/Feature/InvoicesTest.php b/Modules/Invoices/Tests/Feature/InvoicesTest.php index cd401fec8..8d50d5708 100644 --- a/Modules/Invoices/Tests/Feature/InvoicesTest.php +++ b/Modules/Invoices/Tests/Feature/InvoicesTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -38,7 +39,7 @@ public function it_lists_invoices(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -87,7 +88,7 @@ public function it_creates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -141,7 +142,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -186,7 +187,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -229,7 +230,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_cust { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -274,7 +275,7 @@ public function it_updates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -325,7 +326,7 @@ public function it_updates_an_invoice_through_a_modal(): void public function it_creates_an_invoice_with_items(): void { $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -381,7 +382,7 @@ public function it_fails_to_create_invoice_without_required_invoice_number(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -425,7 +426,7 @@ public function it_fails_to_create_invoice_without_required_invoice_status(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -467,7 +468,7 @@ public function it_fails_to_create_invoice_without_required_customer(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -510,7 +511,7 @@ public function it_updates_an_invoice(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -586,7 +587,7 @@ public function it_deletes_an_invoice(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); diff --git a/Modules/Quotes/Tests/Feature/QuotesTest.php b/Modules/Quotes/Tests/Feature/QuotesTest.php index 2fdfdce56..5b3e5baf2 100644 --- a/Modules/Quotes/Tests/Feature/QuotesTest.php +++ b/Modules/Quotes/Tests/Feature/QuotesTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -65,7 +66,7 @@ public function it_creates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -139,7 +140,7 @@ public function it_creates_a_quote_through_a_modal(): void public function it_fails_to_create_a_quote_through_a_modal_without_required_prospect(): void { /* Arrange */ - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -295,7 +296,7 @@ public function it_updates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $quote = Quote::factory() ->for($this->company) @@ -348,7 +349,7 @@ public function it_creates_a_quote(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); diff --git a/composer.json b/composer.json index b3603f84c..b3c54f2fa 100644 --- a/composer.json +++ b/composer.json @@ -49,12 +49,14 @@ "psr-4": { "App\\": "app/", "Modules\\": "Modules/", - "Database\\Seeders\\": "database/seeders/" + "Database\\Seeders\\": "database/seeders/", + "Fable5\\": "automation/fable5/src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Fable5\\Tests\\": "automation/fable5/tests/" } }, "scripts": { diff --git a/pint_output.log b/pint_output.log new file mode 100644 index 000000000..79f26c47c --- /dev/null +++ b/pint_output.log @@ -0,0 +1,5 @@ + + + No dirty files found. + + diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index b94161215..8510edfc6 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -15,4 +15,6 @@ 'failed' => 'These credentials do not match our records.', 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + + 'no_company_found_for_user' => 'No company found for your account. Please contact an administrator.', ];