Files
autocrm/app/Models/Tenant/User.php
T
Vasyka 58004b65c4 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>
2026-06-04 22:03:03 +00:00

136 lines
4.2 KiB
PHP

<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
/**
* Tenant-bound user. Belongs to exactly one Company.
* UNIQUE(company_id, email) — same email can exist in different tenants
* as completely separate accounts.
*/
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication
{
use BelongsToTenant, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes;
/** Spatie Permission scope key matches the team_foreign_key (company_id). */
protected $guard_name = 'web';
protected $fillable = [
'company_id', 'name', 'email', 'phone', 'avatar_url',
'role', 'status', 'locale',
'specialization', 'color', 'hourly_rate',
'email_verified_at', 'password', 'last_login_at',
'email_authentication_at',
'app_authentication_secret', 'app_authentication_recovery_codes',
];
protected $hidden = [
'password', 'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'email_authentication_at' => 'datetime',
'password' => 'hashed',
'app_authentication_secret' => 'encrypted',
'app_authentication_recovery_codes' => 'encrypted:array',
];
}
public function canAccessPanel(Panel $panel): bool
{
return $panel->getId() === 'tenant'
&& $this->status === 'active';
}
public function isAdmin(): bool
{
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
{
return $this->email_authentication_at !== null;
}
public function toggleEmailAuthentication(bool $condition): void
{
$this->forceFill([
'email_authentication_at' => $condition ? now() : null,
])->saveQuietly();
}
public function getAppAuthenticationSecret(): ?string
{
return $this->app_authentication_secret;
}
public function saveAppAuthenticationSecret(?string $secret): void
{
$this->forceFill(['app_authentication_secret' => $secret])->saveQuietly();
}
public function getAppAuthenticationHolderName(): string
{
return $this->email;
}
public function getAppAuthenticationRecoveryCodes(): ?array
{
return $this->app_authentication_recovery_codes;
}
public function saveAppAuthenticationRecoveryCodes(?array $codes): void
{
$this->forceFill(['app_authentication_recovery_codes' => $codes])->saveQuietly();
}
}