From a58bd7f435ca89b2ea74d23d6553976824fabdf6 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sun, 30 Nov 2025 03:10:46 +0100 Subject: [PATCH] Update role stacking --- app/Console/Commands/ManageRoleStacking.php | 314 ++++++++++++++++++ .../Controllers/Admin/AdminUserController.php | 51 ++- app/Models/User.php | 290 +++++++++++++++- app/Models/UserRoleHistory.php | 130 ++++++++ ...dd_role_stacking_fields_to_users_table.php | 30 ++ ..._120001_create_user_role_history_table.php | 40 +++ resources/js/csp-safe.js | 58 ++++ resources/views/admin/users/edit.blade.php | 78 ++++- resources/views/admin/users/index.blade.php | 21 ++ resources/views/profile/index.blade.php | 29 ++ 10 files changed, 1020 insertions(+), 21 deletions(-) create mode 100644 app/Console/Commands/ManageRoleStacking.php create mode 100644 app/Models/UserRoleHistory.php create mode 100644 database/migrations/2025_11_29_120000_add_role_stacking_fields_to_users_table.php create mode 100644 database/migrations/2025_11_29_120001_create_user_role_history_table.php diff --git a/app/Console/Commands/ManageRoleStacking.php b/app/Console/Commands/ManageRoleStacking.php new file mode 100644 index 000000000..2badd1cbf --- /dev/null +++ b/app/Console/Commands/ManageRoleStacking.php @@ -0,0 +1,314 @@ +argument('action'); + + return match($action) { + 'list-pending' => $this->listPendingRoles(), + 'cancel-pending' => $this->cancelPendingRole(), + 'history' => $this->showHistory(), + 'activate-pending' => $this->activatePendingRoles(), + default => $this->handleUnknownAction($action), + }; + } + + /** + * Handle unknown action + */ + protected function handleUnknownAction(string $action): int + { + $this->error("Unknown action: {$action}"); + return 1; + } + + /** + * List all users with pending roles + */ + protected function listPendingRoles(): int + { + $users = User::whereNotNull('pending_roles_id') + ->whereNotNull('pending_role_start_date') + ->get(); + + if ($users->isEmpty()) { + $this->info('No users with pending roles found.'); + return 0; + } + + $this->info('Users with Pending Roles:'); + $this->newLine(); + + $headers = ['User ID', 'Username', 'Current Role', 'Pending Role', 'Activation Date', 'Time Until']; + $rows = []; + + foreach ($users as $user) { + $currentRole = $user->roles->first(); + $pendingRole = $user->getPendingRole(); + $activationDate = Carbon::parse($user->pending_role_start_date); + + $rows[] = [ + $user->id, + $user->username, + $currentRole ? $currentRole->name : 'None', + $pendingRole ? $pendingRole->name : 'Unknown', + $activationDate->format('Y-m-d H:i'), + $activationDate->diffForHumans(), + ]; + } + + $this->table($headers, $rows); + $this->info(sprintf('Total: %d users', count($rows))); + + return 0; + } + + /** + * Cancel pending role for user(s) + */ + protected function cancelPendingRole(): int + { + if ($this->option('all')) { + if (!$this->confirm('Are you sure you want to cancel ALL pending roles?')) { + $this->info('Operation cancelled.'); + return 0; + } + + $users = User::whereNotNull('pending_roles_id')->get(); + $count = 0; + + foreach ($users as $user) { + if ($user->cancelPendingRole()) { + $count++; + $this->info("Cancelled pending role for user: {$user->username}"); + } + } + + $this->info("Cancelled pending roles for {$count} users."); + return 0; + } + + $userId = $this->argument('user'); + if (!$userId) { + $this->error('User ID or username required. Use --all to cancel for all users.'); + return 1; + } + + $user = $this->findUser($userId); + if (!$user) { + return 1; + } + + if (!$user->hasPendingRole()) { + $this->info("User {$user->username} has no pending role."); + return 0; + } + + $pendingRole = $user->getPendingRole(); + if (!$this->confirm("Cancel pending role '{$pendingRole->name}' for user '{$user->username}'?")) { + $this->info('Operation cancelled.'); + return 0; + } + + if ($user->cancelPendingRole()) { + $this->info("Successfully cancelled pending role for user: {$user->username}"); + return 0; + } + + $this->error("Failed to cancel pending role."); + return 1; + } + + /** + * Show role change history for user(s) + */ + protected function showHistory(): int + { + $userId = $this->argument('user'); + if (!$userId) { + $this->error('User ID or username required.'); + return 1; + } + + $user = $this->findUser($userId); + if (!$user) { + return 1; + } + + $history = UserRoleHistory::getUserHistory($user->id); + + if ($history->isEmpty()) { + $this->info("No role change history found for user: {$user->username}"); + return 0; + } + + $this->info("Role Change History for: {$user->username} (ID: {$user->id})"); + $this->newLine(); + + $headers = ['Date', 'From Role', 'To Role', 'Type', 'Reason', 'Changed By']; + $rows = []; + + foreach ($history as $change) { + $oldRole = $change->old_role_id ? Role::find($change->old_role_id) : null; + $newRole = Role::find($change->new_role_id); + $changedBy = $change->changed_by ? User::find($change->changed_by) : null; + + $rows[] = [ + $change->effective_date->format('Y-m-d H:i'), + $oldRole ? $oldRole->name : 'None', + $newRole ? $newRole->name : 'Unknown', + $change->is_stacked ? 'Stacked' : 'Immediate', + $change->change_reason ?? 'N/A', + $changedBy ? $changedBy->username : 'System', + ]; + } + + $this->table($headers, $rows); + $this->info(sprintf('Total changes: %d', count($rows))); + + // Show stacked changes summary + $stackedCount = $history->where('is_stacked', true)->count(); + if ($stackedCount > 0) { + $this->newLine(); + $this->info("Stacked role changes: {$stackedCount}"); + } + + return 0; + } + + /** + * Manually activate pending roles (normally done automatically) + */ + protected function activatePendingRoles(): int + { + $this->warn('This command manually activates pending roles that are scheduled for activation.'); + $this->warn('Normally, this is done automatically by the scheduled task.'); + $this->newLine(); + + if (!$this->confirm('Do you want to continue?')) { + $this->info('Operation cancelled.'); + return 0; + } + + $now = Carbon::now(); + $users = User::whereNotNull('pending_roles_id') + ->whereNotNull('pending_role_start_date') + ->where('pending_role_start_date', '<=', $now) + ->get(); + + if ($users->isEmpty()) { + $this->info('No pending roles ready for activation.'); + return 0; + } + + $this->info(sprintf('Found %d pending roles ready for activation:', $users->count())); + $this->newLine(); + + $activated = 0; + $failed = 0; + + foreach ($users as $user) { + $pendingRole = $user->getPendingRole(); + $oldRole = $user->roles->first(); + + try { + // Activate the pending role + $user->update([ + 'roles_id' => $user->pending_roles_id, + 'pending_roles_id' => null, + 'pending_role_start_date' => null, + ]); + + if ($pendingRole) { + $user->syncRoles([$pendingRole->name]); + } + + // Record in history + UserRoleHistory::recordRoleChange( + userId: $user->id, + oldRoleId: $oldRole ? $oldRole->id : null, + newRoleId: $pendingRole ? $pendingRole->id : $user->roles_id, + oldExpiryDate: null, + newExpiryDate: null, + effectiveDate: $now, + isStacked: true, + changeReason: 'manual_activation', + changedBy: null + ); + + $this->info(sprintf( + '✓ Activated %s -> %s for user: %s', + $oldRole ? $oldRole->name : 'None', + $pendingRole ? $pendingRole->name : 'Unknown', + $user->username + )); + + $activated++; + } catch (\Exception $e) { + $this->error(sprintf( + '✗ Failed to activate role for user %s: %s', + $user->username, + $e->getMessage() + )); + $failed++; + } + } + + $this->newLine(); + $this->info("Activation complete:"); + $this->info(" Successful: {$activated}"); + if ($failed > 0) { + $this->error(" Failed: {$failed}"); + } + + return $failed > 0 ? 1 : 0; + } + + /** + * Find user by ID or username + */ + protected function findUser(string|int $identifier): ?User + { + $user = is_numeric($identifier) + ? User::find($identifier) + : User::where('username', $identifier)->first(); + + if (!$user) { + $this->error("User not found: {$identifier}"); + return null; + } + + return $user; + } +} + diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index 4643ef594..47e8fffb6 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -163,24 +163,63 @@ class AdminUserController extends BasePageController $ret = User::signUp($request->input('username'), $request->input('password'), $request->input('email'), '', $request->input('notes'), $invites, '', true, $request->input('role'), false); } else { $editedUser = User::find($request->input('id')); - // Use the current grabs value since it's read-only - $ret = User::updateUser($editedUser->id, $request->input('username'), $request->input('email'), $editedUser->grabs, $request->input('role'), $request->input('notes'), $request->input('invites'), ($request->has('movieview') ? 1 : 0), ($request->has('musicview') ? 1 : 0), ($request->has('gameview') ? 1 : 0), ($request->has('xxxview') ? 1 : 0), ($request->has('consoleview') ? 1 : 0), ($request->has('bookview') ? 1 : 0)); - if ($request->input('password') !== null) { - User::updatePassword($editedUser->id, $request->input('password')); + + // Check if role is changing and get stack preference + $roleChanged = $editedUser->roles_id != $request->input('role'); + $stackRole = $request->input('stack_role') ? true : false; // Check if checkbox is checked + $changedBy = auth()->check() ? auth()->id() : null; + + // Handle pending role cancellation + if ($request->has('cancel_pending_role') && $request->input('cancel_pending_role')) { + $editedUser->cancelPendingRole(); } - // Handle rolechangedate - update if has value, clear if empty + + // Handle rolechangedate - BEFORE role change to ensure expiry is cleared if needed if ($request->has('rolechangedate')) { $roleChangeDate = $request->input('rolechangedate'); if (! empty($roleChangeDate)) { User::updateUserRoleChangeDate($editedUser->id, $roleChangeDate); + $editedUser->refresh(); } else { // Clear the rolechangedate if empty string is provided $editedUser->update(['rolechangedate' => null]); + $editedUser->refresh(); } } - if ($request->input('role') !== null) { + + // If role is changing, handle it with stacking logic + if ($roleChanged && $request->input('role') !== null) { + User::updateUserRole( + $editedUser->id, + (int) $request->input('role'), // Cast to integer + true, // Apply promotions + $stackRole, // Stack role if requested + $changedBy + ); $editedUser->refresh(); } + + // Update user basic information (but NOT the role - it's handled above) + // Use current role to avoid overwriting + $ret = User::updateUser( + $editedUser->id, + $request->input('username'), + $request->input('email'), + $editedUser->grabs, + $editedUser->roles_id, // Use current role, not the request role + $request->input('notes'), + $request->input('invites'), + ($request->has('movieview') ? 1 : 0), + ($request->has('musicview') ? 1 : 0), + ($request->has('gameview') ? 1 : 0), + ($request->has('xxxview') ? 1 : 0), + ($request->has('consoleview') ? 1 : 0), + ($request->has('bookview') ? 1 : 0) + ); + + if ($request->input('password') !== null) { + User::updatePassword($editedUser->id, $request->input('password')); + } } if ($ret >= 0) { diff --git a/app/Models/User.php b/app/Models/User.php index 57214bd11..3b5a0cc7e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -70,12 +70,14 @@ use Spatie\Permission\Traits\HasRoles; * @property string|null $cp_api * @property string|null $style * @property string|null $rolechangedate When does the role expire + * @property string|null $pending_role_start_date When the pending role change takes effect + * @property int|null $pending_roles_id The role that will be applied after current role expires * @property string|null $remember_token * @property-read Collection|\App\Models\ReleaseComment[] $comment * @property-read Collection|\App\Models\UserDownload[] $download * @property-read Collection|\App\Models\DnzbFailure[] $failedRelease * @property-read Collection|\App\Models\Invitation[] $invitation - * @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications + * @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\NotififupdateUsertcations\DatabaseNotification[] $notifications * @property-read Collection|\App\Models\UsersRelease[] $release * @property-read Collection|\App\Models\UserRequest[] $request * @property-read Collection|\App\Models\UserSerie[] $series @@ -249,6 +251,68 @@ class User extends Authenticatable return $this->hasMany(RolePromotionStat::class); } + public function roleHistory(): HasMany + { + return $this->hasMany(UserRoleHistory::class); + } + + /** + * Check if user has a pending stacked role + */ + public function hasPendingRole(): bool + { + return $this->pending_roles_id !== null && $this->pending_role_start_date !== null; + } + + /** + * Get the pending role details + */ + public function getPendingRole(): ?Role + { + if (!$this->hasPendingRole()) { + return null; + } + + return Role::find($this->pending_roles_id); + } + + /** + * Cancel a pending stacked role + */ + public function cancelPendingRole(): bool + { + if (!$this->hasPendingRole()) { + return false; + } + + return $this->update([ + 'pending_roles_id' => null, + 'pending_role_start_date' => null, + ]); + } + + /** + * Get role expiry information including pending roles + */ + public function getRoleExpiryInfo(): array + { + $currentRole = $this->role; + $currentExpiry = $this->rolechangedate ? Carbon::parse($this->rolechangedate) : null; + $pendingRole = $this->getPendingRole(); + $pendingStart = $this->pending_role_start_date ? Carbon::parse($this->pending_role_start_date) : null; + + return [ + 'current_role' => $currentRole, + 'current_expiry' => $currentExpiry, + 'has_expiry' => $currentExpiry !== null, + 'is_expired' => $currentExpiry ? $currentExpiry->isPast() : false, + 'days_until_expiry' => $currentExpiry ? Carbon::now()->diffInDays($currentExpiry, false) : null, + 'pending_role' => $pendingRole, + 'pending_start' => $pendingStart, + 'has_pending_role' => $this->hasPendingRole(), + ]; + } + /** * Get the user's timezone or default to UTC */ @@ -348,28 +412,152 @@ class User extends Authenticatable return self::whereEmail($email)->first(); } - public static function updateUserRole(int $uid, int|string $role, bool $applyPromotions = true): bool + public static function updateUserRole(int $uid, int|string $role, bool $applyPromotions = true, bool $stackRole = true, ?int $changedBy = null): bool { - if (is_int($role)) { - $roleQuery = Role::query()->where('id', $role)->first(); + \Log::info('updateUserRole called', [ + 'uid' => $uid, + 'role' => $role, + 'role_type' => gettype($role), + 'applyPromotions' => $applyPromotions, + 'stackRole' => $stackRole, + 'changedBy' => $changedBy + ]); + + // Handle role parameter - can be int, numeric string, or role name + if (is_numeric($role)) { + // It's a number (either int or numeric string) + $roleQuery = Role::query()->where('id', (int) $role)->first(); } else { + // It's a role name $roleQuery = Role::query()->where('name', $role)->first(); } + + if (!$roleQuery) { + \Log::error('Role not found', ['role' => $role, 'role_type' => gettype($role)]); + return false; + } + + \Log::info('Role found', ['role_id' => $roleQuery->id, 'role_name' => $roleQuery->name]); + $roleName = $roleQuery->name; - $user = self::find($uid); + + if (!$user) { + \Log::error('User not found', ['uid' => $uid]); + return false; + } + $currentRoleId = $user->roles_id; + $oldExpiryDate = $user->rolechangedate ? Carbon::parse($user->rolechangedate) : null; - $updated = self::find($uid)->update(['roles_id' => $roleQuery->id]); + \Log::info('User current state', [ + 'currentRoleId' => $currentRoleId, + 'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(), + 'oldExpiryIsFuture' => $oldExpiryDate?->isFuture() + ]); - // Apply promotions if enabled and role is being upgraded - if ($applyPromotions && $updated && $currentRoleId !== $roleQuery->id) { - $additionalDays = RolePromotion::calculateAdditionalDays($currentRoleId, $roleQuery->id); + // Check if role is actually changing + if ($currentRoleId === $roleQuery->id) { + \Log::info('Role not changing, returning'); + return true; // No change needed + } - if ($additionalDays > 0) { - $currentExpiry = $user->rolechangedate ? Carbon::parse($user->rolechangedate) : Carbon::now(); - $newExpiry = $currentExpiry->addDays($additionalDays); - $user->update(['rolechangedate' => $newExpiry]); + // Determine if we should stack this role change + $shouldStack = $stackRole && $oldExpiryDate && $oldExpiryDate->isFuture(); + + \Log::info('Stack decision', [ + 'shouldStack' => $shouldStack, + 'stackRole' => $stackRole, + 'hasOldExpiry' => $oldExpiryDate !== null, + 'isFuture' => $oldExpiryDate?->isFuture() + ]); + + if ($shouldStack) { + \Log::info('Stacking role change'); + + // Stack the role change - set it as pending + $user->update([ + 'pending_roles_id' => $roleQuery->id, + 'pending_role_start_date' => $oldExpiryDate, + ]); + + \Log::info('Pending role updated', [ + 'pending_roles_id' => $roleQuery->id, + 'pending_role_start_date' => $oldExpiryDate->toDateTimeString() + ]); + + // Record in history as a stacked change + try { + $history = UserRoleHistory::recordRoleChange( + userId: $user->id, + oldRoleId: $currentRoleId, + newRoleId: $roleQuery->id, + oldExpiryDate: $oldExpiryDate, + newExpiryDate: null, // Will be determined when activated + effectiveDate: $oldExpiryDate, + isStacked: true, + changeReason: 'stacked_role_change', + changedBy: $changedBy + ); + + \Log::info('Role history recorded', ['history_id' => $history->id]); + } catch (\Exception $e) { + \Log::error('Failed to record role history', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + + return true; + } + + // Apply the role change immediately + $additionalDays = 0; + if ($applyPromotions) { + $additionalDays = RolePromotion::calculateAdditionalDays($roleQuery->id); + } + + // Calculate new expiry date + $newExpiryDate = null; + if ($additionalDays > 0) { + $baseDate = $oldExpiryDate && $oldExpiryDate->isFuture() ? $oldExpiryDate : Carbon::now(); + $newExpiryDate = $baseDate->addDays($additionalDays); + } + + // Update the user's role + $updated = $user->update([ + 'roles_id' => $roleQuery->id, + 'rolechangedate' => $newExpiryDate, + ]); + + // Sync Spatie roles + $user->syncRoles([$roleName]); + + // Record in history + if ($updated) { + UserRoleHistory::recordRoleChange( + userId: $user->id, + oldRoleId: $currentRoleId, + newRoleId: $roleQuery->id, + oldExpiryDate: $oldExpiryDate, + newExpiryDate: $newExpiryDate, + effectiveDate: Carbon::now(), + isStacked: false, + changeReason: 'immediate_role_change', + changedBy: $changedBy + ); + + // Track promotion statistics if applicable + if ($applyPromotions && $additionalDays > 0) { + $promotions = RolePromotion::getActivePromotions($roleQuery->id); + foreach ($promotions as $promotion) { + $promotion->trackApplication( + $user->id, + $roleQuery->id, + $oldExpiryDate, + $newExpiryDate + ); + } } } @@ -391,9 +579,76 @@ class User extends Authenticatable SendAccountWillExpireEmail::dispatch($user, $days)->onQueue('emails'); } } + + // Process expired roles foreach (self::query()->whereDate('rolechangedate', '<', $now)->get() as $expired) { - $expired->update(['roles_id' => self::ROLE_USER, 'rolechangedate' => null]); + $oldRoleId = $expired->roles_id; + $oldExpiryDate = $expired->rolechangedate ? Carbon::parse($expired->rolechangedate) : null; + + // Check if there's a pending stacked role + if ($expired->pending_roles_id && $expired->pending_role_start_date) { + $pendingStartDate = Carbon::parse($expired->pending_role_start_date); + + // If the pending role should start now or earlier + if ($pendingStartDate->lte($now)) { + // Apply the pending role + $newRoleId = $expired->pending_roles_id; + $roleQuery = Role::query()->where('id', $newRoleId)->first(); + + if ($roleQuery) { + // Calculate additional days from promotions + $additionalDays = RolePromotion::calculateAdditionalDays($newRoleId); + $newExpiryDate = $additionalDays > 0 ? $now->addDays($additionalDays) : null; + + // Update user with pending role + $expired->update([ + 'roles_id' => $newRoleId, + 'rolechangedate' => $newExpiryDate, + 'pending_roles_id' => null, + 'pending_role_start_date' => null, + ]); + $expired->syncRoles([$roleQuery->name]); + + // Record in history + UserRoleHistory::recordRoleChange( + userId: $expired->id, + oldRoleId: $oldRoleId, + newRoleId: $newRoleId, + oldExpiryDate: $oldExpiryDate, + newExpiryDate: $newExpiryDate, + effectiveDate: Carbon::instance($now), + isStacked: true, + changeReason: 'stacked_role_activated', + changedBy: null + ); + + continue; // Skip the default expiry handling + } + } + } + + // Default behavior: downgrade to User role + $expired->update([ + 'roles_id' => self::ROLE_USER, + 'rolechangedate' => null, + 'pending_roles_id' => null, + 'pending_role_start_date' => null, + ]); $expired->syncRoles(['User']); + + // Record in history + UserRoleHistory::recordRoleChange( + userId: $expired->id, + oldRoleId: $oldRoleId, + newRoleId: self::ROLE_USER, + oldExpiryDate: $oldExpiryDate, + newExpiryDate: null, + effectiveDate: Carbon::instance($now), + isStacked: false, + changeReason: 'role_expired', + changedBy: null + ); + SendAccountExpiredEmail::dispatch($expired)->onQueue('emails'); } } @@ -514,6 +769,13 @@ class User extends Authenticatable return self::SUCCESS; } + public static function updateUserRoleChangeDate(int $id, string $roleChangeDate): int + { + self::find($id)->update(['rolechangedate' => $roleChangeDate]); + + return self::SUCCESS; + } + public static function hashPassword($password): string { return Hash::make($password); diff --git a/app/Models/UserRoleHistory.php b/app/Models/UserRoleHistory.php new file mode 100644 index 000000000..9324cac87 --- /dev/null +++ b/app/Models/UserRoleHistory.php @@ -0,0 +1,130 @@ + 'datetime', + 'new_expiry_date' => 'datetime', + 'effective_date' => 'datetime', + 'is_stacked' => 'boolean', + ]; + + /** + * Get the user this history belongs to + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the admin who made this change + */ + public function changedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'changed_by'); + } + + /** + * Get the old role + */ + public function oldRole(): BelongsTo + { + return $this->belongsTo(\Spatie\Permission\Models\Role::class, 'old_role_id'); + } + + /** + * Get the new role + */ + public function newRole(): BelongsTo + { + return $this->belongsTo(\Spatie\Permission\Models\Role::class, 'new_role_id'); + } + + /** + * Record a role change + */ + public static function recordRoleChange( + int $userId, + ?int $oldRoleId, + int $newRoleId, + ?Carbon $oldExpiryDate, + ?Carbon $newExpiryDate, + Carbon $effectiveDate, + bool $isStacked = false, + ?string $changeReason = null, + ?int $changedBy = null + ): self { + return static::create([ + 'user_id' => $userId, + 'old_role_id' => $oldRoleId, + 'new_role_id' => $newRoleId, + 'old_expiry_date' => $oldExpiryDate, + 'new_expiry_date' => $newExpiryDate, + 'effective_date' => $effectiveDate, + 'is_stacked' => $isStacked, + 'change_reason' => $changeReason, + 'changed_by' => $changedBy, + ]); + } + + /** + * Get role history for a user + */ + public static function getUserHistory(int $userId) + { + return static::where('user_id', $userId) + ->orderBy('effective_date', 'desc') + ->get(); + } + + /** + * Get stacked role changes for a user + */ + public static function getUserStackedChanges(int $userId) + { + return static::where('user_id', $userId) + ->where('is_stacked', true) + ->orderBy('effective_date', 'desc') + ->get(); + } +} + diff --git a/database/migrations/2025_11_29_120000_add_role_stacking_fields_to_users_table.php b/database/migrations/2025_11_29_120000_add_role_stacking_fields_to_users_table.php new file mode 100644 index 000000000..3a1892214 --- /dev/null +++ b/database/migrations/2025_11_29_120000_add_role_stacking_fields_to_users_table.php @@ -0,0 +1,30 @@ +datetime('pending_role_start_date')->nullable()->after('rolechangedate')->comment('When the pending role change takes effect'); + $table->integer('pending_roles_id')->nullable()->after('pending_role_start_date')->comment('The role that will be applied after current role expires'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['pending_role_start_date', 'pending_roles_id']); + }); + } +}; + diff --git a/database/migrations/2025_11_29_120001_create_user_role_history_table.php b/database/migrations/2025_11_29_120001_create_user_role_history_table.php new file mode 100644 index 000000000..076c193b0 --- /dev/null +++ b/database/migrations/2025_11_29_120001_create_user_role_history_table.php @@ -0,0 +1,40 @@ +id(); + $table->unsignedBigInteger('user_id')->index(); + $table->integer('old_role_id')->nullable(); + $table->integer('new_role_id'); + $table->datetime('old_expiry_date')->nullable()->comment('Previous role expiry date'); + $table->datetime('new_expiry_date')->nullable()->comment('New role expiry date'); + $table->datetime('effective_date')->comment('When this role change became active'); + $table->boolean('is_stacked')->default(false)->comment('Was this role stacked after previous expiry'); + $table->string('change_reason')->nullable()->comment('Reason for role change (upgrade, downgrade, expiry, admin, etc)'); + $table->unsignedBigInteger('changed_by')->nullable()->comment('Admin user ID who made the change'); + $table->timestamps(); + + // Note: Foreign key constraint removed due to potential schema conflicts + // Ensure referential integrity at application level + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_role_history'); + } +}; + diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js index ec5489797..725929148 100644 --- a/resources/js/csp-safe.js +++ b/resources/js/csp-safe.js @@ -2323,6 +2323,12 @@ function initializeDateTimePicker() { if (!isNaN(date.getTime())) { document.getElementById('expiry_year').value = date.getFullYear().toString(); document.getElementById('expiry_month').value = String(date.getMonth() + 1).padStart(2, '0'); + + // Update valid days before setting the day value + if (typeof updateValidDays === 'function') { + updateValidDays(); + } + document.getElementById('expiry_day').value = String(date.getDate()).padStart(2, '0'); document.getElementById('expiry_hour').value = String(date.getHours()).padStart(2, '0'); document.getElementById('expiry_minute').value = String(date.getMinutes()).padStart(2, '0'); @@ -2337,6 +2343,58 @@ function initializeDateTimePicker() { element.addEventListener('change', updateDateTime); } }); + + // Add listeners for year and month changes to update valid days + const yearSelect = document.getElementById('expiry_year'); + const monthSelect = document.getElementById('expiry_month'); + if (yearSelect && monthSelect) { + yearSelect.addEventListener('change', updateValidDays); + monthSelect.addEventListener('change', updateValidDays); + } +} + +// Update valid days based on selected month and year +function updateValidDays() { + const yearSelect = document.getElementById('expiry_year'); + const monthSelect = document.getElementById('expiry_month'); + const daySelect = document.getElementById('expiry_day'); + + if (!yearSelect || !monthSelect || !daySelect) return; + + const year = parseInt(yearSelect.value); + const month = parseInt(monthSelect.value); + + if (!year || !month) return; + + // Get number of days in the selected month + const daysInMonth = new Date(year, month, 0).getDate(); + + // Store current selection + const currentDay = parseInt(daySelect.value); + + // Clear and rebuild day options + daySelect.innerHTML = ''; + + for (let d = 1; d <= daysInMonth; d++) { + const option = document.createElement('option'); + option.value = String(d).padStart(2, '0'); + option.textContent = d; + daySelect.appendChild(option); + } + + // Restore selection if still valid + if (currentDay && currentDay <= daysInMonth) { + daySelect.value = String(currentDay).padStart(2, '0'); + } else if (currentDay > daysInMonth) { + // If previously selected day is now invalid, clear it and show warning + daySelect.value = ''; + + // Flash the day selector to indicate it needs attention + daySelect.classList.add('border-yellow-500', 'dark:border-yellow-400', 'bg-yellow-50', 'dark:bg-yellow-900/20'); + setTimeout(() => { + daySelect.classList.remove('border-yellow-500', 'dark:border-yellow-400', 'bg-yellow-50', 'dark:bg-yellow-900/20'); + }, 1500); + } } // Setup type-to-select functionality for all dropdowns diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index 5ba25e488..454473b75 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -75,6 +75,15 @@ + @if(!is_array($user) && !empty($user->pending_roles_id)) + @php + $pendingRole = \Spatie\Permission\Models\Role::find($user->pending_roles_id); + @endphp +
+ + Note: This user has a pending role change to {{ $pendingRole->name ?? 'Unknown' }} +
+ @endif @@ -291,6 +300,73 @@ @endif + + @if(!is_array($user) && !empty($user->pending_roles_id) && !empty($user->pending_role_start_date)) +
+
+ + + SCHEDULED + +
+ + @php + $pendingRole = \Spatie\Permission\Models\Role::find($user->pending_roles_id); + $pendingStartDate = \Carbon\Carbon::parse($user->pending_role_start_date); + $daysUntilActivation = $pendingStartDate->diffInDays(now()); + @endphp + +
+
+ Will change to: + {{ $pendingRole->name ?? 'Unknown' }} +
+
+ Activation date: + {{ $pendingStartDate->format('M j, Y g:i A') }} +
+
+ Time until activation: + {{ $pendingStartDate->diffForHumans() }} +
+
+ +
+

+ + This role will automatically activate when the current role expires +

+ +
+
+ @endif + + + @if(!is_array($user) && !empty($user->rolechangedate) && \Carbon\Carbon::parse($user->rolechangedate)->isFuture()) +
+ +
+ @endif + @if(!empty($user['id']))
diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php index a8cbb899f..3acddb5cb 100644 --- a/resources/views/admin/users/index.blade.php +++ b/resources/views/admin/users/index.blade.php @@ -146,6 +146,9 @@ Role + + Pending Role + Role Expiry @@ -210,6 +213,24 @@ {{ $user->roles->first()->name ?? 'N/A' }} + + @if(!empty($user->pending_roles_id) && !empty($user->pending_role_start_date)) + @php + $pendingRole = \Spatie\Permission\Models\Role::find($user->pending_roles_id); + $pendingStartDate = \Carbon\Carbon::parse($user->pending_role_start_date); + @endphp +
+ + {{ $pendingRole->name ?? 'Unknown' }} + + + {{ $pendingStartDate->diffForHumans() }} + +
+ @else + + @endif + @if($user->rolechangedate) @php diff --git a/resources/views/profile/index.blade.php b/resources/views/profile/index.blade.php index 1190c011b..f3099d071 100644 --- a/resources/views/profile/index.blade.php +++ b/resources/views/profile/index.blade.php @@ -139,6 +139,35 @@
@endif + @if($user->hasPendingRole()) +
+
Pending Role
+
+ @php + $pendingRole = $user->getPendingRole(); + $pendingStartDate = \Carbon\Carbon::parse($user->pending_role_start_date); + @endphp +
+
+ + {{ $pendingRole->name ?? 'Unknown' }} + + Scheduled + +
+
+ + Will activate {{ $pendingStartDate->diffForHumans() }} on {{ $pendingStartDate->format('M d, Y g:i A') }} +
+
+ + This role will automatically activate when your current role expires +
+
+
+
+ @endif +
Last Login
{{ \Carbon\Carbon::parse($user->lastlogin)->diffForHumans() }}