diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index 5244587..cff101a 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -286,6 +286,9 @@ class SsoLoginController extends Controller { try { $access = $identity->appAccess($user->ownerRef(), $intended); + $member = app(\App\Services\Care\OrganizationResolver::class)->memberFor($user); + $access = app(\App\Services\Care\CarePermissions::class) + ->filterStaffAppAccess($member, $access); \App\Support\StaffUx::remember($access); return $access['url'] !== '' ? $access['url'] : $intended; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7c0f4ba..23583b4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -74,16 +74,43 @@ class AppServiceProvider extends ServiceProvider View::composer(['partials.launcher', 'partials.topbar-desktop-widgets', 'components.app-layout'], function () { $user = auth()->user(); - if (! $user || session()->has(\App\Support\StaffUx::SESSION_KEY)) { + if (! $user) { return; } - try { - $access = app(\App\Services\Identity\IdentityTeamClient::class) - ->appAccess($user->ownerRef()); - \App\Support\StaffUx::remember($access); - } catch (\Throwable) { - // Leave fail-open defaults until Identity is available. + $member = app(\App\Services\Care\OrganizationResolver::class)->memberFor($user); + $permissions = app(\App\Services\Care\CarePermissions::class); + + if (! session()->has(\App\Support\StaffUx::SESSION_KEY)) { + try { + $access = app(\App\Services\Identity\IdentityTeamClient::class) + ->appAccess($user->ownerRef()); + \App\Support\StaffUx::remember( + $permissions->filterStaffAppAccess($member, $access) + ); + } catch (\Throwable) { + // Leave fail-open defaults until Identity is available. + } + + return; + } + + // Stale Identity grants may still list POS — strip for Care cashiers. + $cached = session(\App\Support\StaffUx::SESSION_KEY); + if (! is_array($cached) || ! empty($cached['full_access'])) { + return; + } + + $filtered = $permissions->filterStaffAppAccess($member, [ + 'full_access' => false, + 'apps' => array_values(array_map('strval', (array) ($cached['apps'] ?? []))), + 'show_hub' => (bool) ($cached['show_hub'] ?? false), + 'show_billing' => (bool) ($cached['show_billing'] ?? false), + ]); + + if ($filtered['apps'] !== array_values(array_map('strval', (array) ($cached['apps'] ?? []))) + || (bool) ($filtered['show_hub'] ?? false) !== (bool) ($cached['show_hub'] ?? false)) { + \App\Support\StaffUx::remember($filtered); } }); } diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index f911f55..930cd4f 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -47,6 +47,8 @@ class CarePermissions 'pharmacy.view', 'pharmacy.manage', 'service_queues.console', ], + // Cashiers run the Billing desk (bills / payments). They do not get + // Ladill POS app access — see filterAllowedAppSlugs() / DemoWorld. 'cashier' => [ 'dashboard.view', 'patients.view', 'bills.view', 'bills.manage', 'payments.manage', 'service_queues.console', @@ -120,4 +122,47 @@ class CarePermissions { return $member !== null && $member->role === 'doctor'; } + + /** + * Care cashiers collect via Billing, not Ladill POS. Strip POS from + * Identity app grants so the suite launcher hides it. + * + * @param list|null $slugs Null = unrestricted owner access. + * @return list|null + */ + public function filterAllowedAppSlugs(?Member $member, ?array $slugs): ?array + { + if ($slugs === null || $member?->role !== 'cashier') { + return $slugs; + } + + return array_values(array_filter( + array_map('strval', $slugs), + fn (string $slug) => $slug !== 'pos', + )); + } + + /** + * @param array{full_access?: bool, apps?: list, show_hub?: bool, show_billing?: bool} $access + * @return array{full_access?: bool, apps?: list, show_hub?: bool, show_billing?: bool} + */ + public function filterStaffAppAccess(?Member $member, array $access): array + { + if ($member?->role !== 'cashier') { + return $access; + } + + $apps = $this->filterAllowedAppSlugs($member, array_values(array_map( + 'strval', + (array) ($access['apps'] ?? []), + ))) ?? []; + + $access['apps'] = $apps; + + if (empty($access['full_access']) && count($apps) <= 1) { + $access['show_hub'] = false; + } + + return $access; + } } diff --git a/app/Support/DemoWorld.php b/app/Support/DemoWorld.php index 40ccc29..d41833d 100644 --- a/app/Support/DemoWorld.php +++ b/app/Support/DemoWorld.php @@ -286,9 +286,18 @@ final class DemoWorld 'key' => 'cashier', 'email' => 'demo-pro-cashier@ladill.com', 'name' => 'Kojo Cashier (Pro)', - 'apps' => ['care', 'pos'], + // Care cashiers collect via Billing — not Ladill POS. + 'apps' => ['care'], 'roles' => [ 'care' => 'cashier', + ], + ], + [ + 'key' => 'pos_cashier', + 'email' => 'demo-pro-pos-cashier@ladill.com', + 'name' => 'Kofi POS Cashier (Pro)', + 'apps' => ['pos'], + 'roles' => [ 'pos' => 'cashier', ], ], @@ -365,9 +374,18 @@ final class DemoWorld 'key' => 'cashier', 'email' => 'demo-enterprise-cashier@ladill.com', 'name' => 'Kojo Cashier', - 'apps' => ['care', 'pos'], + // Care cashiers collect via Billing — not Ladill POS. + 'apps' => ['care'], 'roles' => [ 'care' => 'cashier', + ], + ], + [ + 'key' => 'pos_cashier', + 'email' => 'demo-enterprise-pos-cashier@ladill.com', + 'name' => 'Kofi POS Cashier', + 'apps' => ['pos'], + 'roles' => [ 'pos' => 'cashier', ], ], diff --git a/tests/Feature/CareWebTest.php b/tests/Feature/CareWebTest.php index eaa61ec..ef23c6d 100644 --- a/tests/Feature/CareWebTest.php +++ b/tests/Feature/CareWebTest.php @@ -193,6 +193,68 @@ class CareWebTest extends TestCase ->assertDontSee('Patients in queue'); } + public function test_cashier_pos_app_access_is_stripped_from_staff_ux(): void + { + Member::where('user_ref', $this->user->public_id)->update(['role' => 'cashier']); + + $member = Member::where('user_ref', $this->user->public_id)->firstOrFail(); + $permissions = app(\App\Services\Care\CarePermissions::class); + + $this->assertSame( + ['care'], + $permissions->filterAllowedAppSlugs($member, ['care', 'pos']), + ); + + $filtered = $permissions->filterStaffAppAccess($member, [ + 'full_access' => false, + 'apps' => ['care', 'pos'], + 'show_hub' => true, + 'show_billing' => false, + ]); + + $this->assertSame(['care'], $filtered['apps']); + $this->assertFalse($filtered['show_hub']); + + foreach (['pro', 'enterprise'] as $tier) { + $cashier = collect(\App\Support\DemoWorld::staff($tier))->firstWhere('key', 'cashier'); + $this->assertNotNull($cashier); + $this->assertSame(['care'], $cashier['apps']); + $this->assertArrayNotHasKey('pos', $cashier['roles']); + $this->assertSame('cashier', $cashier['roles']['care'] ?? null); + } + } + + public function test_cashier_dashboard_hides_pos_launcher_and_keeps_billing(): void + { + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ]), + ]); + + Member::where('user_ref', $this->user->public_id)->update([ + 'role' => 'cashier', + 'branch_id' => Branch::firstOrFail()->id, + ]); + + // Simulate a stale Identity grant that still includes POS. + \App\Support\StaffUx::remember([ + 'full_access' => false, + 'apps' => ['care', 'pos'], + 'show_hub' => true, + 'show_billing' => false, + ]); + + $this->actingAs($this->user) + ->get(route('care.dashboard')) + ->assertOk() + ->assertSee('Billing') + ->assertSee(route('care.bills.index'), false) + ->assertDontSee('https://pos.', false) + ->assertDontSee('>POS<', false); + } + public function test_onboarding_creates_organization(): void { Organization::query()->delete();