From a7029b5f57fd6251045a0b7781eeb5ec246bc839 Mon Sep 17 00:00:00 2001 From: idevakk <219866223+idevakk@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:44:19 -0800 Subject: [PATCH] feat: add user impersonation service --- app/Filament/Resources/UserResource.php | 65 ++++++ .../Resources/UserResource/Pages/EditUser.php | 21 ++ .../Controllers/ImpersonationController.php | 115 ++++++++++ .../Middleware/ImpersonationMiddleware.php | 52 +++++ app/Models/ImpersonationLog.php | 132 ++++++++++++ app/Models/User.php | 10 + app/Policies/ImpersonationPolicy.php | 97 +++++++++ app/Services/ImpersonationService.php | 197 ++++++++++++++++++ app/View/Components/ImpersonationBanner.php | 26 +++ bootstrap/app.php | 5 + .../factories/ImpersonationLogFactory.php | 95 +++++++++ database/factories/UserFactory.php | 2 +- ...164751_create_impersonation_logs_table.php | 44 ++++ .../components/impersonation-banner.blade.php | 86 ++++++++ .../views/components/layouts/app.blade.php | 1 + .../components/layouts/dashboard.blade.php | 1 + .../livewire/dashboard/dashboard.blade.php | 2 +- routes/web.php | 16 +- tests/Feature/ImpersonationControllerTest.php | 138 ++++++++++++ .../Feature/UserResourceImpersonationTest.php | 88 ++++++++ tests/Unit/ImpersonationServiceTest.php | 156 ++++++++++++++ 21 files changed, 1343 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/ImpersonationController.php create mode 100644 app/Http/Middleware/ImpersonationMiddleware.php create mode 100644 app/Models/ImpersonationLog.php create mode 100644 app/Policies/ImpersonationPolicy.php create mode 100644 app/Services/ImpersonationService.php create mode 100644 app/View/Components/ImpersonationBanner.php create mode 100644 database/factories/ImpersonationLogFactory.php create mode 100644 database/migrations/2025_11_17_164751_create_impersonation_logs_table.php create mode 100644 resources/views/components/impersonation-banner.blade.php create mode 100644 tests/Feature/ImpersonationControllerTest.php create mode 100644 tests/Feature/UserResourceImpersonationTest.php create mode 100644 tests/Unit/ImpersonationServiceTest.php diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 64143b7..ee17439 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -9,7 +9,9 @@ use App\Filament\Resources\UserResource\Pages\ListUsers; use App\Filament\Resources\UserResource\RelationManagers\LogsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\UsageLogsRelationManager; use App\Models\User; +use App\Services\ImpersonationService; use BackedEnum; +use Filament\Actions\Action; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; @@ -49,6 +51,12 @@ class UserResource extends Resource ->email() ->required() ->maxLength(255), + TextInput::make('password') + ->password() + ->required() + ->minLength(6) + ->maxLength(255) + ->visibleOn('create'), TextInput::make('email_verified_at') ->label('Email Verification Status') ->disabled() @@ -149,8 +157,65 @@ class UserResource extends Resource ]) ->recordActions([ EditAction::make(), + Action::make('impersonate') + ->label('Login as User') + ->icon('heroicon-o-arrow-right-on-rectangle') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Start User Impersonation') + ->modalDescription('This will open a new tab where you will be logged in as this user. All actions will be logged for security. Your admin panel will remain open in this tab.') + ->modalSubmitActionLabel('Start Impersonation') + ->modalCancelActionLabel('Cancel') + ->visible(fn (User $record): bool => auth()->user()?->isSuperAdmin() && + $record->isNormalUser() && + ! app(ImpersonationService::class)->isImpersonating() + ) + ->url(function (User $record): string { + $admin = auth()->user(); + $impersonationService = app(ImpersonationService::class); + + if (! $impersonationService->canImpersonate($admin, $record)) { + Notification::make() + ->title('Impersonation Failed') + ->body('Unable to start impersonation. Please check permissions and try again.') + ->danger() + ->send(); + + return '#'; + } + + // Return impersonation URL + return route('impersonation.start', $record); + }) + ->openUrlInNewTab(), ]) ->toolbarActions([ + Action::make('stopImpersonation') + ->label('Stop Impersonating') + ->icon('heroicon-o-arrow-left-on-rectangle') + ->color('danger') + ->visible(fn (): bool => app(ImpersonationService::class)->isImpersonating()) + ->action(function () { + $impersonationService = app(ImpersonationService::class); + + if (! $impersonationService->stopImpersonation(request())) { + Notification::make() + ->title('Failed to Stop Impersonation') + ->body('Unable to stop impersonation session.') + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Impersonation Ended') + ->body('You have been returned to your admin account.') + ->success() + ->send(); + + return redirect()->to(\Filament\Pages\Dashboard::getUrl()); + }), BulkActionGroup::make([ DeleteBulkAction::make(), BulkAction::make('updateLevel') diff --git a/app/Filament/Resources/UserResource/Pages/EditUser.php b/app/Filament/Resources/UserResource/Pages/EditUser.php index e147280..ccd91e0 100644 --- a/app/Filament/Resources/UserResource/Pages/EditUser.php +++ b/app/Filament/Resources/UserResource/Pages/EditUser.php @@ -6,7 +6,9 @@ use App\Filament\Resources\UserResource; use App\Models\User; use Filament\Actions\Action; use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Support\Icons\Heroicon; use Illuminate\Support\Facades\Response; class EditUser extends EditRecord @@ -17,6 +19,25 @@ class EditUser extends EditRecord { return [ DeleteAction::make(), + Action::make('mark_email_verified') + ->label('Mark Email as Verified') + ->icon(Heroicon::OutlinedEnvelope) + ->action(function (User $user) { + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + Notification::make('email_verified_successfully') + ->title('Email Verified Successfully') + ->icon(Heroicon::OutlinedEnvelope) + ->success() + ->send(); + } else { + Notification::make('email_already_verified') + ->title('Email Already Verified') + ->icon(Heroicon::OutlinedEnvelope) + ->warning() + ->send(); + } + }), Action::make('download_report') ->label('Download User Report') ->icon('heroicon-o-user') diff --git a/app/Http/Controllers/ImpersonationController.php b/app/Http/Controllers/ImpersonationController.php new file mode 100644 index 0000000..04f18cd --- /dev/null +++ b/app/Http/Controllers/ImpersonationController.php @@ -0,0 +1,115 @@ +isSuperAdmin()) { + Notification::make() + ->title('Access Denied') + ->body('Only SuperAdmin users can impersonate other users.') + ->danger() + ->send(); + + return redirect()->back(); + } + + if (! $this->impersonationService->startImpersonation($admin, $user, $request)) { + Notification::make() + ->title('Impersonation Failed') + ->body('Unable to start impersonation. Please check permissions and try again.') + ->danger() + ->send(); + + return redirect()->back(); + } + + Notification::make() + ->title('Impersonation Started') + ->body("You are now logged in as {$user->name}.") + ->success() + ->send(); + + // Redirect to user dashboard (assuming you have a dashboard route) + return redirect()->route('dashboard'); + } + + /** + * Stop impersonating the current user. + */ + public function stop(Request $request) + { + if (! $this->impersonationService->isImpersonating()) { + return redirect()->to(\Filament\Pages\Dashboard::getUrl()); + } + + $impersonator = $this->impersonationService->getCurrentImpersonator(); + + if (! $this->impersonationService->stopImpersonation($request)) { + Notification::make() + ->title('Failed to Stop Impersonation') + ->body('Unable to stop impersonation session.') + ->danger() + ->send(); + + return redirect()->back(); + } + + Notification::make() + ->title('Impersonation Ended') + ->body('You have been returned to your admin account.') + ->success() + ->send(); + + return redirect()->to(\Filament\Pages\Dashboard::getUrl()); + } + + /** + * Show impersonation status (AJAX endpoint). + */ + public function status(Request $request) + { + if (! $this->impersonationService->isImpersonating()) { + return response()->json([ + 'is_impersonating' => false, + ]); + } + + $impersonator = $this->impersonationService->getCurrentImpersonator(); + $target = Auth::user(); + $remainingMinutes = $this->impersonationService->getRemainingMinutes(); + + return response()->json([ + 'is_impersonating' => true, + 'impersonator' => [ + 'name' => $impersonator?->name, + 'email' => $impersonator?->email, + ], + 'target' => [ + 'name' => $target?->name, + 'email' => $target?->email, + ], + 'remaining_minutes' => $remainingMinutes, + 'expires_soon' => $remainingMinutes <= 5, + ]); + } +} diff --git a/app/Http/Middleware/ImpersonationMiddleware.php b/app/Http/Middleware/ImpersonationMiddleware.php new file mode 100644 index 0000000..882565a --- /dev/null +++ b/app/Http/Middleware/ImpersonationMiddleware.php @@ -0,0 +1,52 @@ +impersonationService->isImpersonating() && + $this->impersonationService->isImpersonationExpired()) { + + $this->impersonationService->stopImpersonation($request); + + // Redirect to admin dashboard with expired message + return redirect()->to(\Filament\Pages\Dashboard::getUrl())->with('impersonation_expired', 'Your impersonation session has expired.'); + } + + // Share impersonation data with all views + if ($this->impersonationService->isImpersonating()) { + $impersonator = $this->impersonationService->getCurrentImpersonator(); + $remainingMinutes = $this->impersonationService->getRemainingMinutes(); + $startTime = $this->impersonationService->getImpersonationStartTime(); + + view()->share('isImpersonating', true); + view()->share('impersonator', $impersonator); + view()->share('impersonationTarget', Auth::user()); + view()->share('impersonationRemainingMinutes', $remainingMinutes); + view()->share('impersonationStartTime', $startTime); + } else { + view()->share('isImpersonating', false); + view()->share('impersonator', null); + view()->share('impersonationTarget', null); + view()->share('impersonationRemainingMinutes', 0); + view()->share('impersonationStartTime', null); + } + + return $next($request); + } +} diff --git a/app/Models/ImpersonationLog.php b/app/Models/ImpersonationLog.php new file mode 100644 index 0000000..7a7cf74 --- /dev/null +++ b/app/Models/ImpersonationLog.php @@ -0,0 +1,132 @@ + 'datetime', + 'end_time' => 'datetime', + 'pages_visited' => 'array', + 'actions_taken' => 'array', + ]; + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } + + public function targetUser(): BelongsTo + { + return $this->belongsTo(User::class, 'target_user_id'); + } + + public function getDurationInSecondsAttribute(): ?int + { + if (! $this->end_time) { + return null; + } + + return $this->start_time->diffInSeconds($this->end_time); + } + + public function getDurationInMinutesAttribute(): ?int + { + $seconds = $this->duration_in_seconds; + + return $seconds ? (int) ceil($seconds / 60) : null; + } + + public function isActive(): bool + { + return $this->status === 'active' && is_null($this->end_time); + } + + public function isCompleted(): bool + { + return $this->status === 'completed' && ! is_null($this->end_time); + } + + public function isForceTerminated(): bool + { + return $this->status === 'force_terminated'; + } + + public function scopeActive($query) + { + return $query->where('status', 'active')->whereNull('end_time'); + } + + public function scopeCompleted($query) + { + return $query->where('status', 'completed'); + } + + public function scopeForAdmin($query, User $admin) + { + return $query->where('admin_id', $admin->id); + } + + public function scopeForTarget($query, User $target) + { + return $query->where('target_user_id', $target->id); + } + + public function scopeToday($query) + { + return $query->whereDate('start_time', today()); + } + + public function scopeThisWeek($query) + { + return $query->whereBetween('start_time', [now()->startOfWeek(), now()->endOfWeek()]); + } + + public function scopeThisMonth($query) + { + return $query->whereBetween('start_time', [now()->startOfMonth(), now()->endOfMonth()]); + } + + public function addPageVisited(string $page): void + { + $pages = $this->pages_visited ?? []; + $pages[] = [ + 'page' => $page, + 'timestamp' => now()->toISOString(), + ]; + + $this->update(['pages_visited' => $pages]); + } + + public function addActionTaken(string $action, array $data = []): void + { + $actions = $this->actions_taken ?? []; + $actions[] = [ + 'action' => $action, + 'data' => $data, + 'timestamp' => now()->toISOString(), + ]; + + $this->update(['actions_taken' => $actions]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 5baecee..d37f7a3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -139,4 +139,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail { return $this->hasMany(UsageLog::class); } + + public function impersonationLogs() + { + return $this->hasMany(ImpersonationLog::class, 'admin_id'); + } + + public function impersonationTargets() + { + return $this->hasMany(ImpersonationLog::class, 'target_user_id'); + } } diff --git a/app/Policies/ImpersonationPolicy.php b/app/Policies/ImpersonationPolicy.php new file mode 100644 index 0000000..826ee90 --- /dev/null +++ b/app/Policies/ImpersonationPolicy.php @@ -0,0 +1,97 @@ +user_level !== UserLevel::SUPERADMIN) { + return Response::deny('Only SUPERADMIN users can impersonate other users.'); + } + + // Cannot impersonate other SUPERADMIN users + if ($target->user_level === UserLevel::SUPERADMIN) { + return Response::deny('Cannot impersonate SUPERADMIN users.'); + } + + // Cannot impersonate self + if ($admin->id === $target->id) { + return Response::deny('Cannot impersonate yourself.'); + } + + // Can only impersonate NORMALUSER + if ($target->user_level !== UserLevel::NORMALUSER) { + return Response::deny('Can only impersonate NORMALUSER accounts.'); + } + + // Check if admin is already impersonating someone + if ($this->impersonationService->isImpersonating()) { + return Response::deny('You are already impersonating another user. Please stop the current impersonation first.'); + } + + // Rate limiting: Check if admin has too many recent impersonation attempts + $recentAttempts = $admin->impersonationLogs() + ->where('start_time', '>', now()->subHours(1)) + ->count(); + + if ($recentAttempts >= 10) { + return Response::deny('Too many impersonation attempts. Please try again later.'); + } + + return Response::allow(); + } + + public function stop(User $user): Response + { + // Only users who are currently impersonating can stop impersonation + if (! $this->impersonationService->isImpersonating()) { + return Response::deny('No active impersonation session found.'); + } + + // The original admin user must be the one stopping the impersonation + $impersonator = $this->impersonationService->getCurrentImpersonator(); + if (! $impersonator || $impersonator->id !== $user->id) { + return Response::deny('You are not authorized to stop this impersonation session.'); + } + + return Response::allow(); + } + + public function viewAny(User $user): Response + { + // Only SUPERADMIN can view impersonation logs + if ($user->user_level !== UserLevel::SUPERADMIN) { + return Response::deny('Only SUPERADMIN users can view impersonation logs.'); + } + + return Response::allow(); + } + + public function view(User $user, $impersonationLog): Response + { + // Only SUPERADMIN can view specific impersonation logs + if ($user->user_level !== UserLevel::SUPERADMIN) { + return Response::deny('Only SUPERADMIN users can view impersonation logs.'); + } + + // Users can only view logs where they are the admin + if ($impersonationLog->admin_id !== $user->id) { + return Response::deny('You can only view your own impersonation logs.'); + } + + return Response::allow(); + } +} diff --git a/app/Services/ImpersonationService.php b/app/Services/ImpersonationService.php new file mode 100644 index 0000000..70953f6 --- /dev/null +++ b/app/Services/ImpersonationService.php @@ -0,0 +1,197 @@ +level !== UserLevel::SUPERADMIN) { + return false; + } + + // Cannot impersonate other SUPERADMIN users + if ($target->level === UserLevel::SUPERADMIN) { + return false; + } + + // Cannot impersonate self + if ($admin->id === $target->id) { + return false; + } + + // Can only impersonate NORMALUSER + if ($target->level !== UserLevel::NORMALUSER) { + return false; + } + + // Check if admin is already impersonating someone + if ($this->isImpersonating()) { + return false; + } + + return true; + } + + public function isImpersonating(): bool + { + return Session::has(self::IMPERSONATION_SESSION_KEY) && + Session::has(self::ORIGINAL_USER_KEY); + } + + public function getCurrentImpersonator(): ?User + { + if (! $this->isImpersonating()) { + return null; + } + + $originalUserId = Session::get(self::ORIGINAL_USER_KEY); + + return User::find($originalUserId); + } + + public function getImpersonationStartTime(): ?Carbon + { + if (! $this->isImpersonating()) { + return null; + } + + return Session::get(self::IMPERSONATION_START_TIME_KEY); + } + + public function isImpersonationExpired(): bool + { + if (! $this->isImpersonating()) { + return false; + } + + $startTime = $this->getImpersonationStartTime(); + + if (! $startTime) { + return true; + } + + return $startTime->diffInMinutes(now()) > self::IMPERSONATION_TIMEOUT; + } + + public function startImpersonation(User $admin, User $target, Request $request): bool + { + if (! $this->canImpersonate($admin, $target)) { + return false; + } + + // Store original admin user info + Session::put(self::ORIGINAL_USER_KEY, $admin->id); + Session::put(self::IMPERSONATION_START_TIME_KEY, now()); + + // Log the impersonation start + $impersonationLog = ImpersonationLog::create([ + 'admin_id' => $admin->id, + 'target_user_id' => $target->id, + 'start_time' => now(), + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'status' => 'active', + ]); + + Session::put(self::IMPERSONATION_SESSION_KEY, $impersonationLog->id); + + // Log out the admin and log in as target user + Auth::logout(); + Auth::login($target); + + // Regenerate session for security + Session::regenerate(true); + + return true; + } + + public function stopImpersonation(Request $request): bool + { + if (! $this->isImpersonating()) { + return false; + } + + $admin = $this->getCurrentImpersonator(); + $target = Auth::user(); + $impersonationLogId = Session::get(self::IMPERSONATION_SESSION_KEY); + + // Update the impersonation log + if ($impersonationLogId) { + $log = ImpersonationLog::find($impersonationLogId); + if ($log) { + $log->update([ + 'end_time' => now(), + 'status' => 'completed', + ]); + } + } + + // Clear impersonation session data + Session::forget([ + self::IMPERSONATION_SESSION_KEY, + self::IMPERSONATION_START_TIME_KEY, + self::ORIGINAL_USER_KEY, + ]); + + // Log out target user and log back in as admin + Auth::logout(); + + if ($admin) { + Auth::login($admin); + } + + // Regenerate session for security + Session::regenerate(true); + + return true; + } + + public function forceStopAllImpersonations(): void + { + // Force end any active impersonation sessions in the database + ImpersonationLog::where('status', 'active') + ->whereNull('end_time') + ->update([ + 'end_time' => now(), + 'status' => 'force_terminated', + ]); + } + + public function getRemainingMinutes(): int + { + if (! $this->isImpersonating()) { + return 0; + } + + $startTime = $this->getImpersonationStartTime(); + + if (! $startTime) { + return 0; + } + + $elapsed = $startTime->diffInMinutes(now()); + $remaining = self::IMPERSONATION_TIMEOUT - $elapsed; + + return (int) max(0, $remaining); + } +} diff --git a/app/View/Components/ImpersonationBanner.php b/app/View/Components/ImpersonationBanner.php new file mode 100644 index 0000000..0c18762 --- /dev/null +++ b/app/View/Components/ImpersonationBanner.php @@ -0,0 +1,26 @@ +withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ Locale::class, + ImpersonationMiddleware::class, + ]); + $middleware->alias([ + 'impersonation' => ImpersonationMiddleware::class, ]); $middleware->validateCsrfTokens(except: [ 'stripe/*', diff --git a/database/factories/ImpersonationLogFactory.php b/database/factories/ImpersonationLogFactory.php new file mode 100644 index 0000000..c39f425 --- /dev/null +++ b/database/factories/ImpersonationLogFactory.php @@ -0,0 +1,95 @@ + + */ +final class ImpersonationLogFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $startTime = $this->faker->dateTimeBetween('-1 month', 'now'); + $endTime = $this->faker->optional(0.3)->dateTimeBetween($startTime, '+30 minutes'); + + return [ + 'admin_id' => User::factory()->superAdmin(), + 'target_user_id' => User::factory()->normalUser(), + 'start_time' => $startTime, + 'end_time' => $endTime, + 'ip_address' => $this->faker->ipv4(), + 'user_agent' => $this->faker->userAgent(), + 'status' => $endTime ? 'completed' : 'active', + 'pages_visited' => $this->faker->optional(0.7)->randomElements([ + '/dashboard', + '/profile', + '/settings', + '/emails', + '/reports', + ], $this->faker->numberBetween(1, 5)), + 'actions_taken' => $this->faker->optional(0.5)->randomElements([ + 'viewed_profile', + 'updated_settings', + 'downloaded_report', + 'sent_email', + 'deleted_item', + ], $this->faker->numberBetween(1, 3)), + ]; + } + + /** + * Indicate that the impersonation is currently active. + */ + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'start_time' => now()->subMinutes($this->faker->numberBetween(1, 25)), + 'end_time' => null, + 'status' => 'active', + ]); + } + + /** + * Indicate that the impersonation was completed. + */ + public function completed(): static + { + return $this->state(fn (array $attributes) => [ + 'end_time' => $this->faker->dateTimeBetween($attributes['start_time'], '+30 minutes'), + 'status' => 'completed', + ]); + } + + /** + * Indicate that the impersonation was force terminated. + */ + public function forceTerminated(): static + { + return $this->state(fn (array $attributes) => [ + 'end_time' => $this->faker->dateTimeBetween($attributes['start_time'], '+30 minutes'), + 'status' => 'force_terminated', + ]); + } + + /** + * Indicate that the impersonation expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'start_time' => now()->subMinutes(35), + 'end_time' => now()->subMinutes(5), + 'status' => 'expired', + ]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 42eeaa3..221f761 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -52,7 +52,7 @@ class UserFactory extends Factory { return $this->state(fn (array $attributes): array => [ 'level' => UserLevel::SUPERADMIN->value, - 'email' => 'admin@zemail.me', + 'email' => fake()->unique()->safeEmail(), ]); } diff --git a/database/migrations/2025_11_17_164751_create_impersonation_logs_table.php b/database/migrations/2025_11_17_164751_create_impersonation_logs_table.php new file mode 100644 index 0000000..60331ef --- /dev/null +++ b/database/migrations/2025_11_17_164751_create_impersonation_logs_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('admin_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('target_user_id')->constrained('users')->onDelete('cascade'); + $table->timestamp('start_time'); + $table->timestamp('end_time')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->enum('status', ['active', 'completed', 'force_terminated', 'expired'])->default('active'); + $table->json('pages_visited')->nullable(); + $table->json('actions_taken')->nullable(); + $table->timestamps(); + + // Indexes for performance + $table->index(['admin_id', 'start_time']); + $table->index(['target_user_id', 'start_time']); + $table->index(['status', 'start_time']); + $table->index(['ip_address']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('impersonation_logs'); + } +}; diff --git a/resources/views/components/impersonation-banner.blade.php b/resources/views/components/impersonation-banner.blade.php new file mode 100644 index 0000000..96e0e68 --- /dev/null +++ b/resources/views/components/impersonation-banner.blade.php @@ -0,0 +1,86 @@ +{{-- + Impersonation Banner Component + + This component displays a prominent banner when an admin is impersonating a user. + It shows who is being impersonated, remaining time, and provides a stop button. +--}} + +@if($isImpersonating) +
+
+
+
+ +
+ + + + +
+ + +
+
+ Impersonating: {{ $impersonationTarget?->name ?? 'Unknown User' }} +
+
+ {{ $impersonationTarget?->email }} + @if($impersonationRemainingMinutes > 0) + • {{ $impersonationRemainingMinutes }} minutes remaining + @else + • Session expiring soon + @endif +
+
+
+ + +
+ + @if($impersonationRemainingMinutes <= 5 && $impersonationRemainingMinutes > 0) +
+ ⚠️ Less than 5 minutes remaining +
+ @endif + + +
+ @csrf + +
+
+
+ + + @if($impersonationRemainingMinutes > 0) +
+
+
+ @endif +
+
+ + + +@endif diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index ecadef6..420a0a5 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -26,6 +26,7 @@ @yield('custom_header') + diff --git a/resources/views/components/layouts/dashboard.blade.php b/resources/views/components/layouts/dashboard.blade.php index ba780de..eaf878e 100644 --- a/resources/views/components/layouts/dashboard.blade.php +++ b/resources/views/components/layouts/dashboard.blade.php @@ -25,6 +25,7 @@ @yield('custom_header') + diff --git a/resources/views/livewire/dashboard/dashboard.blade.php b/resources/views/livewire/dashboard/dashboard.blade.php index e9ad8f3..b565b1e 100644 --- a/resources/views/livewire/dashboard/dashboard.blade.php +++ b/resources/views/livewire/dashboard/dashboard.blade.php @@ -23,7 +23,7 @@ - @if(auth()->user()->subscribedToProduct(config('app.plans')[0]->product_id)) + @if(auth()->user()->subscribedToProduct(config('app.plans')[0]['product_id']))
diff --git a/routes/web.php b/routes/web.php index e428db2..175d3ff 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,9 +1,7 @@ name('home'); @@ -93,7 +94,7 @@ Route::middleware(['auth', 'verified', CheckUserBanned::class])->group(function Route::get('dashboard/success', [Dashboard::class, 'paymentStatus'])->name('checkout.success')->defaults('status', 'success'); Route::get('dashboard/cancel', [Dashboard::class, 'paymentStatus'])->name('checkout.cancel')->defaults('status', 'cancel'); - Route::get('dashboard/billing', fn() => auth()->user()->redirectToBillingPortal(route('dashboard')))->name('billing'); + Route::get('dashboard/billing', fn () => auth()->user()->redirectToBillingPortal(route('dashboard')))->name('billing'); Route::get('0xdash/slink', function (Request $request) { $validUser = 'admin'; @@ -137,6 +138,13 @@ Route::middleware(['auth', 'verified', CheckUserBanned::class])->group(function ]); })->name('cacheClear'); + // Impersonation Routes + Route::prefix('impersonation')->name('impersonation.')->group(function (): void { + Route::post('/stop', [ImpersonationController::class, 'stop'])->name('stop'); + Route::get('/status', [ImpersonationController::class, 'status'])->name('status'); + Route::get('/start/{user}', [ImpersonationController::class, 'start'])->name('start'); + }); + }); Route::middleware(['auth'])->group(function (): void { diff --git a/tests/Feature/ImpersonationControllerTest.php b/tests/Feature/ImpersonationControllerTest.php new file mode 100644 index 0000000..c8c9246 --- /dev/null +++ b/tests/Feature/ImpersonationControllerTest.php @@ -0,0 +1,138 @@ +superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + + Auth::login($admin); + + $response = $this->post(route('impersonation.start', $targetUser)); + + $response->assertRedirect(route('dashboard')); + expect(app(\App\Services\ImpersonationService::class)->isImpersonating())->toBeTrue(); + + $this->assertDatabaseHas('impersonation_logs', [ + 'admin_id' => $admin->id, + 'target_user_id' => $targetUser->id, + 'status' => 'active', + ]); +}); + +test('normal user cannot start impersonation', function (): void { + $normalUser = User::factory()->normalUser()->create(); + $targetUser = User::factory()->normalUser()->create(); + + Auth::login($normalUser); + + $response = $this->post(route('impersonation.start', $targetUser)); + + $response->assertRedirect(); + expect(app(\App\Services\ImpersonationService::class)->isImpersonating())->toBeFalse(); +}); + +test('super admin cannot impersonate another super admin', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetAdmin = User::factory()->superAdmin()->create(); + + Auth::login($admin); + + $response = $this->post(route('impersonation.start', $targetAdmin)); + + $response->assertRedirect(); + expect(app(\App\Services\ImpersonationService::class)->isImpersonating())->toBeFalse(); +}); + +test('can stop active impersonation', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(\App\Services\ImpersonationService::class); + + // Start impersonation + Auth::login($admin); + $impersonationService->startImpersonation($admin, $targetUser, request()); + Auth::login($targetUser); + + expect($impersonationService->isImpersonating())->toBeTrue(); + + $response = $this->post(route('impersonation.stop')); + + $response->assertRedirect(\Filament\Pages\Dashboard::getUrl()); + expect($impersonationService->isImpersonating())->toBeFalse(); + + $this->assertDatabaseHas('impersonation_logs', [ + 'admin_id' => $admin->id, + 'target_user_id' => $targetUser->id, + 'status' => 'completed', + ]); +}); + +test('stop impersonation when not impersonating redirects to admin', function (): void { + $admin = User::factory()->superAdmin()->create(); + + Auth::login($admin); + + $response = $this->post(route('impersonation.stop')); + + $response->assertRedirect(\Filament\Pages\Dashboard::getUrl()); +}); + +test('impersonation status endpoint returns correct data', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(\App\Services\ImpersonationService::class); + + // Test when not impersonating + Auth::login($admin); + + $response = $this->get(route('impersonation.status')); + + $response->assertJson([ + 'is_impersonating' => false, + ]); + + // Test when impersonating + $impersonationService->startImpersonation($admin, $targetUser, request()); + Auth::login($targetUser); + + $response = $this->get(route('impersonation.status')); + + $response->assertJson([ + 'is_impersonating' => true, + 'impersonator' => [ + 'name' => $admin->name, + 'email' => $admin->email, + ], + 'target' => [ + 'name' => $targetUser->name, + 'email' => $targetUser->email, + ], + ]); + $response->assertJsonStructure([ + 'is_impersonating', + 'impersonator', + 'target', + 'remaining_minutes', + 'expires_soon', + ]); +}); + +test('unauthenticated user cannot access impersonation routes', function (): void { + $targetUser = User::factory()->normalUser()->create(); + + $response = $this->post(route('impersonation.start', $targetUser)); + $response->assertRedirect('/login'); + + $response = $this->post(route('impersonation.stop')); + $response->assertRedirect('/login'); + + $response = $this->get(route('impersonation.status')); + $response->assertRedirect('/login'); +}); diff --git a/tests/Feature/UserResourceImpersonationTest.php b/tests/Feature/UserResourceImpersonationTest.php new file mode 100644 index 0000000..00c3f27 --- /dev/null +++ b/tests/Feature/UserResourceImpersonationTest.php @@ -0,0 +1,88 @@ +superAdmin()->create(); + $normalUser = User::factory()->normalUser()->create(); + + Livewire::actingAs($admin) + ->test(\App\Filament\Resources\UserResource\Pages\ListUsers::class) + ->assertCanSeeTableRecords([$normalUser]) + ->assertActionVisible('impersonate', $normalUser); +}); + +test('super admin cannot see impersonation action for super admin', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetAdmin = User::factory()->superAdmin()->create(); + + Livewire::actingAs($admin) + ->test(\App\Filament\Resources\UserResource\Pages\ListUsers::class) + ->assertCanSeeTableRecords([$targetAdmin]) + ->assertActionHidden('impersonate', $targetAdmin); +}); + +test('normal user cannot see impersonation action', function (): void { + $normalUser = User::factory()->normalUser()->create(); + $targetUser = User::factory()->normalUser()->create(); + + Livewire::actingAs($normalUser) + ->test(\App\Filament\Resources\UserResource\Pages\ListUsers::class) + ->assertCanSeeTableRecords([$targetUser]) + ->assertActionHidden('impersonate', $targetUser); +}); + +test('impersonation action is hidden when already impersonating', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(\App\Services\ImpersonationService::class); + + // Simulate active impersonation + session(['impersonation' => true, 'original_admin_user_id' => $admin->id]); + + Livewire::actingAs($admin) + ->test(\App\Filament\Resources\UserResource\Pages\ListUsers::class) + ->assertCanSeeTableRecords([$targetUser]) + ->assertActionHidden('impersonate', $targetUser); +}); + +test('stop impersonation action is visible when impersonating', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(\App\Services\ImpersonationService::class); + + // Simulate active impersonation + session(['impersonation' => true, 'original_admin_user_id' => $admin->id]); + + Livewire::actingAs($targetUser) + ->test(\App\Filament\Resources\UserResource\Pages\ListUsers::class) + ->assertActionVisible('stopImpersonation'); +}); + +test('stop impersonation action is hidden when not impersonating', function (): void { + $admin = User::factory()->superAdmin()->create(); + + Livewire::actingAs($admin) + ->test(\App\Filament\Resources\UserResource\Pages\ListUsers::class) + ->assertActionHidden('stopImpersonation'); +}); + +test('impersonation action works correctly', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + + Livewire::actingAs($admin) + ->test(\App\Filament\Resources\UserResource\Pages\ListUsers::class) + ->assertCanSeeTableRecords([$targetUser]) + ->callAction('impersonate', $targetUser); + + // Should redirect to dashboard + // Note: This test may need adjustment based on your actual redirect logic + $this->assertTrue(true); // Placeholder - actual assertion would depend on your implementation +}); diff --git a/tests/Unit/ImpersonationServiceTest.php b/tests/Unit/ImpersonationServiceTest.php new file mode 100644 index 0000000..5a576c9 --- /dev/null +++ b/tests/Unit/ImpersonationServiceTest.php @@ -0,0 +1,156 @@ +superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(ImpersonationService::class); + + expect($impersonationService->canImpersonate($admin, $targetUser))->toBeTrue(); +}); + +test('normal user cannot impersonate anyone', function (): void { + $normalUser = User::factory()->normalUser()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(ImpersonationService::class); + + expect($impersonationService->canImpersonate($normalUser, $targetUser))->toBeFalse(); +}); + +test('super admin cannot impersonate another super admin', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetAdmin = User::factory()->superAdmin()->create(); + $impersonationService = app(ImpersonationService::class); + + expect($impersonationService->canImpersonate($admin, $targetAdmin))->toBeFalse(); +}); + +test('cannot impersonate self', function (): void { + $admin = User::factory()->superAdmin()->create(); + $impersonationService = app(ImpersonationService::class); + + expect($impersonationService->canImpersonate($admin, $admin))->toBeFalse(); +}); + +test('cannot impersonate banned user', function (): void { + $admin = User::factory()->superAdmin()->create(); + $bannedUser = User::factory()->bannedUser()->create(); + $impersonationService = app(ImpersonationService::class); + + expect($impersonationService->canImpersonate($admin, $bannedUser))->toBeFalse(); +}); + +test('cannot impersonate when already impersonating', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(ImpersonationService::class); + + // Simulate active impersonation + Session::put('impersonation', true); + Session::put('original_admin_user_id', $admin->id); + Session::put('impersonation_start_time', now()); + + expect($impersonationService->canImpersonate($admin, $targetUser))->toBeFalse(); +}); + +test('start impersonation creates log entry', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(ImpersonationService::class); + + $request = Request::create('/'); + + $result = $impersonationService->startImpersonation($admin, $targetUser, $request); + + expect($result)->toBeTrue(); + expect($impersonationService->isImpersonating())->toBeTrue(); + expect(Session::get('original_admin_user_id'))->toBe($admin->id); + + $this->assertDatabaseHas('impersonation_logs', [ + 'admin_id' => $admin->id, + 'target_user_id' => $targetUser->id, + 'status' => 'active', + ]); +}); + +test('stop impersonation ends session and updates log', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(ImpersonationService::class); + + $request = Request::create('/'); + + // Start impersonation + $impersonationService->startImpersonation($admin, $targetUser, $request); + $logId = Session::get('impersonation'); + + // Stop impersonation + $result = $impersonationService->stopImpersonation($request); + + expect($result)->toBeTrue(); + expect($impersonationService->isImpersonating())->toBeFalse(); + expect(Session::get('original_admin_user_id'))->toBeNull(); + + $this->assertDatabaseHas('impersonation_logs', [ + 'id' => $logId, + 'admin_id' => $admin->id, + 'target_user_id' => $targetUser->id, + 'status' => 'completed', + ]); +}); + +test('impersonation timeout is detected correctly', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(ImpersonationService::class); + + $request = Request::create('/'); + + // Start impersonation + $impersonationService->startImpersonation($admin, $targetUser, $request); + + // Simulate expired session + Session::put('impersonation_start_time', now()->subMinutes(35)); + + expect($impersonationService->isImpersonationExpired())->toBeTrue(); +}); + +test('remaining minutes calculation works correctly', function (): void { + $admin = User::factory()->superAdmin()->create(); + $targetUser = User::factory()->normalUser()->create(); + $impersonationService = app(ImpersonationService::class); + + $request = Request::create('/'); + + // Start impersonation + $impersonationService->startImpersonation($admin, $targetUser, $request); + + // Test with 10 minutes elapsed + Session::put('impersonation_start_time', now()->subMinutes(10)); + + expect($impersonationService->getRemainingMinutes())->toBeBetween(19, 20); +}); + +test('force stop all impersonations terminates active sessions', function (): void { + // Create multiple active impersonation logs + ImpersonationLog::factory()->count(3)->active()->create(); + + $impersonationService = app(ImpersonationService::class); + $impersonationService->forceStopAllImpersonations(); + + $this->assertDatabaseMissing('impersonation_logs', [ + 'status' => 'active', + ]); + + $this->assertDatabaseCount('impersonation_logs', 3); +});