diff --git a/app/Console/Commands/SyncPermissions.php b/app/Console/Commands/SyncPermissions.php new file mode 100644 index 0000000..e6f444f --- /dev/null +++ b/app/Console/Commands/SyncPermissions.php @@ -0,0 +1,180 @@ +option('dry-run'); + + $this->info('Đang quét Filament Resources...'); + + $resourcesPath = app_path('Filament/Resources'); + if (! is_dir($resourcesPath)) { + $this->error('Không tìm thấy thư mục Filament/Resources'); + return self::FAILURE; + } + + $modules = []; + $finder = new Finder(); + $finder->files()->in($resourcesPath)->name('*Resource.php'); + + foreach ($finder as $file) { + $class = $this->getClassFromFile($file->getRealPath()); + if (! $class || ! class_exists($class)) { + continue; + } + + $ref = new ReflectionClass($class); + + if (! $ref->hasProperty('permissionActions')) { + continue; + } + + $prop = $ref->getProperty('permissionActions'); + $prop->setAccessible(true); + $actions = $prop->getDefaultValue(); + + $label = 'Resource'; + if ($ref->hasProperty('pluralModelLabel')) { + $labelProp = $ref->getProperty('pluralModelLabel'); + $labelProp->setAccessible(true); + $label = $labelProp->getDefaultValue() ?? $label; + } + + $moduleName = Str::snake(class_basename($class)); + $moduleName = str_replace('_resource', '', $moduleName); + + $modules[] = [ + 'module' => $moduleName, + 'label' => $label, + 'actions' => $actions ?? [], + ]; + } + + if (empty($modules)) { + $this->warn('Không tìm thấy Resource nào có $permissionActions.'); + return self::SUCCESS; + } + + $this->info('Tìm thấy ' . count($modules) . ' module(s):'); + + foreach ($modules as $m) { + $this->line(" - {$m['module']}: " . implode(', ', $m['actions'])); + } + + if ($dryRun) { + $this->info('Dry-run: Không lưu thay đổi.'); + return self::SUCCESS; + } + + foreach ($modules as $m) { + $existing = PermissionModule::where('module', $m['module'])->first(); + + if ($existing) { + $oldActions = $existing->actions ?? []; + $newActions = $m['actions']; + + if ($oldActions === $newActions) { + continue; + } + + $added = array_diff($newActions, $oldActions); + $removed = array_diff($oldActions, $newActions); + + $existing->update([ + 'label' => $m['label'], + 'actions' => $newActions, + ]); + + if (! empty($added)) { + $this->warn(" [{$m['module']}] Thêm actions: " . implode(', ', $added)); + // Action mới mặc định TẮT cho tất cả role + $this->disableNewActionsForAllRoles($m['module'], $added); + } + if (! empty($removed)) { + $this->warn(" [{$m['module']}] Xóa actions: " . implode(', ', $removed)); + $this->removeActionsFromAllRoles($m['module'], $removed); + } + } else { + PermissionModule::create([ + 'module' => $m['module'], + 'label' => $m['label'], + 'actions' => $m['actions'], + ]); + $this->info(" [{$m['module']}] Tạo mới module."); + // Module mới mặc định TẮT cho tất cả role + $this->disableNewActionsForAllRoles($m['module'], $m['actions']); + } + } + + $this->info('Đồng bộ hoàn tất.'); + return self::SUCCESS; + } + + private function getClassFromFile(string $path): ?string + { + $contents = file_get_contents($path); + + // Tìm namespace + $namespace = null; + if (preg_match('/namespace\s+([^;]+);/', $contents, $matches)) { + $namespace = $matches[1]; + } + + // Tìm class name + $class = null; + if (preg_match('/class\s+(\w+)/', $contents, $matches)) { + $class = $matches[1]; + } + + if ($namespace && $class) { + return $namespace . '\\' . $class; + } + + return null; + } + + private function disableNewActionsForAllRoles(string $module, array $actions): void + { + $roles = RoleTemplate::all(); + foreach ($roles as $role) { + $perms = $role->permissions ?? []; + foreach ($actions as $action) { + if (isset($perms[$module]) && in_array($action, $perms[$module])) { + continue; // Đã có thì giữ nguyên + } + // Không thêm vào = mặc định TẮT + } + // Không cần update vì JSONB không lưu action mới = TẮT + } + } + + private function removeActionsFromAllRoles(string $module, array $actions): void + { + $roles = RoleTemplate::all(); + foreach ($roles as $role) { + $perms = $role->permissions ?? []; + if (isset($perms[$module])) { + $perms[$module] = array_values(array_diff($perms[$module], $actions)); + if (empty($perms[$module])) { + unset($perms[$module]); + } + $role->update(['permissions' => $perms]); + } + } + } +} diff --git a/app/Filament/Resources/Appendices/AppendixResource.php b/app/Filament/Resources/Appendices/AppendixResource.php index 1a09356..b465df7 100644 --- a/app/Filament/Resources/Appendices/AppendixResource.php +++ b/app/Filament/Resources/Appendices/AppendixResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\Appendices\Tables\AppendicesTable; class AppendixResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = Appendix::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; diff --git a/app/Filament/Resources/Contracts/ContractResource.php b/app/Filament/Resources/Contracts/ContractResource.php index f8773d9..2c0e997 100644 --- a/app/Filament/Resources/Contracts/ContractResource.php +++ b/app/Filament/Resources/Contracts/ContractResource.php @@ -14,6 +14,7 @@ use App\Filament\Resources\Contracts\Tables\ContractsTable; class ContractResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = Contract::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; diff --git a/app/Filament/Resources/Customers/CustomerResource.php b/app/Filament/Resources/Customers/CustomerResource.php index f5c0a1f..4abbf58 100644 --- a/app/Filament/Resources/Customers/CustomerResource.php +++ b/app/Filament/Resources/Customers/CustomerResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\Customers\Tables\CustomersTable; class CustomerResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = Customer::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-users'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value; diff --git a/app/Filament/Resources/FormTemplates/FormTemplateResource.php b/app/Filament/Resources/FormTemplates/FormTemplateResource.php index 0426b0a..d9e887b 100644 --- a/app/Filament/Resources/FormTemplates/FormTemplateResource.php +++ b/app/Filament/Resources/FormTemplates/FormTemplateResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\FormTemplates\Tables\FormTemplatesTable; class FormTemplateResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = FormTemplate::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-duplicate'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value; diff --git a/app/Filament/Resources/PaymentFines/PaymentFineResource.php b/app/Filament/Resources/PaymentFines/PaymentFineResource.php index e38c013..800d423 100644 --- a/app/Filament/Resources/PaymentFines/PaymentFineResource.php +++ b/app/Filament/Resources/PaymentFines/PaymentFineResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\PaymentFines\Tables\PaymentFinesTable; class PaymentFineResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = PaymentFine::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; diff --git a/app/Filament/Resources/Payments/PaymentResource.php b/app/Filament/Resources/Payments/PaymentResource.php index 05174ed..5b4a8c9 100644 --- a/app/Filament/Resources/Payments/PaymentResource.php +++ b/app/Filament/Resources/Payments/PaymentResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\Payments\Tables\PaymentsTable; class PaymentResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = Payment::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; diff --git a/app/Filament/Resources/Products/ProductResource.php b/app/Filament/Resources/Products/ProductResource.php index 4558a97..5c1e356 100644 --- a/app/Filament/Resources/Products/ProductResource.php +++ b/app/Filament/Resources/Products/ProductResource.php @@ -14,6 +14,7 @@ use App\Filament\Resources\Products\Schemas\ProductForm; class ProductResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = Product::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value; diff --git a/app/Filament/Resources/Projects/ProjectResource.php b/app/Filament/Resources/Projects/ProjectResource.php index fa2c1d9..c5e3628 100644 --- a/app/Filament/Resources/Projects/ProjectResource.php +++ b/app/Filament/Resources/Projects/ProjectResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\Projects\Schemas\ProjectForm; class ProjectResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = Project::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::PROJECT->value; diff --git a/app/Filament/Resources/SalesPhases/SalesPhaseResource.php b/app/Filament/Resources/SalesPhases/SalesPhaseResource.php index 6be92a3..b4891d1 100644 --- a/app/Filament/Resources/SalesPhases/SalesPhaseResource.php +++ b/app/Filament/Resources/SalesPhases/SalesPhaseResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\SalesPhases\Tables\SalesPhasesTable; class SalesPhaseResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = SalesPhase::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rocket-launch'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value; diff --git a/app/Filament/Resources/Settlements/SettlementResource.php b/app/Filament/Resources/Settlements/SettlementResource.php index 1feb8dc..f119845 100644 --- a/app/Filament/Resources/Settlements/SettlementResource.php +++ b/app/Filament/Resources/Settlements/SettlementResource.php @@ -13,6 +13,7 @@ use App\Filament\Resources\Settlements\Tables\SettlementsTable; class SettlementResource extends Resource { + protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"]; protected static ?string $model = Settlement::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check'; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; diff --git a/app/Models/PermissionModule.php b/app/Models/PermissionModule.php new file mode 100644 index 0000000..32328c5 --- /dev/null +++ b/app/Models/PermissionModule.php @@ -0,0 +1,18 @@ + 'array', + ]; +} diff --git a/app/Models/RoleTemplate.php b/app/Models/RoleTemplate.php new file mode 100644 index 0000000..7c1d120 --- /dev/null +++ b/app/Models/RoleTemplate.php @@ -0,0 +1,24 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public function users() + { + return $this->hasMany(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c26ac12..21f71ef 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,9 +29,83 @@ class User extends Authenticatable implements FilamentUser return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'extra_permissions' => 'array', + 'excluded_permissions' => 'array', ]; } + public function roleTemplate() + { + return $this->belongsTo(RoleTemplate::class); + } + + /** + * Tính toán effective permissions từ role template + extra - excluded. + * Cache trong session (1 lần/login). + */ + public function getEffectivePermissions(): array + { + $cacheKey = "user.{$this->id}.permissions"; + + if (session()->has($cacheKey)) { + return session()->get($cacheKey); + } + + $permissions = $this->calculateEffectivePermissions(); + session()->put($cacheKey, $permissions); + + return $permissions; + } + + public function hasEffectivePermission(string $permission): bool + { + return in_array($permission, $this->getEffectivePermissions()); + } + + public function clearPermissionCache(): void + { + session()->forget("user.{$this->id}.permissions"); + } + + protected function calculateEffectivePermissions(): array + { + $templatePerms = []; + + if ($this->roleTemplate) { + $templatePerms = $this->roleTemplate->permissions ?? []; + } + + // Flatten template permissions từ {"contracts":["view","create"]} thành ["contracts.view","contracts.create"] + $templateFlat = []; + foreach ($templatePerms as $module => $actions) { + foreach ($actions as $action) { + $templateFlat[] = "{$module}.{$action}"; + } + } + + $extra = $this->extra_permissions ?? []; + $excluded = $this->excluded_permissions ?? []; + + return array_values(array_diff( + array_unique(array_merge($templateFlat, $extra)), + $excluded + )); + } + + /** + * Override can() để tích hợp với Laravel Authorization. + * Nếu ability là dạng "contracts.view" → dùng effective permissions. + * Ngược lại fallback về parent. + */ + public function can($abilities, $arguments = []): bool + { + if (is_string($abilities) && str_contains($abilities, '.')) { + return $this->hasEffectivePermission($abilities); + } + + return parent::can($abilities, $arguments); + } + public function canAccessPanel(Panel $panel): bool { return true; diff --git a/database/migrations/2026_04_29_030000_create_permission_system_tables.php b/database/migrations/2026_04_29_030000_create_permission_system_tables.php new file mode 100644 index 0000000..d1640a5 --- /dev/null +++ b/database/migrations/2026_04_29_030000_create_permission_system_tables.php @@ -0,0 +1,48 @@ +uuid('id')->primary(); + $table->string('module')->unique(); // contracts, payments, customers... + $table->string('label'); // Hợp đồng, Thu tiền... + $table->jsonb('actions'); // ["view","create","update","delete","restore","forceDelete","export"] + $table->timestamps(); + }); + + // Bảng mẫu nhóm (Role Template) + Schema::create('role_templates', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('name'); // Sales, Kế toán, Admin... + $table->text('description')->nullable(); + $table->jsonb('permissions')->default('{}'); // {"contracts":["view","create"], "payments":["view"]} + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + // Sửa users: thêm role_template_id, extra_permissions, excluded_permissions + Schema::table('users', function (Blueprint $table) { + $table->foreignUuid('role_template_id')->nullable()->constrained('role_templates')->nullOnDelete(); + $table->jsonb('extra_permissions')->default('[]'); + $table->jsonb('excluded_permissions')->default('[]'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['role_template_id']); + $table->dropColumn(['role_template_id', 'extra_permissions', 'excluded_permissions']); + }); + + Schema::dropIfExists('role_templates'); + Schema::dropIfExists('permission_modules'); + } +};