feat: RBAC catalog + 2FA UX (P0 blocker from /tmp/service/new/01-TZ)
Implements the RBAC TZ in app/Auth/Permissions.php with a 51-permission catalog spanning 9 modules (clients/vehicles/work_orders/finance/salaries/ inventory/suppliers/admin/ai_assistant+analytics). All slugs are constants, not magic strings — refactors against renames stay safe. == 7 system roles == owner / admin / manager / accountant / receptionist / mechanic / viewer Each gets a curated role-permission matrix per the TZ section 2.4: - owner + admin: all 51 - manager: 23 (operations + reporting, no destructive finance/users) - accountant: 17 (full finance/salaries, view-only WOs, no admin) - receptionist: 13 (front-desk: clients/vehicles/WOs/payment-create) - mechanic: 4 (own WOs + inventory view + own salary) - viewer: 6 (read-only everything except finance/salaries) == Seeder == App\Services\RbacSeeder: - seedPermissions() creates the 51 Permission rows globally (idempotent) - seedTenantRoles($companyId) sets the team context, creates the 7 Role rows scoped to that tenant, and syncPermissions per matrix - syncUsersToRoles($companyId) maps legacy users.role string column to the new Spatie role assignment (parts_manager→manager, master→mechanic, marketer→manager, user→viewer) == Migration == 2026_06_04_000003 loops over all existing Companies and runs the seeder. On a fresh prod deploy, every tenant gets the full RBAC catalog wired up automatically. CompanyProvisioner::provision() also calls the seeder for new tenants going forward. == Resource gates == canViewAny / canCreate / canDelete on: - PaymentResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_PAYMENT / FINANCE_DELETE_PAYMENT) - ExpenseResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_EXPENSE / FINANCE_DELETE_PAYMENT) - PayrollAdjustmentResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE) - PayrollRunResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE) - UserResource (ADMIN_USERS_VIEW / ADMIN_USERS_MANAGE) - RoleResource (ADMIN_ROLES_MANAGE) Mechanic sees only own WOs + inventory + own salary. Accountant sees all finance but not admin. Receptionist sees clients/WOs but not finance overview. Etc. == User helpers == $user->canDo(Permissions::WORK_ORDERS_CREATE) — admin gets a bypass to prevent lockouts from misconfigured permission grants. $user->isOwner() / isAccountant() / isMechanic() — role shortcuts. $user->hasTwoFactorEnabled() — true when app_authentication_secret is set. == 2FA == Filament 5's native MultiFactorAuthentication (App + Email) is already enabled in both TenantPanelProvider and CentralPanelProvider — confirmed. The User model already implements HasAppAuthentication + HasAppAuthenticationRecovery + HasEmailAuthentication. This commit adds UX around it: - UserResource list column: 2FA badge (green ✓ when enabled, amber ⚠ when off) - UserResource form: "Securitate" section shows enabled/disabled + last_login_at - New admin action "Resetează 2FA" with confirmation modal — clears app_authentication_secret + recovery codes for locked-out users == Roles management UI == New /app/roles RoleResource: - List: role label + slug + permission count + user count - Edit: 10 grouped checkbox lists (per module) for fine-grained permission assignment + bulk-toggle per group - System roles (owner/admin/etc.) have slug locked, can't be deleted - Custom tenant-specific roles can be added on top - Gated behind ADMIN_ROLES_MANAGE == UserResource extension == - Role select now uses Permissions::roleLabels() (owner/admin/manager/...) - New "Roluri suplimentare" multi-select for stacking roles on top of the primary one (permissions cumulate) - afterSave syncs the picked roles + ensures primary role is always included == Tests == RbacTest covers: 51 permissions seeded, 7 roles per tenant, owner has all, mechanic has minimal, accountant has finance but not admin, canDo returns true when role has permission, admin bypass, owner helper, syncUsersToRoles legacy mapping (parts_manager→manager, master→mechanic, user→viewer), 2FA helper round-trip. Suite: 206 passed (576 assertions). Was 196. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central permission catalog — 50 fine-grained permissions across 9 modules.
|
||||||
|
* Mirrors the catalog in /tmp/service/new/01-TZ-rbac-utilizator-tehnician.md §2.3.
|
||||||
|
*
|
||||||
|
* Use these constants instead of magic strings:
|
||||||
|
* $user->can(Permissions::WORK_ORDERS_CREATE)
|
||||||
|
*/
|
||||||
|
class Permissions
|
||||||
|
{
|
||||||
|
// Clients
|
||||||
|
public const CLIENTS_VIEW_ALL = 'clients.view_all';
|
||||||
|
public const CLIENTS_VIEW_OWN = 'clients.view_own';
|
||||||
|
public const CLIENTS_CREATE = 'clients.create';
|
||||||
|
public const CLIENTS_EDIT = 'clients.edit';
|
||||||
|
public const CLIENTS_DELETE = 'clients.delete';
|
||||||
|
public const CLIENTS_EXPORT = 'clients.export';
|
||||||
|
|
||||||
|
// Vehicles
|
||||||
|
public const VEHICLES_VIEW_ALL = 'vehicles.view_all';
|
||||||
|
public const VEHICLES_CREATE = 'vehicles.create';
|
||||||
|
public const VEHICLES_EDIT = 'vehicles.edit';
|
||||||
|
public const VEHICLES_DELETE = 'vehicles.delete';
|
||||||
|
|
||||||
|
// Work orders (Fișe)
|
||||||
|
public const WORK_ORDERS_VIEW_ALL = 'work_orders.view_all';
|
||||||
|
public const WORK_ORDERS_VIEW_OWN_ASSIGNED = 'work_orders.view_own_assigned';
|
||||||
|
public const WORK_ORDERS_CREATE = 'work_orders.create';
|
||||||
|
public const WORK_ORDERS_EDIT = 'work_orders.edit';
|
||||||
|
public const WORK_ORDERS_DELETE = 'work_orders.delete';
|
||||||
|
public const WORK_ORDERS_CHANGE_STATUS = 'work_orders.change_status';
|
||||||
|
public const WORK_ORDERS_APPROVE_DISCOUNT_5 = 'work_orders.approve_discount_5';
|
||||||
|
public const WORK_ORDERS_APPROVE_DISCOUNT_20 = 'work_orders.approve_discount_20';
|
||||||
|
public const WORK_ORDERS_APPROVE_DISCOUNT_ANY = 'work_orders.approve_discount_any';
|
||||||
|
public const WORK_ORDERS_PRINT = 'work_orders.print';
|
||||||
|
|
||||||
|
// Finance
|
||||||
|
public const FINANCE_VIEW_OVERVIEW = 'finance.view_overview';
|
||||||
|
public const FINANCE_VIEW_PL = 'finance.view_pl';
|
||||||
|
public const FINANCE_CREATE_PAYMENT = 'finance.create_payment';
|
||||||
|
public const FINANCE_DELETE_PAYMENT = 'finance.delete_payment';
|
||||||
|
public const FINANCE_CREATE_EXPENSE = 'finance.create_expense';
|
||||||
|
public const FINANCE_EXPORT = 'finance.export';
|
||||||
|
|
||||||
|
// Salaries
|
||||||
|
public const SALARIES_VIEW_OWN = 'salaries.view_own';
|
||||||
|
public const SALARIES_VIEW_ALL = 'salaries.view_all';
|
||||||
|
public const SALARIES_CALCULATE = 'salaries.calculate';
|
||||||
|
public const SALARIES_MARK_PAID = 'salaries.mark_paid';
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
public const INVENTORY_VIEW = 'inventory.view';
|
||||||
|
public const INVENTORY_CREATE_PART = 'inventory.create_part';
|
||||||
|
public const INVENTORY_EDIT_PART = 'inventory.edit_part';
|
||||||
|
public const INVENTORY_DELETE_PART = 'inventory.delete_part';
|
||||||
|
public const INVENTORY_ADJUST_STOCK = 'inventory.adjust_stock';
|
||||||
|
public const INVENTORY_CREATE_PURCHASE = 'inventory.create_purchase';
|
||||||
|
public const INVENTORY_RECEIVE_GOODS = 'inventory.receive_goods';
|
||||||
|
|
||||||
|
// Suppliers
|
||||||
|
public const SUPPLIERS_VIEW = 'suppliers.view';
|
||||||
|
public const SUPPLIERS_EDIT = 'suppliers.edit';
|
||||||
|
public const SUPPLIERS_DELETE = 'suppliers.delete';
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
public const ADMIN_USERS_VIEW = 'admin.users.view';
|
||||||
|
public const ADMIN_USERS_MANAGE = 'admin.users.manage';
|
||||||
|
public const ADMIN_ROLES_MANAGE = 'admin.roles.manage';
|
||||||
|
public const ADMIN_SETTINGS_EDIT = 'admin.settings.edit';
|
||||||
|
public const ADMIN_INTEGRATIONS = 'admin.settings.integrations';
|
||||||
|
public const ADMIN_API_TOKENS_MANAGE = 'admin.api_tokens.manage';
|
||||||
|
public const ADMIN_AUDIT_LOG_VIEW = 'admin.audit_log.view';
|
||||||
|
public const ADMIN_BACKUP_DOWNLOAD = 'admin.backup.download';
|
||||||
|
|
||||||
|
// AI & Analytics
|
||||||
|
public const AI_ASSISTANT_USE = 'ai_assistant.use';
|
||||||
|
public const AI_ASSISTANT_CONFIGURE_KEYS = 'ai_assistant.configure_keys';
|
||||||
|
public const ANALYTICS_VIEW = 'analytics.view';
|
||||||
|
|
||||||
|
/** Full list — used by seeder. */
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_VIEW_OWN, self::CLIENTS_CREATE, self::CLIENTS_EDIT,
|
||||||
|
self::CLIENTS_DELETE, self::CLIENTS_EXPORT,
|
||||||
|
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT, self::VEHICLES_DELETE,
|
||||||
|
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CREATE,
|
||||||
|
self::WORK_ORDERS_EDIT, self::WORK_ORDERS_DELETE, self::WORK_ORDERS_CHANGE_STATUS,
|
||||||
|
self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_APPROVE_DISCOUNT_20,
|
||||||
|
self::WORK_ORDERS_APPROVE_DISCOUNT_ANY, self::WORK_ORDERS_PRINT,
|
||||||
|
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_VIEW_PL, self::FINANCE_CREATE_PAYMENT,
|
||||||
|
self::FINANCE_DELETE_PAYMENT, self::FINANCE_CREATE_EXPENSE, self::FINANCE_EXPORT,
|
||||||
|
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_ALL, self::SALARIES_CALCULATE, self::SALARIES_MARK_PAID,
|
||||||
|
self::INVENTORY_VIEW, self::INVENTORY_CREATE_PART, self::INVENTORY_EDIT_PART, self::INVENTORY_DELETE_PART,
|
||||||
|
self::INVENTORY_ADJUST_STOCK, self::INVENTORY_CREATE_PURCHASE, self::INVENTORY_RECEIVE_GOODS,
|
||||||
|
self::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT, self::SUPPLIERS_DELETE,
|
||||||
|
self::ADMIN_USERS_VIEW, self::ADMIN_USERS_MANAGE, self::ADMIN_ROLES_MANAGE,
|
||||||
|
self::ADMIN_SETTINGS_EDIT, self::ADMIN_INTEGRATIONS, self::ADMIN_API_TOKENS_MANAGE,
|
||||||
|
self::ADMIN_AUDIT_LOG_VIEW, self::ADMIN_BACKUP_DOWNLOAD,
|
||||||
|
self::AI_ASSISTANT_USE, self::AI_ASSISTANT_CONFIGURE_KEYS, self::ANALYTICS_VIEW,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups for UI rendering: module → list of permissions. */
|
||||||
|
public static function grouped(): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
foreach (self::all() as $slug) {
|
||||||
|
[$module] = explode('.', $slug, 2);
|
||||||
|
$groups[$module][] = $slug;
|
||||||
|
}
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function labels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'clients' => 'Clienți',
|
||||||
|
'vehicles' => 'Mașini',
|
||||||
|
'work_orders' => 'Fișe lucru',
|
||||||
|
'finance' => 'Finanțe',
|
||||||
|
'salaries' => 'Salarii',
|
||||||
|
'inventory' => 'Stoc',
|
||||||
|
'suppliers' => 'Furnizori',
|
||||||
|
'admin' => 'Administrare',
|
||||||
|
'ai_assistant' => 'AI Assistant',
|
||||||
|
'analytics' => 'Analitică',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-permission matrix per TZ §2.4.
|
||||||
|
* Returns: ['owner' => [perm1, perm2, ...], ...]
|
||||||
|
*/
|
||||||
|
public static function roleMatrix(): array
|
||||||
|
{
|
||||||
|
$all = self::all();
|
||||||
|
$matrix = [];
|
||||||
|
|
||||||
|
// owner = everything
|
||||||
|
$matrix['owner'] = $all;
|
||||||
|
|
||||||
|
// admin = everything except billing-only stuff (we don't have that yet, so = all)
|
||||||
|
$matrix['admin'] = $all;
|
||||||
|
|
||||||
|
// manager: operations + reporting, no destructive
|
||||||
|
$matrix['manager'] = [
|
||||||
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_CREATE, self::CLIENTS_EDIT, self::CLIENTS_EXPORT,
|
||||||
|
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT,
|
||||||
|
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CREATE,
|
||||||
|
self::WORK_ORDERS_EDIT, self::WORK_ORDERS_CHANGE_STATUS,
|
||||||
|
self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_APPROVE_DISCOUNT_20,
|
||||||
|
self::WORK_ORDERS_PRINT,
|
||||||
|
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_CREATE_PAYMENT, self::FINANCE_CREATE_EXPENSE,
|
||||||
|
self::SALARIES_VIEW_OWN,
|
||||||
|
self::INVENTORY_VIEW, self::INVENTORY_CREATE_PART, self::INVENTORY_EDIT_PART,
|
||||||
|
self::INVENTORY_ADJUST_STOCK, self::INVENTORY_CREATE_PURCHASE, self::INVENTORY_RECEIVE_GOODS,
|
||||||
|
self::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT,
|
||||||
|
self::AI_ASSISTANT_USE,
|
||||||
|
self::ANALYTICS_VIEW,
|
||||||
|
];
|
||||||
|
|
||||||
|
// accountant: finance + reporting only
|
||||||
|
$matrix['accountant'] = [
|
||||||
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_EXPORT,
|
||||||
|
self::VEHICLES_VIEW_ALL,
|
||||||
|
self::WORK_ORDERS_VIEW_ALL,
|
||||||
|
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_VIEW_PL, self::FINANCE_CREATE_PAYMENT,
|
||||||
|
self::FINANCE_DELETE_PAYMENT, self::FINANCE_CREATE_EXPENSE, self::FINANCE_EXPORT,
|
||||||
|
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_ALL, self::SALARIES_CALCULATE, self::SALARIES_MARK_PAID,
|
||||||
|
self::INVENTORY_VIEW,
|
||||||
|
self::SUPPLIERS_VIEW,
|
||||||
|
self::ANALYTICS_VIEW,
|
||||||
|
self::AI_ASSISTANT_USE,
|
||||||
|
];
|
||||||
|
|
||||||
|
// receptionist: front-desk operations
|
||||||
|
$matrix['receptionist'] = [
|
||||||
|
self::CLIENTS_VIEW_ALL, self::CLIENTS_CREATE, self::CLIENTS_EDIT,
|
||||||
|
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT,
|
||||||
|
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_CREATE, self::WORK_ORDERS_EDIT,
|
||||||
|
self::WORK_ORDERS_CHANGE_STATUS, self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_PRINT,
|
||||||
|
self::FINANCE_CREATE_PAYMENT,
|
||||||
|
self::SALARIES_VIEW_OWN,
|
||||||
|
self::INVENTORY_VIEW,
|
||||||
|
self::AI_ASSISTANT_USE,
|
||||||
|
];
|
||||||
|
|
||||||
|
// mechanic: only own WOs + inventory view
|
||||||
|
$matrix['mechanic'] = [
|
||||||
|
self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CHANGE_STATUS,
|
||||||
|
self::INVENTORY_VIEW,
|
||||||
|
self::SALARIES_VIEW_OWN,
|
||||||
|
];
|
||||||
|
|
||||||
|
// viewer: read-only
|
||||||
|
$matrix['viewer'] = [
|
||||||
|
self::CLIENTS_VIEW_ALL,
|
||||||
|
self::VEHICLES_VIEW_ALL,
|
||||||
|
self::WORK_ORDERS_VIEW_ALL,
|
||||||
|
self::INVENTORY_VIEW,
|
||||||
|
self::SUPPLIERS_VIEW,
|
||||||
|
self::AI_ASSISTANT_USE,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function roleLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'owner' => 'Proprietar',
|
||||||
|
'admin' => 'Administrator',
|
||||||
|
'manager' => 'Manager',
|
||||||
|
'accountant' => 'Contabil',
|
||||||
|
'receptionist' => 'Recepție',
|
||||||
|
'mechanic' => 'Mecanic',
|
||||||
|
'viewer' => 'Vizitator',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,16 @@ class ExpenseResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 51;
|
protected static ?int $navigationSort = 51;
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_OVERVIEW) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_CREATE_EXPENSE) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ class PaymentResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 50;
|
protected static ?int $navigationSort = 50;
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_OVERVIEW) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_CREATE_PAYMENT) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete($record): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_DELETE_PAYMENT) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ class PayrollAdjustmentResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 54;
|
protected static ?int $navigationSort = 54;
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_VIEW_ALL) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_CALCULATE) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ class PayrollRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 53;
|
protected static ?int $navigationSort = 53;
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_VIEW_ALL) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_CALCULATE) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Auth\Permissions;
|
||||||
|
use App\Filament\Tenant\Resources\RoleResource\Pages;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
|
class RoleResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Role::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Roluri & Drepturi';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'rol';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'roluri';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 82;
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(Permissions::ADMIN_ROLES_MANAGE) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make('Rol')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')->label('Slug')->required()->maxLength(64)
|
||||||
|
->disabled(fn ($record) => $record && in_array($record->name, array_keys(Permissions::roleMatrix()), true))
|
||||||
|
->helperText('Rolurile sistem (owner/admin/etc.) au numele blocat'),
|
||||||
|
Forms\Components\TextInput::make('guard_name')->default('web')->disabled(),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Drepturi')
|
||||||
|
->description('Bifează ce poate face acest rol. Modificările au efect imediat.')
|
||||||
|
->schema(self::permissionFields())
|
||||||
|
->columns(1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function permissionFields(): array
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
$labels = Permissions::labels();
|
||||||
|
foreach (Permissions::grouped() as $module => $perms) {
|
||||||
|
$options = [];
|
||||||
|
foreach ($perms as $p) {
|
||||||
|
$options[$p] = $p;
|
||||||
|
}
|
||||||
|
$fields[] = Forms\Components\CheckboxList::make("permissions_{$module}")
|
||||||
|
->label($labels[$module] ?? ucfirst($module))
|
||||||
|
->options($options)
|
||||||
|
->columns(2)
|
||||||
|
->bulkToggleable()
|
||||||
|
->afterStateHydrated(function (Forms\Components\CheckboxList $component, $state, $record) use ($module) {
|
||||||
|
if (! $record) { $component->state([]); return; }
|
||||||
|
$names = $record->permissions->pluck('name')->all();
|
||||||
|
$module_prefix = $module . '.';
|
||||||
|
$component->state(array_values(array_filter($names, fn ($n) => str_starts_with($n, $module_prefix))));
|
||||||
|
})
|
||||||
|
->dehydrated(false);
|
||||||
|
}
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('Rol')
|
||||||
|
->formatStateUsing(fn ($state) => Permissions::roleLabels()[$state] ?? $state)
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('Slug')
|
||||||
|
->copyable()
|
||||||
|
->color('gray'),
|
||||||
|
Tables\Columns\TextColumn::make('permissions_count')
|
||||||
|
->counts('permissions')
|
||||||
|
->label('Drepturi')
|
||||||
|
->badge(),
|
||||||
|
Tables\Columns\TextColumn::make('users_count')
|
||||||
|
->counts('users')
|
||||||
|
->label('Utilizatori')
|
||||||
|
->badge(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make()->label('Editează drepturi'),
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->hidden(fn ($record) => in_array($record->name, array_keys(Permissions::roleMatrix()), true)),
|
||||||
|
])
|
||||||
|
->defaultSort('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListRoles::route('/'),
|
||||||
|
'edit' => Pages\EditRole::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\RoleResource\Pages;
|
||||||
|
|
||||||
|
use App\Auth\Permissions;
|
||||||
|
use App\Filament\Tenant\Resources\RoleResource;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
class EditRole extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = RoleResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
// Collect all picked permissions across the module check-lists.
|
||||||
|
$picked = [];
|
||||||
|
foreach (Permissions::grouped() as $module => $_) {
|
||||||
|
$key = "permissions_{$module}";
|
||||||
|
if (isset($data[$key]) && is_array($data[$key])) {
|
||||||
|
$picked = array_merge($picked, $data[$key]);
|
||||||
|
}
|
||||||
|
unset($data[$key]);
|
||||||
|
}
|
||||||
|
$this->_pickedPermissions = $picked;
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
protected array $_pickedPermissions = [];
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$this->record->syncPermissions($this->_pickedPermissions);
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\RoleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\RoleResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListRoles extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = RoleResource::class;
|
||||||
|
}
|
||||||
@@ -31,8 +31,17 @@ class UserResource extends Resource
|
|||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$u = auth()->user();
|
return auth()->user()?->canDo(\App\Auth\Permissions::ADMIN_USERS_VIEW) ?? false;
|
||||||
return $u && $u->role === 'admin';
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::ADMIN_USERS_MANAGE) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete($record): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->canDo(\App\Auth\Permissions::ADMIN_USERS_MANAGE) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@@ -53,17 +62,10 @@ class UserResource extends Resource
|
|||||||
->schema([
|
->schema([
|
||||||
Forms\Components\Select::make('role')
|
Forms\Components\Select::make('role')
|
||||||
->label('Rol primar')
|
->label('Rol primar')
|
||||||
->options([
|
->options(\App\Auth\Permissions::roleLabels())
|
||||||
'admin' => 'Administrator',
|
|
||||||
'manager' => 'Manager',
|
|
||||||
'receptionist' => 'Recepție',
|
|
||||||
'mechanic' => 'Mecanic',
|
|
||||||
'parts_manager' => 'Magazioner piese',
|
|
||||||
'accountant' => 'Contabil',
|
|
||||||
'marketer' => 'Marketing',
|
|
||||||
])
|
|
||||||
->required()
|
->required()
|
||||||
->default('mechanic'),
|
->default('mechanic')
|
||||||
|
->helperText('Rolul principal — sincronizat automat cu drepturile RBAC.'),
|
||||||
Forms\Components\Select::make('status')
|
Forms\Components\Select::make('status')
|
||||||
->options(['active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat'])
|
->options(['active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat'])
|
||||||
->default('active')
|
->default('active')
|
||||||
@@ -76,6 +78,26 @@ class UserResource extends Resource
|
|||||||
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
||||||
->minLength(6)
|
->minLength(6)
|
||||||
->helperText('La editare lasă gol pentru a păstra parola actuală.'),
|
->helperText('La editare lasă gol pentru a păstra parola actuală.'),
|
||||||
|
Forms\Components\Select::make('roles_picked')
|
||||||
|
->label('Roluri suplimentare')
|
||||||
|
->multiple()
|
||||||
|
->options(\App\Auth\Permissions::roleLabels())
|
||||||
|
->afterStateHydrated(function ($component, $record) {
|
||||||
|
if ($record) $component->state($record->roles->pluck('name')->all());
|
||||||
|
})
|
||||||
|
->dehydrated(false)
|
||||||
|
->columnSpanFull()
|
||||||
|
->helperText('Roluri suplimentare peste rolul primar — drepturile se cumulează.'),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Securitate')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Placeholder::make('mfa_status')
|
||||||
|
->label('Autentificare 2FA')
|
||||||
|
->content(fn ($record) => $record && $record->hasTwoFactorEnabled() ? '✓ Activat (TOTP)' : '✗ Dezactivat'),
|
||||||
|
Forms\Components\Placeholder::make('last_login')
|
||||||
|
->label('Ultima autentificare')
|
||||||
|
->content(fn ($record) => $record?->last_login_at?->diffForHumans() ?? '—'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -87,7 +109,17 @@ class UserResource extends Resource
|
|||||||
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('email')->searchable()->copyable(),
|
Tables\Columns\TextColumn::make('email')->searchable()->copyable(),
|
||||||
Tables\Columns\TextColumn::make('phone')->placeholder('—'),
|
Tables\Columns\TextColumn::make('phone')->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('role')->badge(),
|
Tables\Columns\TextColumn::make('role')
|
||||||
|
->formatStateUsing(fn ($state) => \App\Auth\Permissions::roleLabels()[$state] ?? $state)
|
||||||
|
->badge(),
|
||||||
|
Tables\Columns\IconColumn::make('app_authentication_secret')
|
||||||
|
->label('2FA')
|
||||||
|
->boolean()
|
||||||
|
->getStateUsing(fn ($record) => $record->hasTwoFactorEnabled())
|
||||||
|
->trueIcon('heroicon-o-shield-check')
|
||||||
|
->trueColor('success')
|
||||||
|
->falseIcon('heroicon-o-shield-exclamation')
|
||||||
|
->falseColor('warning'),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->colors([
|
->colors([
|
||||||
@@ -109,6 +141,18 @@ class UserResource extends Resource
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
|
Actions\Action::make('reset_2fa')
|
||||||
|
->label('Resetează 2FA')
|
||||||
|
->icon('heroicon-o-shield-exclamation')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn ($record) => $record && $record->hasTwoFactorEnabled())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Dezactivează 2FA pentru acest utilizator. Va trebui să re-configureze TOTP la următoarea autentificare.')
|
||||||
|
->action(function ($record) {
|
||||||
|
$record->saveAppAuthenticationSecret(null);
|
||||||
|
$record->saveAppAuthenticationRecoveryCodes(null);
|
||||||
|
\Filament\Notifications\Notification::make()->title('2FA resetat')->success()->send();
|
||||||
|
}),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc');
|
->defaultSort('created_at', 'desc');
|
||||||
|
|||||||
@@ -8,4 +8,23 @@ use Filament\Resources\Pages\CreateRecord;
|
|||||||
class CreateUser extends CreateRecord
|
class CreateUser extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
protected array $_rolesPicked = [];
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$this->_rolesPicked = $data['roles_picked'] ?? [];
|
||||||
|
unset($data['roles_picked']);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
$picked = $this->_rolesPicked;
|
||||||
|
if (! in_array($this->record->role, $picked, true)) {
|
||||||
|
$picked[] = $this->record->role;
|
||||||
|
}
|
||||||
|
$this->record->syncRoles(array_unique($picked));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,24 @@ class EditUser extends EditRecord
|
|||||||
{
|
{
|
||||||
return [Actions\DeleteAction::make()];
|
return [Actions\DeleteAction::make()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
protected array $_rolesPicked = [];
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$this->_rolesPicked = $data['roles_picked'] ?? [];
|
||||||
|
unset($data['roles_picked']);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
// Always include the primary role
|
||||||
|
$picked = $this->_rolesPicked;
|
||||||
|
if (! in_array($this->record->role, $picked, true)) {
|
||||||
|
$picked[] = $this->record->role;
|
||||||
|
}
|
||||||
|
$this->record->syncRoles(array_unique($picked));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,40 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
|
|||||||
|
|
||||||
public function isAdmin(): bool
|
public function isAdmin(): bool
|
||||||
{
|
{
|
||||||
return $this->role === 'admin';
|
return $this->role === 'admin' || $this->role === 'owner' || $this->hasAnyRole(['admin', 'owner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOwner(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'owner' || $this->hasRole('owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAccountant(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'accountant' || $this->hasRole('accountant');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMechanic(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->role, ['mechanic', 'master'], true) || $this->hasAnyRole(['mechanic']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortcut for permission check using App\Auth\Permissions slug. */
|
||||||
|
public function canDo(string $permission): bool
|
||||||
|
{
|
||||||
|
// Owner + admin always pass — safety net for misconfigured permission grants.
|
||||||
|
if ($this->isAdmin()) return true;
|
||||||
|
try {
|
||||||
|
return $this->can($permission);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Has 2FA app authentication enabled (Filament native). */
|
||||||
|
public function hasTwoFactorEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->app_authentication_secret !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasEmailAuthentication(): bool
|
public function hasEmailAuthentication(): bool
|
||||||
|
|||||||
@@ -59,10 +59,8 @@ class CompanyProvisioner
|
|||||||
$this->tenants->setCurrent($company);
|
$this->tenants->setCurrent($company);
|
||||||
$this->permissions->setPermissionsTeamId($company->id);
|
$this->permissions->setPermissionsTeamId($company->id);
|
||||||
|
|
||||||
// Default roles per tenant.
|
// Seed full RBAC catalog: 7 roles + 51 permissions + matrix per TZ.
|
||||||
foreach (['admin', 'manager', 'receptionist', 'mechanic', 'parts_manager', 'accountant', 'marketer'] as $r) {
|
app(\App\Services\RbacSeeder::class)->seedTenantRoles($company->id);
|
||||||
Role::findOrCreate($r, 'web');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin user.
|
// Admin user.
|
||||||
$adminEmail = $data['admin_email'] ?? "admin@{$company->slug}.local";
|
$adminEmail = $data['admin_email'] ?? "admin@{$company->slug}.local";
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Auth\Permissions;
|
||||||
|
use App\Models\Tenant\User;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the RBAC catalog for a tenant: 50 permissions (global) + 7 system roles
|
||||||
|
* + role-permission matrix per the TZ.
|
||||||
|
*
|
||||||
|
* Idempotent — safe to run multiple times. Adding new permissions later is also
|
||||||
|
* idempotent: each Permission::firstOrCreate skips existing rows.
|
||||||
|
*/
|
||||||
|
class RbacSeeder
|
||||||
|
{
|
||||||
|
/** Seed permissions globally (they're not tenant-scoped). */
|
||||||
|
public function seedPermissions(): void
|
||||||
|
{
|
||||||
|
foreach (Permissions::all() as $slug) {
|
||||||
|
Permission::firstOrCreate(['name' => $slug, 'guard_name' => 'web']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed 7 system roles + role-permission matrix for a specific tenant (team). */
|
||||||
|
public function seedTenantRoles(int $companyId): void
|
||||||
|
{
|
||||||
|
$this->seedPermissions();
|
||||||
|
|
||||||
|
$registrar = app(PermissionRegistrar::class);
|
||||||
|
$registrar->setPermissionsTeamId($companyId);
|
||||||
|
// Bust cache so newly created roles are visible immediately.
|
||||||
|
$registrar->forgetCachedPermissions();
|
||||||
|
|
||||||
|
foreach (Permissions::roleMatrix() as $roleName => $perms) {
|
||||||
|
$role = Role::firstOrCreate(
|
||||||
|
['name' => $roleName, 'guard_name' => 'web', 'company_id' => $companyId]
|
||||||
|
);
|
||||||
|
$role->syncPermissions($perms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After seeding roles, sync each user's role assignment based on legacy
|
||||||
|
* users.role string column. Idempotent: re-running just re-syncs.
|
||||||
|
*/
|
||||||
|
public function syncUsersToRoles(int $companyId): int
|
||||||
|
{
|
||||||
|
$registrar = app(PermissionRegistrar::class);
|
||||||
|
$registrar->setPermissionsTeamId($companyId);
|
||||||
|
$registrar->forgetCachedPermissions();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$validRoles = array_keys(Permissions::roleMatrix());
|
||||||
|
|
||||||
|
User::query()
|
||||||
|
->where('company_id', $companyId)
|
||||||
|
->whereNotNull('role')
|
||||||
|
->chunk(100, function ($users) use ($validRoles, &$count) {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
// Map legacy role strings to the new catalog
|
||||||
|
$role = match ($user->role) {
|
||||||
|
'admin' => 'admin',
|
||||||
|
'manager' => 'manager',
|
||||||
|
'receptionist' => 'receptionist',
|
||||||
|
'mechanic', 'master' => 'mechanic',
|
||||||
|
'accountant' => 'accountant',
|
||||||
|
'parts_manager' => 'manager', // map to manager
|
||||||
|
'marketer' => 'manager',
|
||||||
|
'owner' => 'owner',
|
||||||
|
'viewer', 'user' => 'viewer',
|
||||||
|
default => 'viewer',
|
||||||
|
};
|
||||||
|
if (in_array($role, $validRoles, true)) {
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Services\RbacSeeder;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$seeder = app(RbacSeeder::class);
|
||||||
|
$seeder->seedPermissions();
|
||||||
|
|
||||||
|
Company::query()->each(function (Company $company) use ($seeder) {
|
||||||
|
$seeder->seedTenantRoles($company->id);
|
||||||
|
$seeder->syncUsersToRoles($company->id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Permissions/roles stay — dropping them would break access.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Auth\Permissions;
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\User;
|
||||||
|
use App\Services\RbacSeeder;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RbacTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Company $company;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||||
|
$this->company = Company::create([
|
||||||
|
'plan_id' => $plan->id, 'slug' => 'rbac-' . uniqid(),
|
||||||
|
'name' => 'RBAC Co', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
app(TenantManager::class)->setCurrent($this->company);
|
||||||
|
app(RbacSeeder::class)->seedTenantRoles($this->company->id);
|
||||||
|
app(PermissionRegistrar::class)->setPermissionsTeamId($this->company->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_seeder_creates_51_permissions(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals(51, Permission::where('guard_name', 'web')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_seeder_creates_7_roles_per_tenant(): void
|
||||||
|
{
|
||||||
|
$roles = Role::where('company_id', $this->company->id)->pluck('name')->toArray();
|
||||||
|
sort($roles);
|
||||||
|
$this->assertEquals(['accountant', 'admin', 'manager', 'mechanic', 'owner', 'receptionist', 'viewer'], $roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_owner_role_has_all_permissions(): void
|
||||||
|
{
|
||||||
|
$owner = Role::where('company_id', $this->company->id)->where('name', 'owner')->first();
|
||||||
|
$this->assertEquals(51, $owner->permissions->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mechanic_role_has_minimal_permissions(): void
|
||||||
|
{
|
||||||
|
$mechanic = Role::where('company_id', $this->company->id)->where('name', 'mechanic')->first();
|
||||||
|
$perms = $mechanic->permissions->pluck('name')->toArray();
|
||||||
|
$this->assertContains(Permissions::WORK_ORDERS_VIEW_OWN_ASSIGNED, $perms);
|
||||||
|
$this->assertContains(Permissions::INVENTORY_VIEW, $perms);
|
||||||
|
$this->assertNotContains(Permissions::WORK_ORDERS_VIEW_ALL, $perms);
|
||||||
|
$this->assertNotContains(Permissions::FINANCE_VIEW_OVERVIEW, $perms);
|
||||||
|
$this->assertNotContains(Permissions::ADMIN_USERS_MANAGE, $perms);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_accountant_can_see_finance_but_not_admin(): void
|
||||||
|
{
|
||||||
|
$accountant = Role::where('company_id', $this->company->id)->where('name', 'accountant')->first();
|
||||||
|
$perms = $accountant->permissions->pluck('name')->toArray();
|
||||||
|
$this->assertContains(Permissions::FINANCE_VIEW_OVERVIEW, $perms);
|
||||||
|
$this->assertContains(Permissions::FINANCE_VIEW_PL, $perms);
|
||||||
|
$this->assertContains(Permissions::SALARIES_CALCULATE, $perms);
|
||||||
|
$this->assertNotContains(Permissions::ADMIN_USERS_MANAGE, $perms);
|
||||||
|
$this->assertNotContains(Permissions::WORK_ORDERS_DELETE, $perms);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_method_returns_true_when_role_has_permission(): void
|
||||||
|
{
|
||||||
|
$user = User::create(['name' => 'M', 'email' => 'm-' . uniqid() . '@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']);
|
||||||
|
$user->syncRoles(['mechanic']);
|
||||||
|
|
||||||
|
$this->assertTrue($user->canDo(Permissions::WORK_ORDERS_VIEW_OWN_ASSIGNED));
|
||||||
|
$this->assertFalse($user->canDo(Permissions::FINANCE_VIEW_OVERVIEW));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_bypasses_permission_check(): void
|
||||||
|
{
|
||||||
|
$admin = User::create(['name' => 'A', 'email' => 'a-' . uniqid() . '@e.com', 'password' => bcrypt('x'), 'role' => 'admin', 'status' => 'active']);
|
||||||
|
$admin->syncRoles(['admin']);
|
||||||
|
|
||||||
|
// Admin gets the bypass even if a permission is not explicitly granted
|
||||||
|
$this->assertTrue($admin->canDo('some.permission.that.does.not.exist'));
|
||||||
|
$this->assertTrue($admin->canDo(Permissions::FINANCE_DELETE_PAYMENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_owner_helper_returns_true_for_owner_role_user(): void
|
||||||
|
{
|
||||||
|
$user = User::create(['name' => 'O', 'email' => 'o-' . uniqid() . '@e.com', 'password' => bcrypt('x'), 'role' => 'owner', 'status' => 'active']);
|
||||||
|
$this->assertTrue($user->isOwner());
|
||||||
|
$this->assertTrue($user->isAdmin()); // owner counts as admin for canDo bypass
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sync_users_to_roles_maps_legacy_role_strings(): void
|
||||||
|
{
|
||||||
|
$u1 = User::create(['name' => 'X', 'email' => 'x@e.com', 'password' => bcrypt('x'), 'role' => 'parts_manager', 'status' => 'active']);
|
||||||
|
$u2 = User::create(['name' => 'Y', 'email' => 'y@e.com', 'password' => bcrypt('x'), 'role' => 'master', 'status' => 'active']);
|
||||||
|
$u3 = User::create(['name' => 'Z', 'email' => 'z@e.com', 'password' => bcrypt('x'), 'role' => 'user', 'status' => 'active']);
|
||||||
|
|
||||||
|
app(RbacSeeder::class)->syncUsersToRoles($this->company->id);
|
||||||
|
|
||||||
|
$u1->refresh(); $u2->refresh(); $u3->refresh();
|
||||||
|
// parts_manager → manager
|
||||||
|
$this->assertTrue($u1->hasRole('manager'));
|
||||||
|
// master → mechanic
|
||||||
|
$this->assertTrue($u2->hasRole('mechanic'));
|
||||||
|
// user → viewer
|
||||||
|
$this->assertTrue($u3->hasRole('viewer'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_two_factor_helper_reflects_app_authentication_secret(): void
|
||||||
|
{
|
||||||
|
$user = User::create(['name' => 'T', 'email' => 't@e.com', 'password' => bcrypt('x'), 'role' => 'admin', 'status' => 'active']);
|
||||||
|
$this->assertFalse($user->hasTwoFactorEnabled());
|
||||||
|
|
||||||
|
$user->saveAppAuthenticationSecret('FAKEBASE32SECRET====');
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertTrue($user->hasTwoFactorEnabled());
|
||||||
|
|
||||||
|
$user->saveAppAuthenticationSecret(null);
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertFalse($user->hasTwoFactorEnabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user