feat: add user impersonation service
This commit is contained in:
@@ -9,7 +9,9 @@ use App\Filament\Resources\UserResource\Pages\ListUsers;
|
|||||||
use App\Filament\Resources\UserResource\RelationManagers\LogsRelationManager;
|
use App\Filament\Resources\UserResource\RelationManagers\LogsRelationManager;
|
||||||
use App\Filament\Resources\UserResource\RelationManagers\UsageLogsRelationManager;
|
use App\Filament\Resources\UserResource\RelationManagers\UsageLogsRelationManager;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\ImpersonationService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -49,6 +51,12 @@ class UserResource extends Resource
|
|||||||
->email()
|
->email()
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
TextInput::make('password')
|
||||||
|
->password()
|
||||||
|
->required()
|
||||||
|
->minLength(6)
|
||||||
|
->maxLength(255)
|
||||||
|
->visibleOn('create'),
|
||||||
TextInput::make('email_verified_at')
|
TextInput::make('email_verified_at')
|
||||||
->label('Email Verification Status')
|
->label('Email Verification Status')
|
||||||
->disabled()
|
->disabled()
|
||||||
@@ -149,8 +157,65 @@ class UserResource extends Resource
|
|||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
EditAction::make(),
|
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([
|
->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([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make(),
|
||||||
BulkAction::make('updateLevel')
|
BulkAction::make('updateLevel')
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use App\Filament\Resources\UserResource;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
use Illuminate\Support\Facades\Response;
|
use Illuminate\Support\Facades\Response;
|
||||||
|
|
||||||
class EditUser extends EditRecord
|
class EditUser extends EditRecord
|
||||||
@@ -17,6 +19,25 @@ class EditUser extends EditRecord
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
DeleteAction::make(),
|
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')
|
Action::make('download_report')
|
||||||
->label('Download User Report')
|
->label('Download User Report')
|
||||||
->icon('heroicon-o-user')
|
->icon('heroicon-o-user')
|
||||||
|
|||||||
115
app/Http/Controllers/ImpersonationController.php
Normal file
115
app/Http/Controllers/ImpersonationController.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ImpersonationService;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
final class ImpersonationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImpersonationService $impersonationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start impersonating a user.
|
||||||
|
*/
|
||||||
|
public function start(Request $request, User $user)
|
||||||
|
{
|
||||||
|
$admin = Auth::user();
|
||||||
|
|
||||||
|
if (! $admin || ! $admin->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Http/Middleware/ImpersonationMiddleware.php
Normal file
52
app/Http/Middleware/ImpersonationMiddleware.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\ImpersonationService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class ImpersonationMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImpersonationService $impersonationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Check if impersonation has expired
|
||||||
|
if ($this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Models/ImpersonationLog.php
Normal file
132
app/Models/ImpersonationLog.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
final class ImpersonationLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'admin_id',
|
||||||
|
'target_user_id',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'status',
|
||||||
|
'pages_visited',
|
||||||
|
'actions_taken',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'start_time' => '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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,4 +139,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
|||||||
{
|
{
|
||||||
return $this->hasMany(UsageLog::class);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
app/Policies/ImpersonationPolicy.php
Normal file
97
app/Policies/ImpersonationPolicy.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\enum\UserLevel;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ImpersonationService;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
final class ImpersonationPolicy
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImpersonationService $impersonationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function start(User $admin, User $target): Response
|
||||||
|
{
|
||||||
|
// Only SUPERADMIN can impersonate
|
||||||
|
if ($admin->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
197
app/Services/ImpersonationService.php
Normal file
197
app/Services/ImpersonationService.php
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\enum\UserLevel;
|
||||||
|
use App\Models\ImpersonationLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
final class ImpersonationService
|
||||||
|
{
|
||||||
|
private const IMPERSONATION_SESSION_KEY = 'impersonation';
|
||||||
|
|
||||||
|
private const IMPERSONATION_START_TIME_KEY = 'impersonation_start_time';
|
||||||
|
|
||||||
|
private const ORIGINAL_USER_KEY = 'original_admin_user_id';
|
||||||
|
|
||||||
|
private const IMPERSONATION_TIMEOUT = 30; // minutes
|
||||||
|
|
||||||
|
public function canImpersonate(User $admin, User $target): bool
|
||||||
|
{
|
||||||
|
// Only SUPERADMIN can impersonate
|
||||||
|
if ($admin->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/View/Components/ImpersonationBanner.php
Normal file
26
app/View/Components/ImpersonationBanner.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
|
||||||
|
class ImpersonationBanner extends Component
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new component instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the view / contents that represent the component.
|
||||||
|
*/
|
||||||
|
public function render(): View|Closure|string
|
||||||
|
{
|
||||||
|
return view('components.impersonation-banner');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\ImpersonationMiddleware;
|
||||||
use App\Http\Middleware\Locale;
|
use App\Http\Middleware\Locale;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
@@ -15,6 +16,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
Locale::class,
|
Locale::class,
|
||||||
|
ImpersonationMiddleware::class,
|
||||||
|
]);
|
||||||
|
$middleware->alias([
|
||||||
|
'impersonation' => ImpersonationMiddleware::class,
|
||||||
]);
|
]);
|
||||||
$middleware->validateCsrfTokens(except: [
|
$middleware->validateCsrfTokens(except: [
|
||||||
'stripe/*',
|
'stripe/*',
|
||||||
|
|||||||
95
database/factories/ImpersonationLogFactory.php
Normal file
95
database/factories/ImpersonationLogFactory.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ImpersonationLog>
|
||||||
|
*/
|
||||||
|
final class ImpersonationLogFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ class UserFactory extends Factory
|
|||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes): array => [
|
return $this->state(fn (array $attributes): array => [
|
||||||
'level' => UserLevel::SUPERADMIN->value,
|
'level' => UserLevel::SUPERADMIN->value,
|
||||||
'email' => 'admin@zemail.me',
|
'email' => fake()->unique()->safeEmail(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('impersonation_logs', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
86
resources/views/components/impersonation-banner.blade.php
Normal file
86
resources/views/components/impersonation-banner.blade.php
Normal file
@@ -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)
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-50 bg-amber-600 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Impersonation Icon -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Impersonation Info -->
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-sm">
|
||||||
|
Impersonating: {{ $impersonationTarget?->name ?? 'Unknown User' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-90">
|
||||||
|
{{ $impersonationTarget?->email }}
|
||||||
|
@if($impersonationRemainingMinutes > 0)
|
||||||
|
• {{ $impersonationRemainingMinutes }} minutes remaining
|
||||||
|
@else
|
||||||
|
• Session expiring soon
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<!-- Timer Warning -->
|
||||||
|
@if($impersonationRemainingMinutes <= 5 && $impersonationRemainingMinutes > 0)
|
||||||
|
<div class="text-xs bg-white/20 px-2 py-1 rounded">
|
||||||
|
⚠️ Less than 5 minutes remaining
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Stop Impersonation Button -->
|
||||||
|
<form action="{{ route('impersonation.stop') }}" method="POST" class="inline">
|
||||||
|
@csrf
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-white text-red-600 hover:bg-red-50 px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 flex items-center space-x-2 cursor-pointer">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
<span>Stop Impersonating</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
@if($impersonationRemainingMinutes > 0)
|
||||||
|
<div class="mt-2 bg-white/20 rounded-full h-1">
|
||||||
|
<div class="bg-white h-1 rounded-full transition-all duration-1000 ease-linear"
|
||||||
|
style="width: {{ ($impersonationRemainingMinutes / 30) * 100 }}%"></div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add padding to body content to account for fixed banner -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Add top padding to main content to account for fixed banner
|
||||||
|
const mainContent = document.querySelector('main') || document.querySelector('[role="main"]') || document.querySelector('.flux-main') || document.body;
|
||||||
|
if (mainContent) {
|
||||||
|
mainContent.style.paddingTop = '80px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh timer every minute
|
||||||
|
setInterval(function() {
|
||||||
|
// You could implement a small AJAX call here to refresh the remaining time
|
||||||
|
// For now, the page refresh on navigation will update the timer
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endif
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
@yield('custom_header')
|
@yield('custom_header')
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
||||||
|
<x-impersonation-banner />
|
||||||
<flux:sidebar sticky stashable class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-zinc-200 dark:border-zinc-700">
|
<flux:sidebar sticky stashable class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-zinc-200 dark:border-zinc-700">
|
||||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
||||||
<a class="flex items-center px-2 py-2 cursor-pointer" href="{{ route('home') }}">
|
<a class="flex items-center px-2 py-2 cursor-pointer" href="{{ route('home') }}">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
@yield('custom_header')
|
@yield('custom_header')
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
||||||
|
<x-impersonation-banner />
|
||||||
<flux:sidebar sticky stashable class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-zinc-200 dark:border-zinc-700">
|
<flux:sidebar sticky stashable class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-zinc-200 dark:border-zinc-700">
|
||||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
||||||
<a class="flex items-center px-2 py-2 cursor-pointer" href="{{ route('dashboard') }}">
|
<a class="flex items-center px-2 py-2 cursor-pointer" href="{{ route('dashboard') }}">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@if(auth()->user()->subscribedToProduct(config('app.plans')[0]->product_id))
|
@if(auth()->user()->subscribedToProduct(config('app.plans')[0]['product_id']))
|
||||||
|
|
||||||
<article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
|
<article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Request;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use App\Http\Controllers\AppController;
|
use App\Http\Controllers\AppController;
|
||||||
|
use App\Http\Controllers\ImpersonationController;
|
||||||
use App\Http\Controllers\WebhookController;
|
use App\Http\Controllers\WebhookController;
|
||||||
use App\Http\Middleware\CheckPageSlug;
|
use App\Http\Middleware\CheckPageSlug;
|
||||||
use App\Http\Middleware\CheckUserBanned;
|
use App\Http\Middleware\CheckUserBanned;
|
||||||
@@ -22,6 +20,9 @@ use App\Livewire\Settings\Appearance;
|
|||||||
use App\Livewire\Settings\Password;
|
use App\Livewire\Settings\Password;
|
||||||
use App\Livewire\Settings\Profile;
|
use App\Livewire\Settings\Profile;
|
||||||
use App\Models\Email;
|
use App\Models\Email;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', Home::class)->name('home');
|
Route::get('/', Home::class)->name('home');
|
||||||
@@ -137,6 +138,13 @@ Route::middleware(['auth', 'verified', CheckUserBanned::class])->group(function
|
|||||||
]);
|
]);
|
||||||
})->name('cacheClear');
|
})->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 {
|
Route::middleware(['auth'])->group(function (): void {
|
||||||
|
|||||||
138
tests/Feature/ImpersonationControllerTest.php
Normal file
138
tests/Feature/ImpersonationControllerTest.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('super admin can start impersonating normal user', function (): void {
|
||||||
|
$admin = User::factory()->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');
|
||||||
|
});
|
||||||
88
tests/Feature/UserResourceImpersonationTest.php
Normal file
88
tests/Feature/UserResourceImpersonationTest.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('super admin can see impersonation action for normal user', function (): void {
|
||||||
|
$admin = User::factory()->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
|
||||||
|
});
|
||||||
156
tests/Unit/ImpersonationServiceTest.php
Normal file
156
tests/Unit/ImpersonationServiceTest.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ImpersonationLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ImpersonationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('super admin can impersonate normal user', function (): void {
|
||||||
|
$admin = User::factory()->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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user