Commit 48a79a76 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(branches): make "all branches" a state the app can actually hold

Session::has() is `! is_null(get($key))`, so it reports false for a key
holding null — which is exactly how "all branches" was stored. Three
call sites tested presence that way, so selecting كل الفروع silently
reverted to a single branch on the next navigation and isAllBranches()
was unreachable dead code. All three now use exists().

BranchContext is the one place that reads that state. It lives in
Context, not Services, because the project rule keeps services free of
session/auth so they stay queue-safe; this is the adapter that turns
request state into the explicit ?int $branchId services receive. A null
left by a user whose permission was revoked is repaired rather than
honoured, and stamping deliberately does not follow branchId() — API
routes and queued listeners run outside the request, and a record filed
against no branch would vanish from every per-branch total for good.

The lock itself is dormant on purpose: isLocked() returns false while
the executive dashboard route does not exist, since locking would
otherwise 500 every page including its own redirect target. The
permission ships as a migration as well as a seeder entry, because
db:seed only runs when RUN_SEED_ON_FIRST_DEPLOY is set.

Also stops enabling the query log outside debug — it retained every
statement of every request in production memory for nothing.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent b6fd3fb7
...@@ -25,3 +25,4 @@ _ide_helper.php ...@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
/backups
<?php
namespace App\Domain\Shared\Context;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Services\PermissionService;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* The branch the current request is working in.
*
* This lives in Context rather than Services on purpose. Domain services must
* stay callable from queues and console commands, so they may not touch
* session/auth and instead receive an explicit ?int $branchId. This class is
* the adapter that produces that value — the branch counterpart to the
* `current_academy` instance SetCurrentAcademy binds. It is bound scoped, so
* one instance serves one request (and one Livewire round-trip).
*
* Session state has three meanings, and they must not be confused:
*
* key absent -> not yet resolved; fall back to the user's own branch
* key present, null -> "all branches" (only meaningful for multi-branch users)
* key present, int -> that branch
*
* Everything here tests presence with session()->exists(). Session::has() is
* `! is_null(get($key))`, so it reports false for a key holding null — which is
* exactly how "all branches" is stored. Using has() is what made that mode
* impossible to stay in.
*/
class BranchContext
{
public const KEY = 'active_branch_id';
private ?bool $canViewAll = null;
private ?Collection $activeBranches = null;
public function __construct(private PermissionService $permissions)
{
}
// ---------------------------------------------------------------- state
public function isResolved(): bool
{
return app()->bound('session') && session()->exists(self::KEY);
}
/**
* True only when the user has deliberately chosen to see every branch AND
* is entitled to. A stale null left by a since-downgraded user is repaired
* by branchId() rather than honoured.
*/
public function isAllBranches(): bool
{
return $this->isResolved()
&& session(self::KEY) === null
&& $this->canViewAllBranches();
}
/**
* The branch to filter queries by. Null means "do not filter".
*/
public function branchId(): ?int
{
if (! $this->isResolved()) {
return $this->resolveForUser(auth()->user());
}
$value = session(self::KEY);
if ($value === null) {
// Entitled: genuine all-branches. Not entitled: a stale null from a
// revoked permission or an academy that dropped to one branch —
// repair it rather than stranding them with an empty app.
return $this->canViewAllBranches()
? null
: $this->resolveForUser(auth()->user());
}
return (int) $value;
}
/**
* For operations that genuinely cannot run across branches — opening a cash
* session, taking a payment at a desk. Aborts rather than silently picking
* one, which would quietly unlock a locked user into an arbitrary branch.
*/
public function requireBranchId(): int
{
abort_if(
$this->isAllBranches(),
403,
__('هذه الصفحة تحتاج فرعاً محدداً. اختر فرعاً من القائمة العلوية.')
);
$id = $this->branchId();
if (! $id) {
$branch = $this->activeBranches()->first();
if (! $branch) {
abort(403, __('لا يوجد فرع نشط. يرجى إنشاء فرع أولاً.'));
}
$id = (int) $branch->id;
$this->set($id);
}
return $id;
}
// ---------------------------------------------------------- entitlement
/**
* A single-branch academy is never locked and never gets the cross-branch
* view: comparing one branch to itself is noise, and locking its owner out
* of their own sidebar would be a pure regression.
*/
public function canViewAllBranches(?User $user = null): bool
{
// The common case — the logged-in user — is asked on every sidebar item
// and every middleware pass, so memoise it for the request.
if ($user === null) {
return $this->canViewAll ??= $this->computeCanViewAll(auth()->user());
}
return $this->computeCanViewAll($user);
}
private function computeCanViewAll(?User $user): bool
{
if (! $user) {
return false;
}
return $this->activeBranches()->count() >= 2
&& $this->permissions->can($user, config('branch_lock.permission'));
}
/**
* Viewing every branch, and therefore restricted to the cross-branch pages.
*/
public function isLocked(): bool
{
// Fail safe: the lock redirects everything to the executive dashboard,
// so until that route exists locking would 500 every page including the
// redirect target. Keeps this dormant rather than dangerous while the
// feature is only partly built.
if (! \Illuminate\Support\Facades\Route::has(config('branch_lock.redirect_route'))) {
return false;
}
return $this->isAllBranches();
}
public function routeIsUnlocked(?string $routeName): bool
{
if (! $routeName) {
return false;
}
return Str::is(config('branch_lock.unlocked', []), $routeName);
}
// --------------------------------------------------------------- writes
public function set(?int $branchId): void
{
session([self::KEY => $branchId]);
$this->canViewAll = null;
}
public function clear(): void
{
session()->forget(self::KEY);
$this->canViewAll = null;
}
/**
* Seed the session for a user who has no branch resolved yet, and return
* the resulting branch id (null meaning all-branches).
*
* Order: their pinned branch, then their role's branch, then all-branches
* if entitled, then the sole/main active branch.
*/
public function resolveForUser(?User $user): ?int
{
if (! $user) {
return null;
}
$active = $this->activeBranches();
$pinned = $user->preferred_branch_id;
if ($pinned && $active->contains('id', $pinned)) {
$this->set((int) $pinned);
return (int) $pinned;
}
$pivot = $user->branch_id;
if ($pivot && $active->contains('id', $pivot)) {
$this->set((int) $pivot);
return (int) $pivot;
}
if ($this->canViewAllBranches($user)) {
// Persist the null so the next request reads "all branches" rather
// than falling through this chain again and pinning a branch.
$this->set(null);
return null;
}
$fallback = $active->firstWhere('is_main', true) ?? $active->first();
if (! $fallback) {
return null;
}
$this->set((int) $fallback->id);
return (int) $fallback->id;
}
// -------------------------------------------------------------- lookups
public function activeBranches(): Collection
{
return $this->activeBranches ??= Branch::query()
->where('is_active', true)
->orderByDesc('is_main')
->get(['id', 'name', 'name_ar', 'code', 'is_main']);
}
public function currentBranch(): ?Branch
{
$id = $this->branchId();
return $id ? $this->activeBranches()->firstWhere('id', $id) : null;
}
/**
* The branch to stamp onto a new record.
*
* Deliberately NOT branchId(). API routes sit outside the web middleware
* and queued listeners run outside the request entirely; if this returned
* null in all-branches mode, records created there would be filed against
* no branch and vanish from every per-branch total permanently.
*/
public function branchIdForStamping(): ?int
{
if (app()->runningInConsole()) {
return null;
}
if ($this->isResolved()) {
$value = session(self::KEY);
if ($value) {
return (int) $value;
}
}
return auth()->user()?->branch_id;
}
}
...@@ -39,21 +39,8 @@ public static function bootBelongsToBranch(): void ...@@ -39,21 +39,8 @@ public static function bootBelongsToBranch(): void
*/ */
public static function resolveActiveBranchId(): ?int public static function resolveActiveBranchId(): ?int
{ {
if (app()->runningInConsole()) { return app(\App\Domain\Shared\Context\BranchContext::class)
return null; ->branchIdForStamping();
}
if (app()->bound('session') && session()->has('active_branch_id')) {
$sessionBranch = session('active_branch_id');
// A null in the session is the deliberate "all branches" mode, so
// fall through to the user's own branch rather than storing null.
if ($sessionBranch) {
return (int) $sessionBranch;
}
}
return auth()->user()?->branch_id;
} }
public function branch(): BelongsTo public function branch(): BelongsTo
......
...@@ -2,40 +2,56 @@ ...@@ -2,40 +2,56 @@
namespace App\Domain\Shared\Traits; namespace App\Domain\Shared\Traits;
use App\Domain\Identity\Models\Branch; use App\Domain\Shared\Context\BranchContext;
/**
* Read the active branch inside a Livewire component.
*
* All three methods delegate to BranchContext, which owns the session-state
* rules. The signatures are unchanged, so the 80-odd components using this
* trait need no edits.
*/
trait UsesBranchScope trait UsesBranchScope
{ {
protected function branchContext(): BranchContext
{
return app(BranchContext::class);
}
/**
* The branch to filter by, or null for "every branch".
*
* Null is a real answer, not a missing one — pass it straight into the
* `->when($branchId, ...)` idiom used across the app.
*/
public function getActiveBranchId(): ?int public function getActiveBranchId(): ?int
{ {
if (!session()->has('active_branch_id')) { return $this->branchContext()->branchId();
return auth()->user()->branch_id; }
}
return session('active_branch_id'); /**
* Alias with a name that says what it is actually for.
*/
public function getBranchIdForQuery(): ?int
{
return $this->getActiveBranchId();
} }
/**
* For screens that cannot work across branches — a cash drawer, a till, a
* registration desk.
*
* This used to quietly write a branch into the session when none was set,
* which would unlock a user who was deliberately viewing every branch.
* It now refuses instead.
*/
public function getActiveBranchIdOrFail(): int public function getActiveBranchIdOrFail(): int
{ {
$id = $this->getActiveBranchId(); return $this->branchContext()->requireBranchId();
if (!$id) {
$branch = Branch::where('is_active', true)->first()
?? Branch::withoutGlobalScopes()->where('is_active', true)->first();
if ($branch) {
$id = $branch->id;
session(['active_branch_id' => $id]);
} else {
abort(403, 'لا يوجد فرع نشط. يرجى إنشاء فرع أولاً.');
}
}
return $id;
} }
public function isAllBranches(): bool public function isAllBranches(): bool
{ {
return session()->has('active_branch_id') && session('active_branch_id') === null; return $this->branchContext()->isAllBranches();
} }
} }
<?php
namespace App\Http\Middleware;
use App\Domain\Shared\Context\BranchContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Keep a user who is viewing every branch out of branch-scoped pages.
*
* Hiding sidebar items is cosmetic — the URLs stay reachable, and a bookmark
* would show un-scoped operational data. This closes that.
*
* It runs on the whole web group with an allow-list rather than being attached
* to ~175 individual routes: routes/web.php is one flat auth group, so
* per-route application would mean restructuring the file for an additive
* feature. The allow-list lives in config/branch_lock.php, shared with the
* sidebar so the two cannot drift.
*/
class RequireBranchSelection
{
public function __construct(private BranchContext $branch)
{
}
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()) {
return $next($request);
}
$redirectRoute = config('branch_lock.redirect_route');
// Loop guard: the destination must always be reachable, even if someone
// removes it from the allow-list by accident.
if ($request->routeIs($redirectRoute)) {
return $next($request);
}
// Not viewing all branches — single-branch users and anyone who has
// picked a branch pass straight through, unchanged.
if (! $this->branch->isLocked()) {
return $next($request);
}
if ($this->branch->routeIsUnlocked($request->route()?->getName())) {
return $next($request);
}
if ($request->expectsJson()) {
return response()->json([
'message' => __('اختر فرعاً أولاً للوصول إلى هذه البيانات.'),
], 403);
}
return redirect()
->route($redirectRoute)
->with('error', __('اختر فرعاً من القائمة العلوية للوصول إلى هذه الصفحة.'));
}
}
<?php
namespace App\Http\Middleware;
use App\Domain\Shared\Context\BranchContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Make sure every authenticated request knows which branch it is in.
*
* Seeding used to happen inside BranchSwitcher::mount(), which meant a getter
* with a write side effect that ran only on pages rendering the topbar — and
* which re-pinned a concrete branch over the "all branches" null on every
* render. Doing it here instead makes the switcher a pure reader.
*
* Runs after SetCurrentAcademy, which binds `current_academy`; BranchContext
* needs that to know which branches exist.
*/
class ResolveBranchContext
{
public function __construct(private BranchContext $branch)
{
}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user && ! $this->branch->isResolved()) {
$this->branch->resolveForUser($user);
}
return $next($request);
}
}
...@@ -62,7 +62,17 @@ public function login(AuthService $authService): void ...@@ -62,7 +62,17 @@ public function login(AuthService $authService): void
Auth::login($result->user, $this->remember); Auth::login($result->user, $this->remember);
session()->regenerate(); session()->regenerate();
$defaultRoute = app(LoginRedirectService::class)->getRedirectRoute($result->user); // Seed the branch for the new session here rather than in a service —
// LoginRedirectService must stay free of session/auth so it remains
// callable outside a request.
$branch = app(\App\Domain\Shared\Context\BranchContext::class);
$branch->clear();
$branch->resolveForUser($result->user);
$defaultRoute = $branch->isLocked()
? config('branch_lock.redirect_route')
: app(LoginRedirectService::class)->getRedirectRoute($result->user);
$this->redirectIntended(route($defaultRoute)); $this->redirectIntended(route($defaultRoute));
} }
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Domain\Identity\Models\Branch; use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Context\BranchContext;
use Livewire\Component; use Livewire\Component;
class BranchSwitcher extends Component class BranchSwitcher extends Component
...@@ -10,40 +11,56 @@ class BranchSwitcher extends Component ...@@ -10,40 +11,56 @@ class BranchSwitcher extends Component
public string $selectedBranch = 'all'; public string $selectedBranch = 'all';
public bool $isPinned = false; public bool $isPinned = false;
/** Whether to offer the "كل الفروع" option at all. */
public bool $canViewAll = false;
/**
* Purely a reader. Seeding the session is ResolveBranchContext's job now —
* doing it here meant this method overwrote the "all branches" null with a
* concrete branch on every render, so that mode could never be held.
*/
public function mount(): void public function mount(): void
{ {
$user = auth()->user(); $ctx = app(BranchContext::class);
$this->isPinned = $user->preferred_branch_id !== null;
if (session()->has('active_branch_id')) { $this->isPinned = auth()->user()->preferred_branch_id !== null;
$value = session('active_branch_id'); $this->canViewAll = $ctx->canViewAllBranches();
$this->selectedBranch = $value === null ? 'all' : (string) $value;
} else { $branchId = $ctx->branchId();
$branchId = $user->preferred_branch_id ?? $user->branch_id; $this->selectedBranch = $branchId === null ? 'all' : (string) $branchId;
if (!$branchId) {
$first = Branch::where('is_active', true)->first();
$branchId = $first?->id;
}
$this->selectedBranch = $branchId ? (string) $branchId : 'all';
session(['active_branch_id' => $branchId]);
}
} }
public function updatedSelectedBranch($value): void public function updatedSelectedBranch($value): void
{ {
$ctx = app(BranchContext::class);
$user = auth()->user();
if ($value === 'all') { if ($value === 'all') {
session(['active_branch_id' => null]); // Offered only to users who may actually see every branch; guard
auth()->user()->update(['preferred_branch_id' => null]); // anyway so a crafted request cannot strand someone in a mode the
// context would immediately repair.
if (! $ctx->canViewAllBranches()) {
return;
}
$ctx->set(null);
$user->update(['preferred_branch_id' => null]);
$this->isPinned = false; $this->isPinned = false;
$target = route(config('branch_lock.redirect_route'));
} else { } else {
$branchId = (int) $value; $branchId = (int) $value;
session(['active_branch_id' => $branchId]); $ctx->set($branchId);
auth()->user()->update(['preferred_branch_id' => $branchId]); $user->update(['preferred_branch_id' => $branchId]);
$this->isPinned = true; $this->isPinned = true;
$target = route('dashboard');
} }
$this->dispatch('branch-switched'); // Route on the new state rather than bouncing back via Referer — the
$this->redirect(request()->header('Referer', '/'), navigate: true); // page they came from may no longer be reachable in the mode they just
// entered.
$this->redirect($target, navigate: true);
} }
public function unpin(): void public function unpin(): void
...@@ -62,8 +79,12 @@ public function pin(): void ...@@ -62,8 +79,12 @@ public function pin(): void
public function render() public function render()
{ {
$ctx = app(BranchContext::class);
return view('livewire.branch-switcher', [ return view('livewire.branch-switcher', [
'branches' => Branch::where('is_active', true)->get(['id', 'name_ar', 'code']), // Already loaded and cached for this request by the context.
'branches' => $ctx->activeBranches(),
'isLocked' => $ctx->isLocked(),
]); ]);
} }
} }
...@@ -32,6 +32,13 @@ public function mount(): void ...@@ -32,6 +32,13 @@ public function mount(): void
$user = auth()->user(); $user = auth()->user();
$roleSlug = $user->primaryRole?->slug; $roleSlug = $user->primaryRole?->slug;
// Checked before the role map so someone viewing every branch is never
// dropped into a branch-scoped dashboard showing one branch's numbers.
if (app(\App\Domain\Shared\Context\BranchContext::class)->isLocked()) {
$this->redirect(route(config('branch_lock.redirect_route')));
return;
}
// Redirect non-management roles to their dedicated dashboards // Redirect non-management roles to their dedicated dashboards
$redirectMap = [ $redirectMap = [
'trainer' => 'trainer.dashboard', 'trainer' => 'trainer.dashboard',
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Domain\Identity\Services\PermissionService; use App\Domain\Identity\Services\PermissionService;
use App\Domain\Shared\Context\BranchContext;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
...@@ -18,7 +19,9 @@ class AppServiceProvider extends ServiceProvider ...@@ -18,7 +19,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// // One branch context per request — and per Livewire round-trip, since
// each of those is its own request.
$this->app->scoped(BranchContext::class);
} }
/** /**
...@@ -26,7 +29,11 @@ public function register(): void ...@@ -26,7 +29,11 @@ public function register(): void
*/ */
public function boot(): void public function boot(): void
{ {
DB::enableQueryLog(); // Only for the 500-error handler's diagnostics. Left on unconditionally
// it retains every statement of every request in memory for no benefit.
if (config('app.debug')) {
DB::enableQueryLog();
}
Gate::before(function ($user, $ability) { Gate::before(function ($user, $ability) {
return app(PermissionService::class)->can($user, $ability) ?: null; return app(PermissionService::class)->can($user, $ability) ?: null;
......
...@@ -20,8 +20,13 @@ ...@@ -20,8 +20,13 @@
$middleware->trustProxies(at: '*'); $middleware->trustProxies(at: '*');
$middleware->redirectGuestsTo('/login'); $middleware->redirectGuestsTo('/login');
$middleware->redirectUsersTo('/dashboard'); $middleware->redirectUsersTo('/dashboard');
// Order matters: SetCurrentAcademy binds `current_academy`, which
// ResolveBranchContext needs to know which branches exist, and
// RequireBranchSelection needs a resolved context to judge.
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\SetCurrentAcademy::class, \App\Http\Middleware\SetCurrentAcademy::class,
\App\Http\Middleware\ResolveBranchContext::class,
\App\Http\Middleware\RequireBranchSelection::class,
]); ]);
$middleware->api(append: [ $middleware->api(append: [
\App\Http\Middleware\SetCurrentAcademy::class, \App\Http\Middleware\SetCurrentAcademy::class,
...@@ -30,6 +35,7 @@ ...@@ -30,6 +35,7 @@
$middleware->alias([ $middleware->alias([
'permission' => \App\Http\Middleware\CheckPermission::class, 'permission' => \App\Http\Middleware\CheckPermission::class,
'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class, 'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
'branch' => \App\Http\Middleware\RequireBranchSelection::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
......
<?php
return [
/*
|--------------------------------------------------------------------------
| Permission
|--------------------------------------------------------------------------
|
| Holding this permission makes a user eligible for the cross-branch view.
| Eligibility also requires the academy to have at least two active
| branches — see BranchContext::canViewAllBranches().
|
*/
'permission' => 'branches.view_all',
/*
|--------------------------------------------------------------------------
| Redirect target
|--------------------------------------------------------------------------
|
| Where a locked user is sent when they reach for a branch-scoped page.
| Must itself appear in `unlocked` below, or the redirect loops.
|
*/
'redirect_route' => 'executive.dashboard',
/*
|--------------------------------------------------------------------------
| Routes reachable without a branch
|--------------------------------------------------------------------------
|
| Read by BOTH the sidebar and RequireBranchSelection, so menu visibility
| and route access cannot drift apart. Patterns are matched with Str::is().
|
| Everything operational is absent on purpose: it belongs to a branch, and
| showing it un-scoped would be showing the wrong numbers.
|
*/
'unlocked' => [
// The cross-branch view itself.
'executive.*',
// Reports already take an optional branch and handle "all" correctly.
'reports.*',
'export.*',
// Academy-level administration.
'branches.*',
'users.*',
'roles.*',
'audit.*',
'settings.*',
'setup.wizard',
'admin.*',
// Academy-level CMS — no branch semantics at all.
'website.manage.*',
// Landing route; it redirects onward silently rather than flashing an
// error at someone who merely typed /dashboard.
'dashboard',
// The topbar bell links here; a dead bell in locked mode reads as a bug.
'notifications.center',
// Always reachable.
'profile',
'profile.*',
'logout',
'health',
// Livewire's own update endpoint. Block this and every locked page is
// inert — no clicks, no dropdowns, not even the branch switcher.
'livewire.*',
],
];
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Add the `branches.view_all` permission and grant it to the roles that manage
* a whole academy.
*
* PermissionSeeder also lists this permission, but db:seed only runs when
* RUN_SEED_ON_FIRST_DEPLOY is true (docker/entrypoint.sh), so a client deployed
* outside the one-click template would never receive it. Migrations always run,
* so this guarantees delivery.
*
* Idempotent: every insert checks for an existing row first, so repeated
* container starts are harmless.
*/
return new class extends Migration
{
private const PERMISSION = 'branches.view_all';
private const ROLES = ['super_admin', 'academy_owner', 'academy_admin'];
public function up(): void
{
if (! Schema::hasTable('permissions') || ! Schema::hasTable('permission_role')) {
return;
}
$permissionId = DB::table('permissions')
->where('name', self::PERMISSION)
->value('id');
if (! $permissionId) {
$permissionId = DB::table('permissions')->insertGetId([
'name' => self::PERMISSION,
'module' => 'branches',
'action' => 'view_all',
'description' => 'View aggregated data across every branch',
'description_ar' => 'عرض بيانات كل الفروع مجتمعة',
'created_at' => now(),
]);
}
if (! Schema::hasTable('roles')) {
return;
}
// Roles are per-academy, so this covers every tenant in the database.
$roleIds = DB::table('roles')
->whereIn('slug', self::ROLES)
->pluck('id');
foreach ($roleIds as $roleId) {
$exists = DB::table('permission_role')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->exists();
if (! $exists) {
DB::table('permission_role')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
'scope' => 'all',
'created_at' => now(),
]);
}
}
}
public function down(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
$permissionId = DB::table('permissions')
->where('name', self::PERMISSION)
->value('id');
if (! $permissionId) {
return;
}
if (Schema::hasTable('permission_role')) {
DB::table('permission_role')->where('permission_id', $permissionId)->delete();
}
DB::table('permissions')->where('id', $permissionId)->delete();
}
};
...@@ -83,6 +83,8 @@ public static function getPermissionsList(): array ...@@ -83,6 +83,8 @@ public static function getPermissionsList(): array
// Organizations // Organizations
'academies.list', 'academies.show', 'academies.update', 'academies.list', 'academies.show', 'academies.update',
'branches.list', 'branches.create', 'branches.update', 'branches.delete', 'branches.list', 'branches.create', 'branches.update', 'branches.delete',
// Grants the cross-branch executive view, and with it the branch lock.
'branches.view_all',
// Identity & Users // Identity & Users
'users.list', 'users.create', 'users.update', 'users.delete', 'users.assign_roles', 'users.list', 'users.create', 'users.update', 'users.delete', 'users.assign_roles',
......
@php @php
$navigation = [ $navigation = [
['label' => 'لوحة التحكم', 'route' => 'dashboard', 'icon' => 'home', 'permission' => 'dashboard.view'], ['label' => 'الإدارة التنفيذية', 'route' => 'executive.dashboard', 'icon' => 'chart-bar', 'permission' => 'branches.view_all'],
// Route stays reachable so /dashboard redirects silently, but the link is
// hidden while locked — it would sit right beside the executive entry and
// lead to the same place.
['label' => 'لوحة التحكم', 'route' => 'dashboard', 'icon' => 'home', 'permission' => 'dashboard.view', 'requiresBranch' => true],
['label' => 'بوابة ولي الأمر', 'route' => 'guardian.dashboard', 'icon' => 'user-group', 'permission' => 'dashboard.view', 'role' => 'parent'], ['label' => 'بوابة ولي الأمر', 'route' => 'guardian.dashboard', 'icon' => 'user-group', 'permission' => 'dashboard.view', 'role' => 'parent'],
['label' => 'مكتب الاستقبال', 'route' => 'receptionist.dashboard', 'icon' => 'reception', 'permission' => 'participants.list'], ['label' => 'مكتب الاستقبال', 'route' => 'receptionist.dashboard', 'icon' => 'reception', 'permission' => 'participants.list'],
...@@ -128,13 +132,23 @@ ...@@ -128,13 +132,23 @@
$userRole = $currentUser->primaryRole?->slug ?? $currentUser->roles->first()?->slug; $userRole = $currentUser->primaryRole?->slug ?? $currentUser->roles->first()?->slug;
$itemVisible = function (array $item) use ($userCan, $userRole): bool { // While a user is viewing every branch, only the cross-branch pages make sense
// — everything else would show un-scoped operational data. The allow-list lives
// in config/branch_lock.php and is shared with RequireBranchSelection, so what
// is hidden and what is blocked can never drift apart.
$branchCtx = app(\App\Domain\Shared\Context\BranchContext::class);
$navLocked = $branchCtx->isLocked();
$itemVisible = function (array $item) use ($userCan, $userRole, $navLocked, $branchCtx): bool {
if (!Route::has($item['route']) || !$userCan($item['permission'])) { if (!Route::has($item['route']) || !$userCan($item['permission'])) {
return false; return false;
} }
if (isset($item['role']) && $userRole !== $item['role']) { if (isset($item['role']) && $userRole !== $item['role']) {
return false; return false;
} }
if ($navLocked && ($item['requiresBranch'] ?? !$branchCtx->routeIsUnlocked($item['route']))) {
return false;
}
return true; return true;
}; };
@endphp @endphp
......
...@@ -60,6 +60,13 @@ ...@@ -60,6 +60,13 @@
'plus' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.5v15m7.5-7.5h-15"/>', 'plus' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.5v15m7.5-7.5h-15"/>',
'minus' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14"/>', 'minus' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14"/>',
'no-symbol' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>', 'no-symbol' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>',
'lock-closed' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>',
// Referenced by $navigation and ReportsHub but previously absent, so those
// entries rendered label-only — the component emits nothing for an unknown
// name rather than falling back.
'plus-circle' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/>',
'squares-2x2' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 018.25 20.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/>',
'cog' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
]; ];
@endphp @endphp
......
...@@ -33,7 +33,9 @@ class="absolute top-full mt-2 end-0 w-64 bg-white rounded-xl shadow-xl border bo ...@@ -33,7 +33,9 @@ class="absolute top-full mt-2 end-0 w-64 bg-white rounded-xl shadow-xl border bo
{{-- Header --}} {{-- Header --}}
<div class="px-4 py-3 bg-gray-50 border-b border-gray-200"> <div class="px-4 py-3 bg-gray-50 border-b border-gray-200">
<p class="text-xs font-bold text-gray-500 uppercase tracking-wider">{{ __('اختر الفرع') }}</p> <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">{{ __('اختر الفرع') }}</p>
@if($isPinned) @if($isLocked)
<p class="text-[11px] text-amber-600 mt-0.5">{{ __('التطبيق مقفل على لوحة الإدارة حتى تختار فرعاً') }}</p>
@elseif($isPinned)
<p class="text-[11px] text-emerald-600 mt-0.5 flex items-center gap-1"> <p class="text-[11px] text-emerald-600 mt-0.5 flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg> <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
{{ __('مثبّت — يبقى حتى بعد تسجيل الخروج') }} {{ __('مثبّت — يبقى حتى بعد تسجيل الخروج') }}
...@@ -45,7 +47,10 @@ class="absolute top-full mt-2 end-0 w-64 bg-white rounded-xl shadow-xl border bo ...@@ -45,7 +47,10 @@ class="absolute top-full mt-2 end-0 w-64 bg-white rounded-xl shadow-xl border bo
{{-- Branch list --}} {{-- Branch list --}}
<div class="py-1"> <div class="py-1">
{{-- All branches option --}} {{-- All branches — only offered to users entitled to it, so a
single-branch user can't select a mode the context would
immediately repair. --}}
@if($canViewAll)
<button wire:click="$set('selectedBranch', 'all')" @click="open = false" <button wire:click="$set('selectedBranch', 'all')" @click="open = false"
class="w-full flex items-center gap-3 px-4 py-2.5 text-start text-sm hover:bg-gray-50 transition class="w-full flex items-center gap-3 px-4 py-2.5 text-start text-sm hover:bg-gray-50 transition
{{ $selectedBranch === 'all' ? 'bg-blue-50 text-blue-700 font-bold' : 'text-gray-700' }}"> {{ $selectedBranch === 'all' ? 'bg-blue-50 text-blue-700 font-bold' : 'text-gray-700' }}">
...@@ -60,6 +65,7 @@ class="w-full flex items-center gap-3 px-4 py-2.5 text-start text-sm hover:bg-gr ...@@ -60,6 +65,7 @@ class="w-full flex items-center gap-3 px-4 py-2.5 text-start text-sm hover:bg-gr
</button> </button>
<div class="border-t border-gray-100 my-1"></div> <div class="border-t border-gray-100 my-1"></div>
@endif
{{-- Individual branches --}} {{-- Individual branches --}}
@foreach($branches as $branch) @foreach($branches as $branch)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment