.
This commit is contained in:
@@ -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=کامل؛ درخت مشاهده/ویرایش/حذف؛ ورود=اولین صفحه مجاز؛ منوی ممنوع=«شما به این صفحه دسترسی ندارید»
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\AdminPageAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EnsureAdminPageAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$user = auth('user')->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);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class AdminPageAccess
|
||||
{
|
||||
public const DEPLOY_ADMIN_MOBILE_CORE = '9128081392';
|
||||
|
||||
/** کلید برگها؛ با ADMIN_PAGE_TREE در ادمین یکی بماند. */
|
||||
public const LEAF_KEYS = [
|
||||
'dashboardMain',
|
||||
'customerMain_limitedOrder',
|
||||
'customerMain_archive',
|
||||
'customerMain_online',
|
||||
'customerMain_specialPrice',
|
||||
'customerMain_goldCredit',
|
||||
'productsMain_limitedOrder',
|
||||
'productsMain_archive',
|
||||
'productsMain_categories',
|
||||
'ordersMain',
|
||||
'transactionsMain',
|
||||
'accountsMain',
|
||||
'walletMain',
|
||||
'physicalWithdrawMain',
|
||||
'withdrawMain',
|
||||
'pricingMain',
|
||||
'priceTriggers',
|
||||
'conditionalOrders',
|
||||
'colleagueOrdersMain',
|
||||
'accountingMain',
|
||||
'supportMain',
|
||||
'websiteLandingSite',
|
||||
'websiteLandingHeader',
|
||||
'websiteLandingHero',
|
||||
'websiteLandingLivePrice',
|
||||
'websiteLandingCalculator',
|
||||
'websiteLandingFeature',
|
||||
'websiteLandingSteps',
|
||||
'websiteLandingTrust',
|
||||
'websiteLandingFaq',
|
||||
'websiteLandingBottomCta',
|
||||
'websiteLandingFooter',
|
||||
'configuration',
|
||||
'captureSitesMain',
|
||||
'advancedConfiguration',
|
||||
'smsPanelMain',
|
||||
'tradingHours',
|
||||
'banners',
|
||||
'appNotifications',
|
||||
'automations',
|
||||
'smsMain',
|
||||
'giftCardMain',
|
||||
'discounts',
|
||||
'usersMain',
|
||||
];
|
||||
|
||||
private const ALIASES = [
|
||||
'dashboard' => '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('users') || Schema::hasColumn('users', 'page_access')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user