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',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user