Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions Modules/Core/Database/Seeders/RolesSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 30 additions & 10 deletions Modules/Core/Filament/Responses/LoginResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
11 changes: 11 additions & 0 deletions Modules/Core/Tests/AbstractAdminPanelTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}

Expand Down
11 changes: 11 additions & 0 deletions Modules/Core/Tests/AbstractCompanyPanelTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}

Expand Down
231 changes: 231 additions & 0 deletions Modules/Core/Tests/Feature/LoginRedirectTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<?php

namespace Modules\Core\Tests\Feature;

use Illuminate\Support\Carbon;
use Livewire\Livewire;
use Modules\Core\Enums\UserRole;
use Modules\Core\Filament\Pages\Auth\Login;
use Modules\Core\Filament\Responses\LoginResponse;
use Modules\Core\Models\Company;
use Modules\Core\Models\User;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;

#[CoversClass(LoginResponse::class)]
class LoginRedirectTest extends AbstractCompanyPanelTestCase
{

protected function setUp(): void
{
parent::setUp();
// Clean slate: remove companies created by base class to properly test fallback logic
Company::query()->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
}
Loading
Loading