From a917c76c6bab48604ce50018d5fd4c80b73a026a Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 10 Jun 2026 01:51:05 +0700 Subject: [PATCH] Add role and permission management using Spatie's Laravel Permission package. Introduce Role and Permission enums, update User model to use roles, and implement authorization checks in EmployeeController and related components. Enhance employee management forms and data tables to include role selection and display. Update migrations and seeders for roles and permissions. --- app/Enums/Permission.php | 50 ++++ app/Enums/Role.php | 109 +++++++++ .../Admin/Hr/EmployeeController.php | 15 +- app/Http/Middleware/HandleInertiaRequests.php | 12 +- .../Requests/Admin/Hr/EmployeeRequest.php | 9 +- app/Models/User.php | 3 +- app/Providers/AppServiceProvider.php | 14 ++ bootstrap/app.php | 9 + composer.json | 5 +- composer.lock | 150 +++++++++++- config/permission.php | 219 ++++++++++++++++++ ..._06_09_183326_create_permission_tables.php | 137 +++++++++++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/RolePermissionSeeder.php | 72 ++++++ database/seeders/UserSeeder.php | 3 + resources/js/components/AppSidebar.vue | 4 +- resources/js/components/hr/EmployeeForm.vue | 18 +- .../js/components/hr/employees/columns.ts | 9 + .../hr/employees/data-table-actions.vue | 19 +- .../hr/employees/employee-status-toggle.vue | 13 +- resources/js/composables/useCan.ts | 29 +++ .../js/pages/admin/hr/employees/Create.vue | 11 +- .../js/pages/admin/hr/employees/Edit.vue | 7 +- .../js/pages/admin/hr/employees/Index.vue | 4 +- resources/js/types/auth.ts | 4 +- resources/js/types/employee.ts | 4 + routes/web.php | 29 ++- 27 files changed, 930 insertions(+), 29 deletions(-) create mode 100644 app/Enums/Permission.php create mode 100644 app/Enums/Role.php create mode 100644 config/permission.php create mode 100644 database/migrations/2026_06_09_183326_create_permission_tables.php create mode 100644 database/seeders/RolePermissionSeeder.php create mode 100644 resources/js/composables/useCan.ts diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php new file mode 100644 index 0000000..babf5db --- /dev/null +++ b/app/Enums/Permission.php @@ -0,0 +1,50 @@ + 'Lihat Dashboard', + + self::EMPLOYEES_VIEW => 'Lihat Pegawai', + self::EMPLOYEES_CREATE => 'Tambah Pegawai', + self::EMPLOYEES_UPDATE => 'Ubah Pegawai', + self::EMPLOYEES_DELETE => 'Hapus Pegawai', + self::EMPLOYEES_RESET_PASSWORD => 'Reset Kata Sandi Pegawai', + self::EMPLOYEES_TOGGLE_STATUS => 'Ubah Status Pegawai', + }; + } + + public function group(): string + { + return match ($this) { + self::DASHBOARD_VIEW => 'Umum', + self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE, + self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai', + }; + } + + /** + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php new file mode 100644 index 0000000..dbdc287 --- /dev/null +++ b/app/Enums/Role.php @@ -0,0 +1,109 @@ + 'Developer', + self::OWNER => 'Owner', + self::ADMIN_TOKO => 'Admin Toko', + self::ADMIN_BAHAN_BAKU => 'Admin Bahan Baku', + self::DIREKTUR => 'Direktur', + self::MARKETING => 'Marketing', + self::NON_OPERATOR => 'Non Operator', + }; + } + + /** + * @return list + */ + public function permissions(): array + { + return match ($this) { + self::DEVELOPER, self::OWNER => Permission::cases(), + self::DIREKTUR => [ + Permission::DASHBOARD_VIEW, + Permission::EMPLOYEES_VIEW, + Permission::EMPLOYEES_CREATE, + Permission::EMPLOYEES_UPDATE, + Permission::EMPLOYEES_DELETE, + Permission::EMPLOYEES_RESET_PASSWORD, + Permission::EMPLOYEES_TOGGLE_STATUS, + ], + self::ADMIN_TOKO => [ + Permission::DASHBOARD_VIEW, + Permission::EMPLOYEES_VIEW, + Permission::EMPLOYEES_CREATE, + Permission::EMPLOYEES_UPDATE, + Permission::EMPLOYEES_RESET_PASSWORD, + Permission::EMPLOYEES_TOGGLE_STATUS, + ], + self::ADMIN_BAHAN_BAKU => [ + Permission::DASHBOARD_VIEW, + Permission::EMPLOYEES_VIEW, + ], + self::MARKETING => [ + Permission::DASHBOARD_VIEW, + Permission::EMPLOYEES_VIEW, + ], + self::NON_OPERATOR => [ + Permission::DASHBOARD_VIEW, + ], + }; + } + + public function isAssignable(): bool + { + return $this !== self::DEVELOPER; + } + + /** + * @return list + */ + public static function assignableSelectOptions(): array + { + return collect(self::cases()) + ->filter(fn (self $role) => $role->isAssignable()) + ->map(fn (self $role) => [ + 'value' => $role->value, + 'label' => $role->label(), + ]) + ->values() + ->all(); + } + + /** + * @return list + */ + public static function assignableValues(): array + { + return collect(self::cases()) + ->filter(fn (self $role) => $role->isAssignable()) + ->map(fn (self $role) => $role->value) + ->values() + ->all(); + } + + /** + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Http/Controllers/Admin/Hr/EmployeeController.php b/app/Http/Controllers/Admin/Hr/EmployeeController.php index c7c8784..13205bf 100644 --- a/app/Http/Controllers/Admin/Hr/EmployeeController.php +++ b/app/Http/Controllers/Admin/Hr/EmployeeController.php @@ -4,6 +4,7 @@ use App\Enums\EmploymentStatus; use App\Enums\Gender; +use App\Enums\Role; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Hr\EmployeeRequest; @@ -32,7 +33,7 @@ public function index(Request $request): Response $employmentStatus = $request->string('employment_status')->toString(); $query = User::query() - ->with(['profile', 'employee']) + ->with(['profile', 'employee', 'roles']) ->whereHas('employee') ->when($search !== '', function ($query) use ($search): void { $query->where(function ($query) use ($search): void { @@ -70,6 +71,7 @@ public function create(): Response return Inertia::render('admin/hr/employees/Create', [ 'genders' => Gender::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(), + 'roles' => Role::assignableSelectOptions(), ]); } @@ -99,6 +101,8 @@ public function store(EmployeeRequest $request): RedirectResponse 'employment_status' => $validated['employment_status'], 'base_salary' => $validated['base_salary'], ]); + + $user->syncRoles([$validated['role']]); }); Inertia::flash('success', 'Pegawai berhasil ditambahkan.'); @@ -108,11 +112,12 @@ public function store(EmployeeRequest $request): RedirectResponse public function edit(User $user): Response { - $user->load(['profile', 'employee']); + $user->load(['profile', 'employee', 'roles']); return Inertia::render('admin/hr/employees/Edit', [ 'genders' => Gender::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(), + 'roles' => Role::assignableSelectOptions(), 'employee' => $this->transformEmployeeForForm($user), ]); } @@ -139,6 +144,8 @@ public function update(EmployeeRequest $request, User $user): RedirectResponse $employee->employment_status = $validated['employment_status']; $employee->base_salary = $validated['base_salary']; $employee->save(); + + $user->syncRoles([$validated['role']]); }); Inertia::flash('success', 'Data pegawai berhasil diperbarui.'); @@ -248,6 +255,7 @@ private function transformEmployeeForForm(User $user): array 'join_date' => $employee->join_date?->format('Y-m-d'), 'employment_status' => $employee->employment_status?->value, 'base_salary' => $employee->base_salary, + 'role' => $user->roles->first()?->name, ]; } @@ -258,6 +266,7 @@ private function transformEmployee(User $user): array { $employee = $user->employee; $profile = $user->profile; + $role = $user->roles->first(); return [ 'id' => $user->id, @@ -276,6 +285,8 @@ private function transformEmployee(User $user): array 'gender_label' => $profile->gender?->label(), 'birth_date' => $profile->birth_date_formatted, 'address' => $profile->address, + 'role' => $role?->name, + 'role_label' => $role ? Role::from($role->name)->label() : null, ]; } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3bbf7d8..c9e1368 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -38,8 +38,16 @@ public function share(Request $request): array return [ ...parent::share($request), 'name' => config('app.name'), - 'auth' => [ - 'user' => $request->user(), + 'auth' => fn () => [ + 'user' => $request->user() ? [ + 'id' => $request->user()->id, + 'email' => $request->user()->email, + 'username' => $request->user()->username, + 'is_active' => $request->user()->is_active, + 'last_login_at' => $request->user()->last_login_at, + 'roles' => $request->user()->getRoleNames(), + 'permissions' => $request->user()->getAllPermissions()->pluck('name'), + ] : null, ], 'flash' => [ 'success' => $request->session()->get('success'), diff --git a/app/Http/Requests/Admin/Hr/EmployeeRequest.php b/app/Http/Requests/Admin/Hr/EmployeeRequest.php index c271b1f..98af213 100644 --- a/app/Http/Requests/Admin/Hr/EmployeeRequest.php +++ b/app/Http/Requests/Admin/Hr/EmployeeRequest.php @@ -4,6 +4,8 @@ use App\Enums\EmploymentStatus; use App\Enums\Gender; +use App\Enums\Permission; +use App\Enums\Role; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,7 +13,11 @@ class EmployeeRequest extends FormRequest { public function authorize(): bool { - return auth()->check(); + $permission = $this->isMethod('POST') + ? Permission::EMPLOYEES_CREATE + : Permission::EMPLOYEES_UPDATE; + + return $this->user()?->can($permission->value) ?? false; } /** @@ -30,6 +36,7 @@ public function rules(): array 'join_date' => ['required', 'date'], 'employment_status' => ['required', Rule::enum(EmploymentStatus::class)], 'base_salary' => ['required', 'integer', 'min:0'], + 'role' => ['required', Rule::in(Role::assignableValues())], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index c93b8dc..617ff03 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,12 +11,13 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Spatie\Permission\Traits\HasRoles; #[Guarded(['id'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable { - use HasFactory, Notifiable, SoftDeletes; + use HasFactory, HasRoles, Notifiable, SoftDeletes; protected function casts(): array { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..51747c7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,11 @@ namespace App\Providers; +use App\Enums\Role; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -24,6 +26,18 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureAuthorization(); + } + + protected function configureAuthorization(): void + { + Gate::before(function ($user, string $ability) { + if ($user->hasRole(Role::DEVELOPER->value) || $user->hasRole(Role::OWNER->value)) { + return true; + } + + return null; + }); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 0f46242..8c18807 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,9 @@ use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Request; +use Spatie\Permission\Middleware\PermissionMiddleware; +use Spatie\Permission\Middleware\RoleMiddleware; +use Spatie\Permission\Middleware\RoleOrPermissionMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -14,6 +17,12 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'role' => RoleMiddleware::class, + 'permission' => PermissionMiddleware::class, + 'role_or_permission' => RoleOrPermissionMiddleware::class, + ]); + $middleware->web(append: [ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, diff --git a/composer.json b/composer.json index 9b3f398..0b1aa34 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "inertiajs/inertia-laravel": "^3.0", "laravel/framework": "^13.7", "laravel/tinker": "^3.0", - "laravel/wayfinder": "^0.1.14" + "laravel/wayfinder": "^0.1.14", + "spatie/laravel-permission": "^8.0" }, "require-dev": { "fakerphp/faker": "^1.24", @@ -103,4 +104,4 @@ } }, "minimum-stability": "stable" -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index af88d9f..d89d0f1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "addd2ce0fdffa77be44c490a027acb70", + "content-hash": "214f5e51f5d046a46a01d066adf96929", "packages": [ { "name": "brick/math", @@ -3488,6 +3488,154 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" + }, + { + "name": "spatie/laravel-permission", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "70a6ab04108616b438e0839598f473b513281644" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/70a6ab04108616b438e0839598f473b513281644", + "reference": "70a6ab04108616b438e0839598f473b513281644", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "8.x-dev", + "dev-master": "8.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-30T19:30:22+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..8f1f452 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/database/migrations/2026_06_09_183326_create_permission_tables.php b/database/migrations/2026_06_09_183326_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_06_09_183326_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b9c7377..68d7198 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + RolePermissionSeeder::class, UserSeeder::class, ]); } diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..55ddd23 --- /dev/null +++ b/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,72 @@ +forgetCachedPermissions(); + + $permissions = collect(PermissionEnum::cases()) + ->mapWithKeys(fn (PermissionEnum $permission) => [ + $permission->value => Permission::findOrCreate($permission->value, 'web'), + ]); + + $registrar->forgetCachedPermissions(); + + foreach (RoleEnum::cases() as $role) { + $roleModel = Role::findOrCreate($role->value, 'web'); + + $roleModel->syncPermissions( + collect($role->permissions()) + ->map(fn (PermissionEnum $permission) => $permissions[$permission->value]) + ->all() + ); + } + + $legacyRoleMap = [ + 'super-admin' => RoleEnum::DEVELOPER->value, + 'admin' => RoleEnum::DIREKTUR->value, + 'hr' => RoleEnum::ADMIN_TOKO->value, + 'user' => RoleEnum::NON_OPERATOR->value, + ]; + + foreach ($legacyRoleMap as $oldRole => $newRole) { + $oldRoleModel = Role::query()->where('name', $oldRole)->where('guard_name', 'web')->first(); + + if (! $oldRoleModel) { + continue; + } + + $newRoleModel = Role::findByName($newRole, 'web'); + + foreach ($oldRoleModel->users as $user) { + $user->removeRole($oldRole); + $user->assignRole($newRoleModel); + } + + $oldRoleModel->delete(); + } + + Role::query() + ->where('guard_name', 'web') + ->whereNotIn('name', RoleEnum::values()) + ->each(fn (Role $role) => $role->delete()); + + Permission::query() + ->where('guard_name', 'web') + ->whereNotIn('name', PermissionEnum::values()) + ->each(fn (Permission $permission) => $permission->delete()); + + $registrar->forgetCachedPermissions(); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index e795e1c..79d6128 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Enums\Gender; +use App\Enums\Role; use App\Models\User; use App\Models\UserProfile; use Illuminate\Database\Seeder; @@ -27,6 +28,8 @@ public function run(): void 'phone_number' => '082121495806', 'gender' => Gender::MALE->value, ]); + + $developer->assignRole(Role::DEVELOPER->value); }); } } diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 1d11bcb..01093dc 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -14,8 +14,10 @@ import { SidebarMenuItem, SidebarRail, } from '@/components/ui/sidebar'; +import { useCan } from '@/composables/useCan'; const page = usePage(); +const { can } = useCan(); const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard')); const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees')); @@ -52,7 +54,7 @@ const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employee - + HR diff --git a/resources/js/components/hr/EmployeeForm.vue b/resources/js/components/hr/EmployeeForm.vue index e4cfa11..e02f37a 100644 --- a/resources/js/components/hr/EmployeeForm.vue +++ b/resources/js/components/hr/EmployeeForm.vue @@ -31,6 +31,7 @@ const props = withDefaults( defineProps<{ genders: EnumOption[]; employmentStatuses: EnumOption[]; + roles: EnumOption[]; initialData?: Partial; submitUrl: string; method?: 'post' | 'put'; @@ -55,6 +56,7 @@ const form = useForm({ join_date: props.initialData?.join_date ?? '', employment_status: props.initialData?.employment_status ?? 'full_time', base_salary: props.initialData?.base_salary ?? '', + role: props.initialData?.role ?? '', }); function submit() { @@ -83,7 +85,7 @@ function submit() { -
+
Email @@ -94,6 +96,20 @@ function submit() { + + Role + + +
diff --git a/resources/js/components/hr/employees/columns.ts b/resources/js/components/hr/employees/columns.ts index 38f82c1..c0d2dba 100644 --- a/resources/js/components/hr/employees/columns.ts +++ b/resources/js/components/hr/employees/columns.ts @@ -3,6 +3,7 @@ import { h } from 'vue'; import { DataTableColumnHeader } from '@/components/data-table'; import DataTableActions from '@/components/hr/employees/data-table-actions.vue'; import EmployeeStatusToggle from '@/components/hr/employees/employee-status-toggle.vue'; +import { Badge } from '@/components/ui/badge'; import type { EmployeeListItem } from '@/types/employee'; function formatDate(value: string | null): string { @@ -39,6 +40,14 @@ export const columns: ColumnDef[] = [ h('div', { class: 'text-muted-foreground text-xs' }, row.original.username ?? '-'), ]), }, + { + accessorKey: 'role_label', + enableSorting: false, + header: () => h(DataTableColumnHeader, { title: 'Role', column: 'role' }), + cell: ({ row }) => row.original.role_label + ? h(Badge, { variant: 'outline' }, () => row.original.role_label) + : '-', + }, { accessorKey: 'phone_number', enableSorting: false, diff --git a/resources/js/components/hr/employees/data-table-actions.vue b/resources/js/components/hr/employees/data-table-actions.vue index 279d20c..ac52557 100644 --- a/resources/js/components/hr/employees/data-table-actions.vue +++ b/resources/js/components/hr/employees/data-table-actions.vue @@ -6,12 +6,15 @@ import { toast } from 'vue-sonner'; import ConfirmDialog from '@/components/ConfirmDialog.vue'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useCan } from '@/composables/useCan'; import type { EmployeeListItem } from '@/types/employee'; const props = defineProps<{ employee: EmployeeListItem; }>(); +const { can } = useCan(); + const deleteConfirmOpen = ref(false); const resetPasswordConfirmOpen = ref(false); const deleteProcessing = ref(false); @@ -53,7 +56,7 @@ function resetPassword() { diff --git a/resources/js/components/hr/employees/employee-status-toggle.vue b/resources/js/components/hr/employees/employee-status-toggle.vue index 1c54505..a1b0ff0 100644 --- a/resources/js/components/hr/employees/employee-status-toggle.vue +++ b/resources/js/components/hr/employees/employee-status-toggle.vue @@ -4,12 +4,15 @@ import { ref, watch } from 'vue'; import { toast } from 'vue-sonner'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; +import { useCan } from '@/composables/useCan'; import type { EmployeeListItem } from '@/types/employee'; const props = defineProps<{ employee: EmployeeListItem; }>(); +const { can } = useCan(); + const isActive = ref(props.employee.is_active); const processing = ref(false); @@ -21,6 +24,10 @@ watch( ); function toggleStatus(checked: boolean) { + if (!can('employees.toggle-status')) { + return; + } + const previous = isActive.value; isActive.value = checked; processing.value = true; @@ -42,7 +49,11 @@ function toggleStatus(checked: boolean) { diff --git a/resources/js/pages/admin/hr/employees/Edit.vue b/resources/js/pages/admin/hr/employees/Edit.vue index 5dddc3e..c0a8797 100644 --- a/resources/js/pages/admin/hr/employees/Edit.vue +++ b/resources/js/pages/admin/hr/employees/Edit.vue @@ -11,6 +11,7 @@ const props = defineProps<{ employee: EmployeeEditItem; genders: EnumOption[]; employmentStatuses: EnumOption[]; + roles: EnumOption[]; }>(); const initialData = computed(() => ({ @@ -24,6 +25,7 @@ const initialData = computed(() => ({ join_date: props.employee.join_date ?? '', employment_status: props.employee.employment_status ?? 'full_time', base_salary: props.employee.base_salary != null ? String(props.employee.base_salary) : '', + role: props.employee.role ?? '', })); @@ -49,6 +51,9 @@ const initialData = computed(() => ({ + :genders="genders" + :employment-statuses="employmentStatuses" + :roles="roles" + /> diff --git a/resources/js/pages/admin/hr/employees/Index.vue b/resources/js/pages/admin/hr/employees/Index.vue index bbd3e65..d94f71b 100644 --- a/resources/js/pages/admin/hr/employees/Index.vue +++ b/resources/js/pages/admin/hr/employees/Index.vue @@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue'; import { DataTable } from '@/components/data-table'; import { columns } from '@/components/hr/employees/columns'; import { Button } from '@/components/ui/button'; +import { useCan } from '@/composables/useCan'; import { Card, CardContent } from '@/components/ui/card'; import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery'; import AdminLayout from '@/layouts/AdminLayout.vue'; @@ -22,6 +23,7 @@ const props = defineProps<{ employmentStatuses: EnumOption[]; }>(); +const { can } = useCan(); const search = ref(props.filters.search ?? ''); const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({ @@ -87,7 +89,7 @@ watch( -