'datetime', 'last_login_at' => 'datetime', 'email_authentication_at' => 'datetime', 'invited_at' => 'datetime', 'accepted_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']); } public function permissionOverrides(): HasMany { return $this->hasMany(UserPermissionOverride::class); } public function invitedBy(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(self::class, 'invited_by_id'); } public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(\App\Models\Central\Company::class); } /** * Permission check honoring (in order): * 1. Active deny-override → false * 2. Active grant-override → true * 3. Admin/owner bypass → true * 4. Standard role-based check */ public function canDo(string $permission): bool { $override = $this->activeOverrideFor($permission); if ($override) { if ($override->mode === 'deny') { $this->logDeniedIfSensitive($permission); return false; } if ($override->mode === 'grant') return true; } // Owner + admin bypass for permissions without explicit deny. if ($this->isAdmin()) return true; try { $allowed = $this->can($permission); if (! $allowed) $this->logDeniedIfSensitive($permission); return $allowed; } catch (\Throwable $e) { return false; } } private function activeOverrideFor(string $permissionSlug): ?UserPermissionOverride { return $this->permissionOverrides() ->whereHas('permission', fn ($q) => $q->where('name', $permissionSlug)) ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) ->first(); } /** Sensitive permissions whose deny we should record for audit. */ private const AUDITED_DENIALS = [ 'admin.users.manage', 'admin.roles.manage', 'admin.settings.edit', 'admin.backup.download', 'finance.delete_payment', 'finance.view_pl', 'salaries.mark_paid', 'salaries.view_all', 'work_orders.delete', 'work_orders.approve_discount_any', ]; private function logDeniedIfSensitive(string $permission): void { if (! in_array($permission, self::AUDITED_DENIALS, true)) return; try { activity('permissions') ->causedBy($this) ->withProperties(['permission' => $permission]) ->event('permission_denied') ->log("permission denied: $permission for user #{$this->id}"); } catch (\Throwable $e) { // activity-log may be misconfigured in some contexts — never let auth fail because of it. } } /** Has 2FA app authentication enabled (Filament native). */ public function hasTwoFactorEnabled(): bool { return $this->app_authentication_secret !== null; } /** Pending invitation (sent but not yet accepted). */ public function isPendingInvitation(): bool { return $this->invited_at !== null && $this->accepted_at === null; } /** * Create + send an invitation: generates a random token, marks invited_at, * and queues the email with the signed accept link. Idempotent — calling * again regenerates the token (useful for "resend invitation"). */ public function sendInvitation(?User $invitedBy = null): string { $token = bin2hex(random_bytes(32)); // 64 chars $this->forceFill([ 'invitation_token' => hash('sha256', $token), 'invited_at' => now(), 'invited_by_id' => $invitedBy?->id ?? auth()->id(), 'accepted_at' => null, 'status' => 'inactive', // can't login until accepted ])->saveQuietly(); \Illuminate\Support\Facades\Mail::to($this->email) ->queue(new \App\Mail\UserInvitationMail($this, $token)); return $token; // returned mainly for tests / API } public static function findByInvitationToken(string $rawToken): ?self { return self::where('invitation_token', hash('sha256', $rawToken))->first(); } public function acceptInvitation(string $password): void { $this->forceFill([ 'password' => $password, // hashed cast handles it 'invitation_token' => null, 'accepted_at' => now(), 'status' => 'active', 'email_verified_at' => now(), ])->save(); } 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(); } }