'integer', 'location_id' => 'integer', 'company_id' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', ]; /** * Model validation rules * * @var array */ protected $rules = [ 'first_name' => 'required|string|max:191', 'last_name' => 'nullable|string|max:191', 'display_name' => 'nullable|string|max:191', 'username' => 'required|string|min:1|unique_undeleted|max:191', 'email' => 'email|nullable|max:191', 'password' => 'required|min:8', 'locale' => 'max:10|nullable', 'website' => 'url|nullable|max:191', 'manager_id' => 'nullable|exists:users,id|cant_manage_self', 'location_id' => 'exists:locations,id|nullable|fmcs_location', 'start_date' => 'nullable|date_format:Y-m-d', 'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date', 'autoassign_licenses' => 'boolean', 'address' => 'nullable|string|max:191', 'city' => 'nullable|string|max:191', 'state' => 'nullable|string|max:191', 'country' => 'min:2|max:191|nullable', 'zip' => 'max:10|nullable', 'vip' => 'boolean', 'remote' => 'boolean', 'activated' => 'boolean', ]; /** * The attributes that should be included when searching the model. * * @var array */ protected $searchableAttributes = [ 'address', 'city', 'country', 'display_name', 'email', 'employee_num', 'first_name', 'jobtitle', 'last_name', 'locale', 'mobile', 'notes', 'phone', 'state', 'username', 'website', 'zip', ]; /** * The relations and their attributes that should be included when searching the model. * * @var array */ protected $searchableRelations = [ 'userloc' => ['name', 'address', 'address2', 'city', 'state', 'zip'], 'department' => ['name'], 'groups' => ['name'], 'companies' => ['name'], 'manager' => ['first_name', 'last_name', 'username', 'display_name'], 'adminuser' => ['first_name', 'last_name', 'display_name'], ]; protected $searchableCounts = [ 'accessories_count', 'assets_count', 'licenses_count', 'consumables_count', 'accessories_count', 'manages_users_count', 'manages_locations_count', ]; /** * Virtual column aliases that map a single filter key to a set of real columns * searched via CONCAT (SQL) so that, for example, filtering by "name" searches * across both first_name and last_name together. * * Because "name" is not a real column on the users table we cannot add it to * $searchableAttributes; this map bridges that gap for structured filter queries. * * @var array> */ protected $searchableVirtualColumns = [ 'name' => ['first_name', 'last_name'], ]; /** * Maps filter/API keys to the actual Eloquent relation names used in * $searchableRelations. The User model uses "userloc" as its location * relation name (to avoid a collision with the framework's own "location" * magic), but every consumer — UI and API alike — sends the key "location". * * @var array */ protected $searchableRelationAliases = [ 'location' => 'userloc', ]; /** * Narrow structured-filter relation columns for specific UI/API filter keys. * * The advanced-search "location" field represents the location name, so * structured filters should target only userloc.name (not address/city/etc). * * @var array> */ protected $searchableRelationFilterColumns = [ 'location' => ['name'], ]; /** * This sets the name property on the user. It's not a real field in the database * (since we use first_name and last_name), but the Laravel mailable method * uses this to determine the name of the user to send emails to. * * We only have to do this on the User model and no other models because other * first-class objects have a name field. * * @return void */ public $name; protected static function boot() { parent::boot(); static::retrieved( function ($user) { $user->name = $user->getFullNameAttribute(); } ); } protected static function booted(): void { // Bridge for factories/seeders that still set company_id directly: ensure // that company appears in the pivot so FMCS scoping works correctly. // Application code (controllers, importers) writes only to the pivot. static::created(function (User $user) { if ($user->company_id) { $user->companies()->syncWithoutDetaching([$user->company_id]); } }); static::forceDeleted(function (User $user) { CheckoutRequest::where(['user_id' => $user->id])->forceDelete(); $user->purgeAssociatedPassportTokens(); }); static::softDeleted(function (User $user) { CheckoutRequest::where(['user_id' => $user->id])->delete(); $user->revokeAssociatedPassportTokens(); }); } /** * Revoke all Passport access/refresh tokens associated with this user. */ private function revokeAssociatedPassportTokens(): void { $accessTokenIds = DB::table('oauth_access_tokens') ->where('user_id', $this->id) ->pluck('id'); if ($accessTokenIds->isEmpty()) { return; } DB::table('oauth_access_tokens') ->whereIn('id', $accessTokenIds) ->update(['revoked' => true]); DB::table('oauth_refresh_tokens') ->whereIn('access_token_id', $accessTokenIds) ->update(['revoked' => true]); } /** * Hard-delete all Passport access/refresh tokens associated with this user. */ private function purgeAssociatedPassportTokens(): void { $accessTokenIds = DB::table('oauth_access_tokens') ->where('user_id', $this->id) ->pluck('id'); if ($accessTokenIds->isNotEmpty()) { DB::table('oauth_refresh_tokens') ->whereIn('access_token_id', $accessTokenIds) ->delete(); } DB::table('oauth_access_tokens') ->where('user_id', $this->id) ->delete(); } /** * This overrides the SnipeModel displayName accessor to return the full name if display_name is not set * * @see SnipeModel::displayName() */ protected function displayName(): Attribute { return Attribute::make( get: fn (mixed $value) => ($value !== null && $value !== '') ? $value : $this->getFullNameAttribute(), ); } public function isAvatarExternal(): bool { // Check if it's a google avatar or some external avatar if (Str::startsWith($this->avatar, ['http://', 'https://'])) { return true; } return false; } public function hasIndividualPermissions() { $permissions = []; if (is_object($this->permissions)) { $permissions = json_decode(json_encode($this->permissions), true); } if (is_string($this->permissions)) { $permissions = json_decode($this->permissions, true); } if (($permissions) && (is_array($permissions))) { foreach ($permissions as $permission) { if ($permission != 0) { return true; } } } return false; } /** * Build a list of effective user permissions grouped by permission section. * * Includes explicit denials from user or group permissions so the UI can * show both allowed and denied entries. * * This is kind of duplicative from the other permission-checking methods, but it allows us to build a * list of permissions for display purposes without having to do a lot of super-confusing and * redundant checks in the UI layer. * * This will likely go away once we refactor the permissions to be in a database table instead of the * stupiud config file. */ public function getEffectivePermissionsBySection(): array { $displayablePermissions = collect(config('permissions')) ->map(static fn (array $permissions): array => array_values(array_filter($permissions, static fn (array $permission): bool => ($permission['display'] ?? false) === true))) ->all(); $configuredPermissions = collect($displayablePermissions) ->flatMap(static function (array $permissions, string $section) { return collect($permissions)->map(static function (array $permission) use ($section): array { return [ 'section' => $section, 'permission' => $permission['permission'], ]; }); }) ->unique('permission') ->values(); $directPermissions = $this->decodePermissions(); $directPermissions = is_array($directPermissions) ? $directPermissions : []; $groupGrantsByPermission = []; $groupDenialsByPermission = []; foreach ($this->groups as $group) { $groupPermissions = $group->decodePermissions(); if (! is_array($groupPermissions)) { continue; } foreach ($groupPermissions as $permissionKey => $permissionValue) { if ((int) $permissionValue === 1) { $groupGrantsByPermission[$permissionKey][] = $group->name; } elseif ((int) $permissionValue === -1) { $groupDenialsByPermission[$permissionKey][] = $group->name; } } } $effectiveBySection = []; foreach ($configuredPermissions as $permissionConfig) { $permissionKey = $permissionConfig['permission']; $directPermissionValue = (int) ($directPermissions[$permissionKey] ?? 0); $isAllowed = $this->hasAccess($permissionKey); $isDenied = ($directPermissionValue === -1) || ((count($groupDenialsByPermission[$permissionKey] ?? []) > 0) && ! $isAllowed); if (! $isAllowed && ! $isDenied) { continue; } $status = $isDenied ? 'denied' : 'allowed'; $source = 'group'; $sourceGroups = $isDenied ? ($groupDenialsByPermission[$permissionKey] ?? []) : ($groupGrantsByPermission[$permissionKey] ?? []); if ($isDenied && $directPermissionValue === -1) { $source = 'individual'; $sourceGroups = []; } elseif ($this->isSuperUser()) { $source = 'superuser'; $sourceGroups = []; } elseif (! $isDenied && $directPermissionValue === 1) { $source = 'individual'; $sourceGroups = []; } $effectiveBySection[$permissionConfig['section']][] = [ 'permission' => $permissionKey, 'status' => $status, 'source' => $source, 'groups' => array_values(array_unique($sourceGroups)), 'source_label' => $this->buildPermissionSourceLabel( status: $status, source: $source, sourceGroups: $sourceGroups ), ]; } return $effectiveBySection; } /** * Build a compact source label for a permission entry. */ private function buildPermissionSourceLabel(string $status, string $source, array $sourceGroups = []): string { $statusLabel = $status === 'denied' ? 'Denied' : 'Allowed'; $sourceLabel = match ($source) { 'individual' => 'Individual', 'superuser' => 'Superuser', default => 'Group', }; if ($sourceGroups === []) { return $statusLabel.' ('.$sourceLabel.')'; } return $statusLabel.' ('.$sourceLabel.'): '.implode(', ', array_values(array_unique($sourceGroups))); } /** * Internally check the user permission for the given section * * @return bool */ protected function checkPermissionSection($section) { $user_groups = $this->groups; if (($this->permissions == '') && (count($user_groups) == 0)) { return false; } $user_permissions = $this->permissions; if (is_object($this->permissions)) { $user_permissions = json_decode(json_encode($this->permissions), true); } if (is_string($this->permissions)) { $user_permissions = json_decode($this->permissions, true); } $is_user_section_permissions_set = ($user_permissions != '') && array_key_exists($section, $user_permissions); // If the user is explicitly granted, return true if ($is_user_section_permissions_set && ($user_permissions[$section] == '1')) { return true; } // If the user is explicitly denied, return false if ($is_user_section_permissions_set && ($user_permissions[$section] == '-1')) { return false; } // Loop through the groups to see if any of them grant this permission foreach ($user_groups as $user_group) { $group_permissions = (array) json_decode($user_group->permissions, true); if (((array_key_exists($section, $group_permissions)) && ($group_permissions[$section] == '1'))) { return true; } } return false; } /** * Check user permissions * * Parses the user and group permission masks to see if the user * is authorized to do the thing * * @author A. Gianotto * * @since [v1.0] * * @return bool */ public function hasAccess($section) { if ($this->isSuperUser()) { return true; } return $this->checkPermissionSection($section); } /** * Checks if the user is a SuperUser * * @author A. Gianotto * * @since [v1.0] * * @return bool */ public function isSuperUser() { return $this->checkPermissionSection('superuser'); } /** * Checks if the user is an admin * * @author A. Gianotto * * @since [v8.1.18] * * @return bool */ public function isAdmin() { return $this->checkPermissionSection('admin'); } /** * Checks if the user can edit their own profile * * @author A. Gianotto * * @since [v6.3.4] */ public function canEditProfile(): bool { $setting = Setting::getSettings(); if ($setting->profile_edit == 1) { return true; } return false; } /** * Checks if the user is deletable * * @author A. Gianotto * * @since [v6.3.4] * * @return bool */ public function isDeletable() { return Gate::allows('delete', $this) && (($this->assets_count ?? $this->assets()->count()) === 0) && (($this->accessories_count ?? $this->accessories()->count()) === 0) && (($this->licenses_count ?? $this->licenses()->count()) === 0) && (($this->consumables_count ?? $this->consumables()->count()) === 0) && (($this->manages_users_count ?? $this->managesUsers()->count()) === 0) && (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0) && ($this->deleted_at == ''); } /** * Establishes the user -> company relationship * * @author A. Gianotto * * @since [v2.0] * * @return Relation */ public function company() { return $this->belongsTo(Company::class, 'company_id'); } public function companies(): BelongsToMany { return $this->belongsToMany(Company::class, 'company_user'); } /** * Returns whether an FMCS company check should allow this user to receive * an asset that belongs to the given company. * * - If the user has no company associations at all: returns true (no restriction). * - If the user has associations: returns true only when $companyId is among them. */ public function canReceiveFromCompany(int $companyId): bool { // Items with no company association are unrestricted — anyone can receive them. if (! $companyId) { return true; } // Query the pivot directly to avoid the Company model's FMCS global scope, // which would restrict results to the current actor's visible companies. $userCompanyIds = DB::table('company_user') ->where('user_id', $this->id) ->pluck('company_id'); if ($userCompanyIds->isEmpty()) { return (bool) Setting::getSettings()->null_company_is_floater; } return $userCompanyIds->contains($companyId); } /** * Returns all companies this user belongs to — union of the primary company_id * column and the many-to-many pivot — as a deduplicated Collection. * Used to scope FMCS dropdowns to companies the user is allowed to work with. */ public function allCompanies(): Collection { return $this->companies->unique('id')->values(); } /** * Sync company pivot membership and log the change if the set of companies changed. * * When called after $user->save() in the same request, UserObserver::updating() will * have already written an Actionlog row and stored its ID in $this->currentUpdateLogId. * In that case we merge the company change into that existing entry so that a single * edit session (field changes + company changes) produces one log row, not two. */ public function syncCompaniesWithLogging(array $companyIds): void { $oldIds = $this->companies()->orderBy('companies.id')->pluck('companies.id')->toArray(); $this->companies()->sync($companyIds); $newIds = $this->companies()->orderBy('companies.id')->pluck('companies.id')->toArray(); if ($oldIds === $newIds) { return; } $companyChange = ['companies' => ['old' => $oldIds, 'new' => $newIds]]; if ($this->currentUpdateLogId && ($existing = Actionlog::find($this->currentUpdateLogId))) { $meta = json_decode($existing->log_meta ?? '{}', true) ?: []; $existing->log_meta = json_encode(array_merge($meta, $companyChange)); $existing->save(); $this->currentUpdateLogId = null; return; } $logAction = new Actionlog; $logAction->item_type = static::class; $logAction->item_id = $this->id; $logAction->target_type = static::class; $logAction->target_id = $this->id; $logAction->created_at = date('Y-m-d H:i:s'); $logAction->created_by = auth()->id(); $logAction->log_meta = json_encode($companyChange); $logAction->logaction('update'); } /** * Establishes the user -> department relationship * * @author A. Gianotto * * @since [v4.0] * * @return Relation */ public function department() { return $this->belongsTo(Department::class, 'department_id'); } /** * Checks activated status * * @author A. Gianotto * * @since [v1.0] * * @return bool */ public function isActivated() { return $this->activated == 1; } /** * Returns the full name attribute * * @author A. Gianotto * * @since [v2.0] * * @return string */ public function getFullNameAttribute() { $setting = Setting::getSettings(); if ($setting?->name_display_format == 'last_first') { return ($this->last_name) ? $this->last_name.' '.$this->first_name : $this->first_name; } return $this->last_name ? $this->first_name.' '.$this->last_name : $this->first_name; } protected function linkLightColor(): Attribute { return Attribute::make( get: fn (?string $value) => CssColor::sanitize($value, '#296282'), ); } protected function linkDarkColor(): Attribute { return Attribute::make( get: fn (?string $value) => CssColor::sanitize($value, '#5fa4cc'), ); } protected function navLinkColor(): Attribute { return Attribute::make( get: fn (?string $value) => CssColor::sanitize($value, '#ffffff'), ); } /** * Establishes the user -> assets relationship * * @author A. Gianotto * * @since [v1.0] * * @return Relation */ public function assets() { return $this->morphMany(Asset::class, 'assigned', 'assigned_type', 'assigned_to')->withTrashed()->orderBy('id'); } /** * Establishes the user -> maintenances relationship * * This would only be used to return maintenances that this user * created. * * @author A. Gianotto * * @since [v4.0] * * @return Relation */ public function maintenances() { return $this->hasMany(Maintenance::class, 'user_id')->withTrashed(); } /** * Establishes the user -> accessories relationship * * @author A. Gianotto * * @since [v2.0] * * @return Relation */ public function accessories() { return $this->belongsToMany(Accessory::class, 'accessories_checkout', 'assigned_to', 'accessory_id') ->where('assigned_type', '=', 'App\Models\User') ->withPivot('id', 'created_at', 'note')->withTrashed()->orderBy('accessory_id'); } /** * Establishes the user -> consumables relationship * * @author A. Gianotto * * @since [v3.0] * * @return Relation */ public function consumables() { return $this->belongsToMany(Consumable::class, 'consumables_users', 'assigned_to', 'consumable_id')->withPivot('id', 'created_at', 'note')->withTrashed(); } /** * Establishes the user -> license seats relationship * * @author A. Gianotto * * @since [v1.0] * * @return Relation */ public function licenses() { return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at'); } public function directLicenses() { return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed(); } /** * Establishes the user -> reportTemplates relationship */ public function reportTemplates(): HasMany { return $this->hasMany(ReportTemplate::class, 'created_by'); } public function getImageUrl($path = null) { return $this->present()->gravatar(); } /** * Establishes a count of all items assigned * * @author J. Vinsmoke * * @since [v6.1] * * @return Relation */ public function allAssignedCount() { $assetsCount = $this->assets()->count(); $licensesCount = $this->licenses()->count(); $accessoriesCount = $this->accessories()->count(); $consumablesCount = $this->consumables()->count(); $totalCount = $assetsCount + $licensesCount + $accessoriesCount + $consumablesCount; return (int) $totalCount; } /** * Establishes the user -> actionlogs relationship * * @author A. Gianotto * * @since [v1.0] * * @return Relation */ public function userlog() { return $this->hasMany(Actionlog::class, 'target_id')->where('target_type', '=', self::class)->orderBy('created_at', 'DESC')->withTrashed(); } /** * Establishes the user -> location relationship * * Get the asset's location based on the assigned user * * @todo - this should be removed once we're sure we've switched it to location() * * @author A. Gianotto * * @since [v4.0] * * @return Relation */ public function userloc() { return $this->belongsTo(Location::class, 'location_id')->withTrashed(); } /** * Establishes the user -> location relationship * * @author A. Gianotto * * @since [v3.0] * * @return Relation */ public function location() { return $this->belongsTo(Location::class, 'location_id')->withTrashed(); } /** * Establishes the user -> manager relationship * * @author A. Gianotto * * @since [v4.0] * * @return Relation */ public function manager() { return $this->belongsTo(self::class, 'manager_id')->withTrashed(); } /** * Establishes the user -> managed users relationship * * @author A. Gianotto * * @since [v6.4.1] * * @return Relation */ public function managesUsers() { return $this->hasMany(User::class, 'manager_id'); } /** * Establishes the user -> managed locations relationship * * @author A. Gianotto * * @since [v4.0] * * @return Relation */ public function managedLocations() { return $this->hasMany(Location::class, 'manager_id'); } /** * Establishes the user -> groups relationship * * @author A. Gianotto * * @since [v1.0] * * @return Relation */ public function groups() { return $this->belongsToMany(Group::class, 'users_groups'); } /** * Establishes the user -> assets relationship * * @author A. Gianotto * * @since [v4.0] * * @return Relation */ public function assetlog() { return $this->hasMany(Asset::class, 'id')->withTrashed(); } /** * Establishes the user -> acceptances relationship * * @author A. Gianotto * * @since [v7.0.7] * * @return Relation */ public function acceptances() { return $this->hasMany(Actionlog::class, 'target_id') ->where('target_type', self::class) ->where('action_type', '=', 'accepted') ->orderBy('created_at', 'desc'); } /** * Get all assigned items that still have a pending acceptance for this user. */ public function getAssignedItemsWithPendingAcceptance(): Collection { return CheckoutAcceptance::query() ->forUser($this) ->pending() ->with('checkoutable') ->get() ->map(fn (CheckoutAcceptance $acceptance) => $acceptance->checkoutable) ->filter() ->unique(fn ($item) => $item::class.':'.$item->getKey()) ->values(); } /** * Establishes the user -> eula relationship * * @return Relation * * @since [v8.1.16] * * @author [Godfrey Martinez] [] */ public function eulas() { return $this->hasMany(Actionlog::class, 'target_id') ->with('item') ->select(['id', 'target_id', 'target_type', 'action_type', 'filename', 'accept_signature', 'created_at', 'note', 'item_id', 'item_type']) ->where('target_type', self::class) ->where('action_type', 'accepted') ->whereNotNull('filename') ->whereNotNull('accept_signature') ->orderBy('created_at', 'desc'); } /** * Establishes the user -> requested assets relationship * * @author A. Gianotto * * @since [v2.0] * * @return Relation */ public function checkoutRequests() { return $this->belongsToMany(Asset::class, 'checkout_requests', 'user_id', 'requestable_id')->whereNull('canceled_at'); } /** * Set a common string when the user has been imported/synced from: * * - LDAP without password syncing * - SCIM * - CSV import where no password was provided * * @author A. Gianotto * * @since [v6.2.0] * * @return string */ public function noPassword() { return '*** NO PASSWORD ***'; } /** * Query builder scope to return NOT-deleted users * * @author A. Gianotto * * @since [v2.0] * * @param string $query * @return \Illuminate\Database\Query\Builder */ public function scopeGetNotDeleted($query) { return $query->whereNull('deleted_at'); } /** * Query builder scope to return users by email or username * * @author A. Gianotto * * @since [v2.0] * * @param string $query * @param string $user_username * @param string $user_email * @return \Illuminate\Database\Query\Builder */ public function scopeMatchEmailOrUsername($query, $user_username, $user_email) { return $query->where('email', '=', $user_email) ->orWhere('username', '=', $user_username) ->orWhere('username', '=', $user_email); } /** * Generate email from full name * * @author A. Gianotto * * @since [v2.0] * * @param string $query * @return string */ public static function generateEmailFromFullName($name) { $username = self::generateFormattedNameFromFullName($name, Setting::getSettings()->email_format); return $username['username'].'@'.Setting::getSettings()->email_domain; } public static function generateFormattedNameFromFullName($users_name, $format = 'filastname') { // If there was only one name given if (strpos($users_name, ' ') === false) { $first_name = $users_name; $last_name = ''; $username = $users_name; } else { [$first_name, $last_name] = explode(' ', $users_name, 2); // Assume filastname by default $username = str_slug(substr($first_name, 0, 1).$last_name); if ($format == 'firstname.lastname') { $username = str_slug($first_name).'.'.str_slug($last_name); } elseif ($format == 'lastnamefirstinitial') { $username = str_slug($last_name.substr($first_name, 0, 1)); } elseif ($format == 'firstintial.lastname') { $username = substr($first_name, 0, 1).'.'.str_slug($last_name); } elseif ($format == 'firstname_lastname') { $username = str_slug($first_name).'_'.str_slug($last_name); } elseif ($format == 'firstname') { $username = str_slug($first_name); } elseif ($format == 'lastname') { $username = str_slug($last_name); } elseif ($format == 'firstinitial.lastname') { $username = str_slug(substr($first_name, 0, 1).'.'.str_slug($last_name)); } elseif ($format == 'lastname_firstinitial') { $username = str_slug($last_name).'_'.str_slug(substr($first_name, 0, 1)); } elseif ($format == 'lastname.firstinitial') { $username = str_slug($last_name).'.'.str_slug(substr($first_name, 0, 1)); } elseif ($format == 'firstnamelastname') { $username = str_slug($first_name).str_slug($last_name); } elseif ($format == 'firstnamelastinitial') { $username = str_slug(($first_name.substr($last_name, 0, 1))); } elseif ($format == 'lastname.firstname') { $username = str_slug($last_name).'.'.str_slug($first_name); } } $user['first_name'] = $first_name; $user['last_name'] = $last_name; $user['username'] = strtolower($username); return $user; } /** * Check whether two-factor authorization is requiredfor this user * * 0 = 2FA disabled * 1 = 2FA optional * 2 = 2FA universally required * * @author [A. Gianotto] [] * * @since [v4.0] * * @return bool */ public function two_factor_active() { // If the 2FA is optional and the user has opted in if ((Setting::getSettings()->two_factor_enabled == '1') && ($this->two_factor_optin == '1')) { return true; } // If the 2FA is required for everyone so is implicitly active elseif (Setting::getSettings()->two_factor_enabled == '2') { return true; } return false; } /** * Check whether two-factor authorization is required and the user has activated it * and enrolled a device * * 0 = 2FA disabled * 1 = 2FA optional * 2 = 2FA universally required * * @author [A. Gianotto] [] * * @since [v4.6.14] * * @return bool */ public function two_factor_active_and_enrolled() { // If the 2FA is optional and the user has opted in and is enrolled if ((Setting::getSettings()->two_factor_enabled == '1') && ($this->two_factor_optin == '1') && ($this->two_factor_enrolled == '1')) { return true; } // If the 2FA is required for everyone and the user has enrolled elseif ((Setting::getSettings()->two_factor_enabled == '2') && ($this->two_factor_enrolled)) { return true; } return false; } /** * Get the admin user who created this user * * @author [A. Gianotto] [] * * @since [v6.0.5] * * @return Relation */ public function createdBy() { return $this->belongsTo(User::class, 'created_by')->withTrashed(); } /** * Decode JSON permissions into array * * @author A. Gianotto * * @since [v1.0] * * @return array | \stdClass */ public function decodePermissions() { // If the permissions are an array, convert it to JSON if (is_array($this->permissions)) { $this->permissions = json_encode($this->permissions); } $permissions = json_decode($this->permissions ?? '{}', JSON_OBJECT_AS_ARRAY); // Otherwise, loop through the permissions and cast the values as integers if ((is_array($permissions)) && ($permissions)) { foreach ($permissions as $permission => $value) { if (! is_int($permission)) { $permissions[$permission] = (int) $value; } else { \Log::info('Weird data here - skipping it'); unset($permissions[$permission]); } } return $permissions ?: new \stdClass; } return new \stdClass; } /** * Query builder scope to search user by name with spaces in it. * We don't use the advancedTextSearch() scope because that searches * all of the relations as well, which is more than what we need. * * @param \Illuminate\Database\Query\Builder $query Query builder instance * @param array $terms The search terms * @return \Illuminate\Database\Query\Builder */ public function scopeSimpleNameSearch($query, $search) { return $query->where('first_name', 'LIKE', '%'.$search.'%') ->orWhere('last_name', 'LIKE', '%'.$search.'%') ->orWhere('display_name', 'LIKE', '%'.$search.'%') ->orWhereMultipleColumns( [ 'users.first_name', 'users.last_name', ], $search ); } /** * Run additional, advanced searches. * * @param \Illuminate\Database\Query\Builder $query Query builder instance * @param array $terms The search terms * @return Builder */ public function advancedTextSearch(Builder $query, array $terms) { foreach ($terms as $term) { $query->orWhereMultipleColumns( [ 'users.first_name', 'users.last_name', ], $term ); } return $query; } /** * Query builder scope to return users by group * * @param \Illuminate\Database\Query\Builder $query Query builder instance * @param int $id * @return \Illuminate\Database\Query\Builder */ public function scopeByGroup($query, $id) { return $query->whereHas( 'groups', function ($query) use ($id) { $query->where('permission_groups.id', '=', $id); } ); } /** * Return only admins and superusers * * @param \Illuminate\Database\Query\Builder $query Query builder instance */ public function scopeOnlySuperAdmins($query) { return $query->where('users.permissions', 'LIKE', '%"superuser":"1"%') ->orWhere('users.permissions', 'LIKE', '%"superuser":1%') ->orWhereHas( 'groups', function ($query) { $query->where('permission_groups.permissions', 'LIKE', '%"superuser":"1"%') ->orWhere('permission_groups.permissions', 'LIKE', '%"superuser":1%'); } ); } /** * Return only admins and superusers * * @param \Illuminate\Database\Query\Builder $query Query builder instance */ public function scopeOnlyAdminsAndSuperAdmins($query) { return $query->where('users.permissions', 'LIKE', '%"superuser":"1"%') ->orWhere('users.permissions', 'LIKE', '%"superuser":1%') ->orWhere('users.permissions', 'LIKE', '%"admin":1%') ->orWhere('users.permissions', 'LIKE', '%"admin":"1"%') ->orWhereHas( 'groups', function ($query) { $query->where('permission_groups.permissions', 'LIKE', '%"superuser":"1"%') ->orWhere('permission_groups.permissions', 'LIKE', '%"superuser":1%') ->orWhere('permission_groups.permissions', 'LIKE', '%"admin":1%') ->orWhere('permission_groups.permissions', 'LIKE', '%"admin":"1"%'); } ); } /** * Query builder scope to order on manager * * @param \Illuminate\Database\Query\Builder $query Query builder instance * @param string $order Order * @return \Illuminate\Database\Query\Builder Modified query builder */ public function scopeOrderManager($query, $order) { // Left join here, or it will only return results with parents return $query->leftJoin('users as users_manager', 'users.manager_id', '=', 'users_manager.id')->orderBy('users_manager.first_name', $order)->orderBy('users_manager.last_name', $order); } /** * Query builder scope to order on company * * @param \Illuminate\Database\Query\Builder $query Query builder instance * @param string $order Order * @return \Illuminate\Database\Query\Builder Modified query builder */ public function scopeOrderLocation($query, $order) { return $query->leftJoin('locations as locations_users', 'users.location_id', '=', 'locations_users.id')->orderBy('locations_users.name', $order); } /** * Query builder scope to order on department * * @param \Illuminate\Database\Query\Builder $query Query builder instance * @param string $order Order * @return \Illuminate\Database\Query\Builder Modified query builder */ public function scopeOrderDepartment($query, $order) { return $query->leftJoin('departments as departments_users', 'users.department_id', '=', 'departments_users.id')->orderBy('departments_users.name', $order); } /** * Query builder scope to order on admin user * * @param \Illuminate\Database\Query\Builder $query Query builder instance * @param string $order Order * @return \Illuminate\Database\Query\Builder Modified query builder */ public function scopeOrderByCreatedBy($query, $order) { // Left join here, or it will only return results with parents return $query->leftJoin('users as admin_user', 'users.created_by', '=', 'admin_user.id') ->orderBy('admin_user.first_name', $order) ->orderBy('admin_user.last_name', $order); } /** * Query builder scope to order on company * * @param Illuminate\Database\Query\Builder $query Query builder instance * @param text $order Order * @return Illuminate\Database\Query\Builder Modified query builder */ public function scopeOrderCompany($query, $order) { $sub = DB::table('company_user') ->join('companies', 'companies.id', '=', 'company_user.company_id') ->select('company_user.user_id', DB::raw('MIN(companies.name) as min_company_name')) ->groupBy('company_user.user_id'); return $query ->leftJoinSub($sub, 'companies_sort', 'companies_sort.user_id', '=', 'users.id') ->orderBy('companies_sort.min_company_name', $order); } /** * Get the preferred locale for the user. * * This uses the HasLocalePreference contract to determine the user's preferred locale, * used by Laravel's mail system to determine the locale for sending emails. * https://laravel.com/docs/11.x/mail#user-preferred-locales */ public function preferredLocale(): string { return $this->locale ?? Setting::getSettings()->locale ?? config('app.locale'); } public function getUserTotalCost() { $asset_cost = 0; $license_cost = 0; $accessory_cost = 0; foreach ($this->assets as $asset) { $asset_cost += $asset->purchase_cost; $this->asset_cost = $asset_cost; } foreach ($this->licenses as $license) { $license_cost += $license->purchase_cost; $this->license_cost = $license_cost; } foreach ($this->accessories as $accessory) { $accessory_cost += $accessory->purchase_cost; $this->accessory_cost = $accessory_cost; } $this->total_user_cost = ($asset_cost + $accessory_cost + $license_cost); return $this; } public function scopeUserLocation($query, $location, $search) { return $query->where('location_id', '=', $location) ->where('users.first_name', 'LIKE', '%'.$search.'%') ->orWhere('users.email', 'LIKE', '%'.$search.'%') ->orWhere('users.last_name', 'LIKE', '%'.$search.'%') ->orWhere('users.permissions', 'LIKE', '%'.$search.'%') ->orWhere('users.country', 'LIKE', '%'.$search.'%') ->orWhere('users.phone', 'LIKE', '%'.$search.'%') ->orWhere('users.jobtitle', 'LIKE', '%'.$search.'%') ->orWhere('users.employee_num', 'LIKE', '%'.$search.'%') ->orWhere('users.username', 'LIKE', '%'.$search.'%') ->orWhere('users.display_name', 'LIKE', '%'.$search.'%') ->orwhereRaw('CONCAT(users.first_name," ",users.last_name) LIKE \''.$search.'%\''); } public function scopeWithInventoryRelations($query, int $id, bool $withLicenses = true, bool $withAccessories = true, bool $withConsumables = true) { $with = [ 'assets.log' => fn ($query) => $query->withTrashed() ->where('target_type', User::class) ->where('target_id', $id) ->where('action_type', 'accepted'), 'assets.defaultLoc', 'assets.location', 'assets.model.category', 'assets.assignedAssets.log' => fn ($query) => $query->withTrashed() ->where('target_type', User::class) ->where('target_id', $id) ->where('action_type', 'accepted'), 'assets.assignedAssets.assignedTo', 'assets.assignedAssets.defaultLoc', 'assets.assignedAssets.location', 'assets.assignedAssets.model.category', 'assets.components.category', ]; if ($withLicenses) { $with = array_merge($with, [ 'assets.licenses', 'assets.licenses.category', 'directLicenses.category', 'licenses.category', ]); } if ($withAccessories) { $with = array_merge($with, [ 'assets.assignedAccessories', 'assets.assignedAccessories.accessory.category', 'accessories.log' => fn ($query) => $query->withTrashed() ->where('target_type', User::class) ->where('target_id', $id) ->where('action_type', 'accepted'), 'accessories.category', 'accessories.manufacturer', ]); } if ($withConsumables) { $with = array_merge($with, [ 'consumables.log' => fn ($query) => $query->withTrashed() ->where('target_type', User::class) ->where('target_id', $id) ->where('action_type', 'accepted'), 'consumables.category', 'consumables.manufacturer', ]); } return $query->where('id', $id)->with($with)->withTrashed(); } /** * Get all direct and indirect subordinates for this user. * * @return Collection */ public function getAllSubordinates() { $subordinates = collect(); $this->fetchSubordinatesRecursive($this, $subordinates); return $subordinates->unique('id'); } /** * Get all direct and indirect subordinates for this user, including self. * * @return Collection */ public function getAllSubordinatesIncludingSelf() { $subordinates = collect([$this]); $this->fetchSubordinatesRecursive($this, $subordinates); return $subordinates->unique('id'); } /** * Recursive helper function to fetch subordinates. */ protected function fetchSubordinatesRecursive(User $manager, Collection &$subs) { // Eager load 'managesUsers' to prevent N+1 queries in recursion $directSubordinates = $manager->managesUsers()->with('managesUsers')->get(); foreach ($directSubordinates as $directSubordinate) { // Add subordinate if not already in the collection if (! $subs->contains('id', $directSubordinate->id)) { $subs->push($directSubordinate); // Recursive call for this subordinate's subordinates $this->fetchSubordinatesRecursive($directSubordinate, $subs); } } } /** * Check if the current user is a direct or indirect manager of the given user. */ public function isManagerOf(User $userToCheck): bool { // Optimization: If it's the same user, they are not their own manager if ($this->id === $userToCheck->id) { return false; } // Eager load manager relationship to potentially reduce queries in the loop $manager = $userToCheck->load('manager')->manager; while ($manager) { if ($manager->id === $this->id) { return true; } // Move up the hierarchy (load relationship if not already loaded) $manager = $manager->load('manager')->manager; } return false; } }