Files
avives 1f72193a64
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
2026-08-21 19:29:00 -06:00

457 lines
14 KiB
PHP

<?php namespace Backend\Models;
use Backend\Facades\Backend;
use Backend\Facades\BackendAuth;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Lang;
use Winter\Storm\Auth\AuthorizationException;
use Winter\Storm\Auth\Models\User as UserBase;
use Winter\Storm\Support\Facades\Mail;
/**
* Administrator user model
*
* @package winter\wn-backend-module
* @author Alexey Bobkov, Samuel Georges
*/
class User extends UserBase
{
use \Winter\Storm\Database\Traits\SoftDelete;
/**
* @var string The database table used by the model.
*/
protected $table = 'backend_users';
/**
* Validation rules
*/
public $rules = [
'email' => 'required|between:6,255|email|unique:backend_users',
'login' => 'required|between:2,255|unique:backend_users',
'password' => 'required:create|min:4|confirmed',
'password_confirmation' => 'required_with:password|min:4'
];
/**
* @var array Attributes that should be cast to dates
*/
protected $dates = [
'activated_at',
'last_login',
'created_at',
'updated_at',
'deleted_at',
];
/**
* Relations
*/
public $belongsToMany = [
'groups' => [UserGroup::class, 'table' => 'backend_users_groups', 'softDelete' => true]
];
public $belongsTo = [
'role' => UserRole::class
];
public $attachOne = [
'avatar' => \System\Models\File::class
];
public $hasMany = [
'throttle' => [UserThrottle::class, 'key' => 'user_id']
];
/**
* Purge attributes from data set.
*/
protected $purgeable = ['password_confirmation', 'send_invite'];
/**
* @var array List of attribute names which are json encoded and decoded from the database.
*/
protected $jsonable = ['permissions', 'metadata'];
/**
* @var string Login attribute
*/
public static $loginAttribute = 'login';
/**
* @var array<string> Relations on this model that require `backend.manage_users`
* to change on another user's record. Deliberately limited to the relations this
* model owns: authorization semantics for plugin-added relations belong to the
* plugin, which can append to this list or bind its own guard to the
* `model.relation.*` events.
*/
public array $permissionGuardedRelations = ['groups', 'avatar', 'throttle'];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
// Guard relation writes at the point they actually happen: direct
// attach()/detach(), deferred binding commits and queued relation
// syncs all funnel through these relation events.
$guard = function (string $relationName) {
$this->authorizeRelationChange($relationName);
};
$this->bindEvent('model.relation.beforeAttach', $guard);
$this->bindEvent('model.relation.beforeDetach', $guard);
$this->bindEvent('model.relation.beforeAdd', $guard);
$this->bindEvent('model.relation.beforeRemove', $guard);
}
/**
* @return string Returns the user's full name.
*/
public function getFullNameAttribute()
{
return trim($this->first_name . ' ' . $this->last_name);
}
/**
* Gets a code for when the user is persisted to a cookie or session which identifies the user.
* @return string
*/
public function getPersistCode()
{
// Option A: @todo config
// return parent::getPersistCode();
// Option B:
if (!$this->persist_code) {
return parent::getPersistCode();
}
return $this->persist_code;
}
/**
* Returns the public image file path to this user's avatar.
*/
public function getAvatarThumb($size = 25, $options = null)
{
if (is_string($options)) {
$options = ['default' => $options];
}
elseif (!is_array($options)) {
$options = [];
}
// Default is "mm" (Mystery man)
$default = array_get($options, 'default', 'mm');
if ($this->avatar) {
return $this->avatar->getThumb($size, $size, $options);
}
return '//www.gravatar.com/avatar/' .
md5(strtolower(trim($this->email))) .
'?s='. $size .
'&d='. urlencode($default);
}
/**
* Determine whether the given user (or the currently authenticated user)
* is authorized to manage this user record.
*
* Returns true when no user is provided and no user is authenticated (CLI/queue),
* or when the user has `backend.manage_users` and (if this record is a superuser)
* the user is also a superuser.
*/
public function canBeManagedByUser(?User $user = null): bool
{
$user = $user ?? BackendAuth::getUser();
if (!$user) {
return true;
}
if (!$user->hasAccess('backend.manage_users')) {
return false;
}
if (!$user->isSuperUser() && ($this->is_superuser || $this->getOriginal('is_superuser'))) {
return false;
}
return true;
}
/**
* Before create event — enforce authorization rules to prevent privilege escalation.
*/
public function beforeCreate()
{
$this->authorizeChange();
}
/**
* Before update event — enforce authorization rules to prevent privilege escalation.
*
* Bound to update rather than save so that a save with nothing to write (e.g. a
* pivot form submission re-saving an untouched related record) requires no
* permission: the update event only fires when attributes have actually changed,
* after purgeable attributes have been stripped. Relation writes (group
* membership, avatar) are guarded separately by authorizeRelationChange().
*/
public function beforeUpdate()
{
$this->authorizeChange();
}
/**
* Enforce authorization rules for a change to this record's attributes.
*
* @throws AuthorizationException if the current user lacks permission
*/
protected function authorizeChange(): void
{
$actor = BackendAuth::getUser();
if (!$actor) {
return;
}
$isCurrentUser = $this->exists && $actor->getKey() === $this->getKey();
if ($isCurrentUser && $this->isDirty(['role_id', 'is_superuser', 'permissions'])) {
throw new AuthorizationException(Lang::get('backend::lang.user.self_escalation_denied'));
}
if (!$isCurrentUser && !$this->canBeManagedByUser($actor)) {
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
}
}
/**
* Enforce authorization rules for a change to one of this record's relations.
*
* Only the relations listed in $permissionGuardedRelations are guarded, so
* plugin-added relations (e.g. records that reference a user) behave normally.
*
* Changes to your own record's guarded relations are allowed: groups do not
* carry permissions out of the box, so they are not an escalation vector.
*
* @throws AuthorizationException if the current user lacks permission
*/
protected function authorizeRelationChange(string $relationName): void
{
if (!in_array($relationName, $this->permissionGuardedRelations)) {
return;
}
$actor = BackendAuth::getUser();
if (!$actor) {
return;
}
$isCurrentUser = $this->exists && $actor->getKey() === $this->getKey();
if (!$isCurrentUser && !$this->canBeManagedByUser($actor)) {
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
}
}
/**
* Before delete event — enforce authorization rules.
*/
public function beforeDelete()
{
if (!$this->canBeManagedByUser()) {
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
}
}
/**
* Before restore event — enforce authorization rules.
*/
public function beforeRestore()
{
if (!$this->canBeManagedByUser()) {
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
}
}
/**
* After create event
* @return void
*/
public function afterCreate()
{
$this->restorePurgedValues();
if ($this->send_invite) {
$this->sendInvitation();
}
}
/**
* After login event
* @return void
*/
public function afterLogin()
{
parent::afterLogin();
/**
* @event backend.user.login
* Provides an opportunity to interact with the Backend User model after the user has logged in
*
* Example usage:
*
* Event::listen('backend.user.login', function ((\Backend\Models\User) $user) {
* Flash::success(sprintf('Welcome %s!', $user->getFullNameAttribute()));
* });
*
*/
Event::fire('backend.user.login', [$this]);
}
/**
* Sends an invitation to the user using template "backend::mail.invite".
* @return void
*/
public function sendInvitation()
{
$data = [
'name' => $this->full_name,
'login' => $this->login,
'password' => $this->getOriginalHashValue('password'),
'link' => Backend::url('backend'),
];
Mail::send('backend::mail.invite', $data, function ($message) {
$message->to($this->email, $this->full_name);
});
}
public function getGroupsOptions()
{
$result = [];
foreach (UserGroup::all() as $group) {
$result[$group->id] = [$group->name, $group->description];
}
return $result;
}
public function getRoleOptions()
{
$result = [];
foreach (UserRole::all() as $role) {
$result[$role->id] = [$role->name, $role->description];
}
return $result;
}
/**
* Check if the user is suspended.
* @return bool
*/
public function isSuspended()
{
return BackendAuth::findThrottleByUserId($this->id)->checkSuspended();
}
/**
* Remove the suspension on this user.
*
* @throws AuthorizationException if the current user lacks permission
*/
public function unsuspend()
{
if (!$this->canBeManagedByUser()) {
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
}
BackendAuth::findThrottleByUserId($this->id)->unsuspend();
}
/**
* Get a reset password code for this user.
*
* When called by an authenticated user targeting a different account,
* the actor must have `backend.manage_users` permission.
* Self-service resets (Auth controller restore flow) are allowed.
*
* @throws AuthorizationException if the current user lacks permission
*/
public function getResetPasswordCode()
{
$actor = BackendAuth::getUser();
if ($actor && $actor->getKey() !== $this->getKey() && !$this->canBeManagedByUser($actor)) {
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
}
return parent::getResetPasswordCode();
}
//
// Impersonation
//
/**
* Returns an array of merged permissions based on the user's individual permissions
* and their role permissions filtering out any permissions the impersonator doesn't
* have access to (if the current user is being impersonated)
*
* @return array
*/
public function getMergedPermissions()
{
if (!$this->mergedPermissions) {
$permissions = parent::getMergedPermissions();
// If the user is being impersonated filter out any permissions the impersonator doesn't have access to already
if (BackendAuth::isImpersonator()) {
$impersonator = BackendAuth::getImpersonator();
if ($impersonator && $impersonator !== $this) {
foreach ($permissions as $permission => $status) {
if (!$impersonator->hasAccess($permission)) {
unset($permissions[$permission]);
}
}
$this->mergedPermissions = $permissions;
}
}
}
return $this->mergedPermissions;
}
/**
* Check if this user can be impersonated by the provided impersonator
* Super users cannot be impersonated and all users cannot be impersonated unless there is an impersonator
* present and the impersonator has access to `backend.impersonate_users`, and the impersonator is not the
* user being impersonated
*
* @param \Winter\Storm\Auth\Models\User|false $impersonator The user attempting to impersonate this user, false when not available
* @return boolean
*/
public function canBeImpersonated($impersonator = false)
{
if (
$this->isSuperUser() ||
!$impersonator ||
!($impersonator instanceof static) ||
!$impersonator->hasAccess('backend.impersonate_users') ||
$impersonator === $this
) {
return false;
}
// Clear the merged permissions before the impersonation starts
// so that they are correct even if they had been loaded prior
// to the impersonation starting
$this->mergedPermissions = null;
return true;
}
}