diff --git a/.cursor/skills/abshode-domain-lookup/learned-notes.md b/.cursor/skills/abshode-domain-lookup/learned-notes.md index f620549..3ec0aa6 100644 --- a/.cursor/skills/abshode-domain-lookup/learned-notes.md +++ b/.cursor/skills/abshode-domain-lookup/learned-notes.md @@ -18,3 +18,4 @@ - 2026-09-16 | تعداد کد هدیه | `GiftCardController::redeem` | count=N → یک رکورد giftcards با count=N + N ردیف giftcard_codes؛ مثل storeRedeem - 2026-09-12 | انقضای کارت هدیه | `GiftCardController::validateGiftExpiryDate` + `GiftCardEditorFormBody` | تاریخ قبل از امروز → ۴۰۰ / خطای فرم؛ امروز مجاز - 2026-09-09 | رمز ادمین ≠ لاگین اپ | `Customer::setPasswordAttribute` + `AuthController` + `updateAdmin` | قبل از هش/لاگین ارقام فارسی→انگلیسی؛ jwt hash=false؛ آنلاین فقط password غیرخالی بفرست +- 2026-10-20 | دسترسی صفحات مدیر | `users.page_access` + `AdminPageAccess` + ادمین `adminPageAccess.ts` / ویرایش مدیران | null=کامل؛ درخت مشاهده/ویرایش/حذف؛ ورود=اولین صفحه مجاز؛ منوی ممنوع=«شما به این صفحه دسترسی ندارید» diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index b230268..ee5e6a1 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -8,7 +8,9 @@ use App\Models\Permission; use App\Models\User; use Illuminate\Http\Request; use App\Models\Setting; +use App\Services\AdminPageAccess; use App\Services\SmsBilling\SmsWalletService; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; class UserController extends SuperController { @@ -25,6 +27,41 @@ class UserController extends SuperController } + public function store(Request $request) + { + $this->applyPageAccessInput($request, true); + return parent::store($request); + } + + public function update(Request $request, $id) + { + $this->applyPageAccessInput($request, false); + return parent::update($request, $id); + } + + private function applyPageAccessInput(Request $request, bool $creating): void + { + $actor = auth('user')->user(); + if (!$actor instanceof User || !AdminPageAccess::hasFullAccess($actor)) { + if ($creating && $actor instanceof User) { + $request->merge(['page_access' => $actor->page_access]); + } else { + $request->replace($request->except('page_access')); + } + return; + } + + if (!$request->exists('page_access')) { + return; + } + + $normalized = AdminPageAccess::normalize($request->input('page_access')); + if (is_array($normalized) && !AdminPageAccess::hasAnyView($normalized)) { + throw new UnprocessableEntityHttpException('حداقل مشاهدهٔ یک صفحه را انتخاب کنید'); + } + $request->merge(['page_access' => $normalized]); + } + public function me(){ $user = User::with($this->with) ->select($this->select) diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index d48b9df..beb4b5c 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -71,5 +71,6 @@ class Kernel extends HttpKernel 'sms.panel.super' => \App\Http\Middleware\EnsureSmsPanelSuperAdmin::class, 'customer.active' => \App\Http\Middleware\EnsureCustomerIsActive::class, 'superadmin.remote' => \App\Http\Middleware\EnsureSuperAdminRemoteToken::class, + 'admin.page' => \App\Http\Middleware\EnsureAdminPageAccess::class, ]; } diff --git a/app/Http/Middleware/EnsureAdminPageAccess.php b/app/Http/Middleware/EnsureAdminPageAccess.php new file mode 100644 index 0000000..2b798ae --- /dev/null +++ b/app/Http/Middleware/EnsureAdminPageAccess.php @@ -0,0 +1,54 @@ +user(); + if (!$user || AdminPageAccess::hasFullAccess($user)) { + return $next($request); + } + + $path = strtolower(trim($request->path(), '/')); + if (str_starts_with($path, 'admin/')) { + $path = substr($path, strlen('admin/')); + } + if ($path === 'me' || $path === 'me/update') { + return $next($request); + } + + $method = strtoupper($request->method()); + $action = null; + if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) { + if (preg_match('#/active(/|$)#', $path)) { + $action = 'edit'; + } + } elseif ($method === 'DELETE') { + $action = 'delete'; + } elseif (in_array($method, ['PUT', 'PATCH'], true)) { + $action = 'edit'; + } elseif ($method === 'POST' && !AdminPageAccess::isReadPost($path)) { + $action = 'edit'; + } + + if ($action === null) { + return $next($request); + } + + $page = (string) $request->header('X-Admin-Page', ''); + if ($page === '' || !AdminPageAccess::allows($user, $page, $action)) { + $message = $action === 'delete' + ? 'شما مجوز حذف در این صفحه را ندارید' + : 'شما مجوز ویرایش در این صفحه را ندارید'; + return response()->json(['message' => $message], 403); + } + + return $next($request); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 9a2c5dc..c5a8b48 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -35,7 +35,8 @@ class User extends Authenticatable implements JWTSubject 'send_sms', 'avatar_id', 'email', - 'sts' + 'sts', + 'page_access', ]; /** @@ -56,6 +57,7 @@ class User extends Authenticatable implements JWTSubject protected $casts = [ 'active'=> 'boolean', 'send_sms'=> 'boolean', + 'page_access' => 'array', ]; /** * Get the identifier that will be stored in the subject claim of the JWT. diff --git a/app/Services/AdminPageAccess.php b/app/Services/AdminPageAccess.php new file mode 100644 index 0000000..d0e2d15 --- /dev/null +++ b/app/Services/AdminPageAccess.php @@ -0,0 +1,250 @@ + 'dashboardMain', + 'main' => 'dashboardMain', + 'orders' => 'ordersMain', + 'transactions' => 'transactionsMain', + 'support' => 'supportMain', + 'factor' => 'withdrawMain', + ]; + + public static function hasFullAccess(?User $user): bool + { + if (!$user) { + return false; + } + if (self::isDeployAdmin($user)) { + return true; + } + $access = $user->page_access; + return $access === null; + } + + public static function isDeployAdmin(?User $user): bool + { + if (!$user) { + return false; + } + return self::normalizeMobile($user->mobile ?? '') === self::DEPLOY_ADMIN_MOBILE_CORE; + } + + /** + * null = دسترسی کامل. آرایه = فقط کلیدهای شناخته‌شده. + */ + public static function normalize($value): ?array + { + if ($value === null || $value === '' || $value === false) { + return null; + } + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : null; + if ($value === null) { + return null; + } + } + if (!is_array($value)) { + return null; + } + + $out = []; + foreach (self::LEAF_KEYS as $key) { + $row = $value[$key] ?? null; + if (!is_array($row)) { + continue; + } + $view = self::toBool($row['view'] ?? false); + $edit = self::toBool($row['edit'] ?? false); + $delete = self::toBool($row['delete'] ?? false); + if ($edit || $delete) { + $view = true; + } + if (!$view) { + $edit = false; + $delete = false; + } + if (!$view && !$edit && !$delete) { + continue; + } + $out[$key] = [ + 'view' => $view, + 'edit' => $edit, + 'delete' => $delete, + ]; + } + + return $out; + } + + public static function hasAnyView(?array $access): bool + { + if ($access === null) { + return true; + } + foreach ($access as $row) { + if (!empty($row['view'])) { + return true; + } + } + return false; + } + + public static function allows(?User $user, string $routeName, string $action): bool + { + if (self::hasFullAccess($user)) { + return true; + } + $leaf = self::ALIASES[$routeName] ?? $routeName; + if (!in_array($leaf, self::LEAF_KEYS, true)) { + return false; + } + $access = $user->page_access; + if (!is_array($access)) { + return false; + } + $row = $access[$leaf] ?? null; + if (!is_array($row)) { + return false; + } + if ($action === 'delete') { + return !empty($row['delete']); + } + if ($action === 'edit') { + return !empty($row['edit']); + } + return !empty($row['view']); + } + + public static function isReadPost(string $path): bool + { + $path = strtolower(trim($path, '/')); + if (str_contains($path, '/table') || substr($path, -5) === 'table') { + return true; + } + if (str_contains($path, 'preview')) { + return true; + } + if (str_contains($path, 'get_customer_list') || str_contains($path, 'products-by-categories')) { + return true; + } + if (str_contains($path, 'mande-by-codes')) { + return true; + } + $exact = [ + 'getbalance', + 'prices', + 'prices/chart', + 'gateway', + 'finance', + 'check_card', + 'dashboard', + 'dashboard/finance', + 'dashboard/customer/all_sell', + 'dashboard/customer/single/all_sell', + 'dashboard/customer/flow', + 'dashboard/customer/order-peak-hours', + 'dashboard/profit-conversions', + 'dashboard/montzar-reconcile', + ]; + if (in_array($path, $exact, true)) { + return true; + } + if (preg_match('#^customers/\d+/(referrals|funds|piggy-funds)$#', $path)) { + return true; + } + if (preg_match('#^customers/\d+/assets/\d+/(piggy-funds|transactions)$#', $path)) { + return true; + } + if (preg_match('#^customers/\d+/piggy-funds/\d+/transactions$#', $path)) { + return true; + } + if (preg_match('#^customers/\d+/gold_credit/installments$#', $path)) { + return true; + } + if (preg_match('#^chat/(focus|unfocus|ensure-webhook)$#', $path)) { + return true; + } + return false; + } + + private static function toBool($value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int) $value === 1; + } + $text = strtolower(trim((string) $value)); + return in_array($text, ['1', 'true', 'yes', 'on'], true); + } + + private static function normalizeMobile(?string $mobile): string + { + $digits = preg_replace('/\D+/', '', (string) $mobile) ?? ''; + if (str_starts_with($digits, '98') && strlen($digits) >= 12) { + $digits = substr($digits, 2); + } + if (strlen($digits) === 11 && str_starts_with($digits, '0')) { + $digits = substr($digits, 1); + } + return $digits; + } +} diff --git a/database/migrations/2026_10_20_000001_add_page_access_to_users.php b/database/migrations/2026_10_20_000001_add_page_access_to_users.php new file mode 100644 index 0000000..013c8af --- /dev/null +++ b/database/migrations/2026_10_20_000001_add_page_access_to_users.php @@ -0,0 +1,30 @@ +json('page_access')->nullable()->comment('null=دسترسی کامل؛ در غیر این صورت مشاهده/ویرایش/حذف هر صفحه'); + }); + } + + public function down(): void + { + if (!Schema::hasTable('users') || !Schema::hasColumn('users', 'page_access')) { + return; + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('page_access'); + }); + } +}; diff --git a/routes/admin.php b/routes/admin.php index 6bbdab7..156318f 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -32,7 +32,7 @@ use App\Http\Controllers\Admin\SmsPanelController; use App\Services\ConfigService; -Route::middleware('auth:user')->group(function (){ +Route::middleware(['auth:user', 'admin.page'])->group(function (){ Route::middleware('validation')->group(function (){ Route::get('/p', [PriceController::class, 'methodPrices']) ->withoutMiddleware(['throttle:api']);