diff --git a/.env.example b/.env.example
index 88c402990b..0083b36c79 100644
--- a/.env.example
+++ b/.env.example
@@ -221,6 +221,11 @@ LOG_CHANNEL=single
LOG_DEPRECATIONS=false
LOG_MAX_DAYS=10
APP_LOCKED=false
+# Comma-separated usernames allowed to impersonate other users (e.g. admin,jsmith).
+# Users in this list must ALSO be superusers. Wrap usernames that contain commas
+# in double quotes, e.g. ALLOW_USER_IMPERSONATION=admin,"jane, doe". Leave blank
+# to disable entirely.
+ALLOW_USER_IMPERSONATION=
APP_CIPHER=AES-256-CBC
APP_FORCE_TLS=false
APP_ALLOW_INSECURE_HOSTS=false
diff --git a/app/Enums/ActionType.php b/app/Enums/ActionType.php
index a065b376d0..4655397d27 100644
--- a/app/Enums/ActionType.php
+++ b/app/Enums/ActionType.php
@@ -26,6 +26,8 @@ enum ActionType: string
case Merged = 'merged';
case TokenRevoked = 'token revoked';
case TokenUnrevoked = 'token unrevoked';
+ case Impersonated = 'impersonated';
+ case StoppedImpersonating = 'stopped impersonating';
// Licenses
case DeleteSeats = 'delete seats';
diff --git a/app/Helpers/IconHelper.php b/app/Helpers/IconHelper.php
index 5b57c589a9..9be6b627ef 100644
--- a/app/Helpers/IconHelper.php
+++ b/app/Helpers/IconHelper.php
@@ -122,6 +122,10 @@ class IconHelper
return 'fa-solid fa-key';
case 'api-key':
return 'fas fa-user-secret';
+ case 'impersonate':
+ return 'fa-solid fa-person-walking-arrow-right';
+ case 'undo':
+ return 'fas fa-arrow-left';
case 'nav-toggle':
return 'fas fa-bars';
case 'dashboard':
diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php
index 5bfd5390c1..f3dabe3864 100644
--- a/app/Http/Controllers/SettingsController.php
+++ b/app/Http/Controllers/SettingsController.php
@@ -54,7 +54,23 @@ class SettingsController extends Controller
{
$settings = Setting::getSettings();
- return view('settings/index', compact('settings'));
+ $impersonationUsernames = (array) config('app.user_impersonation_usernames');
+ if (empty($impersonationUsernames)) {
+ $impersonators = collect();
+ $missingImpersonationUsernames = [];
+ } else {
+ $impersonators = User::withTrashed()
+ ->whereIn(DB::raw('LOWER(username)'), array_map('mb_strtolower', $impersonationUsernames))
+ ->orderBy('username')
+ ->get();
+ $foundLower = $impersonators->map(fn ($u) => mb_strtolower((string) $u->username))->all();
+ $missingImpersonationUsernames = array_values(array_filter(
+ $impersonationUsernames,
+ fn ($name) => ! in_array(mb_strtolower($name), $foundLower, true)
+ ));
+ }
+
+ return view('settings/index', compact('settings', 'impersonators', 'missingImpersonationUsernames'));
}
/**
diff --git a/app/Http/Controllers/Users/ImpersonateController.php b/app/Http/Controllers/Users/ImpersonateController.php
new file mode 100644
index 0000000000..d894696b14
--- /dev/null
+++ b/app/Http/Controllers/Users/ImpersonateController.php
@@ -0,0 +1,92 @@
+canImpersonate()) {
+ abort(403);
+ }
+
+ if ($user->id === $actor->id) {
+ return redirect()->route('users.show', $user)
+ ->with('error', trans('admin/users/message.impersonate.cannot_impersonate_self'));
+ }
+
+ if ($user->deleted_at !== null || $user->activated != 1) {
+ return redirect()->route('users.show', $user)
+ ->with('error', trans('admin/users/message.impersonate.target_not_active'));
+ }
+
+ $log = new Actionlog;
+ $log->item_type = User::class;
+ $log->item_id = $user->id;
+ $log->target_type = User::class;
+ $log->target_id = $user->id;
+ $log->created_at = date('Y-m-d H:i:s');
+ $log->created_by = $actor->id;
+ $log->logaction('impersonated');
+
+ $impersonatorId = $actor->id;
+ Auth::login($user);
+ $request->session()->put('impersonator_id', $impersonatorId);
+
+ return redirect()->route('home')
+ ->with('success', trans('admin/users/message.impersonate.started', ['name' => $user->display_name]));
+ }
+
+ public function stop(Request $request): RedirectResponse
+ {
+ $impersonatorId = $request->session()->pull('impersonator_id');
+
+ if (! $impersonatorId) {
+ return redirect()->route('home');
+ }
+
+ $impersonatedId = Auth::id();
+ // Bypass CompanyableScope: the impersonated user may not share a company with the
+ // original superuser, but we still need to restore their session.
+ $impersonator = User::withTrashed()
+ ->withoutGlobalScope(CompanyableScope::class)
+ ->find($impersonatorId);
+
+ if (! $impersonator) {
+ Auth::logout();
+
+ return redirect()->route('login')
+ ->with('error', trans('admin/users/message.impersonate.impersonator_missing'));
+ }
+
+ if ($impersonatedId) {
+ $log = new Actionlog;
+ $log->item_type = User::class;
+ $log->item_id = $impersonatedId;
+ $log->target_type = User::class;
+ $log->target_id = $impersonatedId;
+ $log->created_at = date('Y-m-d H:i:s');
+ $log->created_by = $impersonator->id;
+ $log->logaction('stopped impersonating');
+ }
+
+ Auth::login($impersonator);
+
+ return redirect()->route('users.show', $impersonatedId ?: $impersonator->id)
+ ->with('success', trans('admin/users/message.impersonate.stopped'));
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index 6a6b92ef0e..4c973f9523 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -552,6 +552,17 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return $this->checkPermissionSection('superuser');
}
+ public function canImpersonate(): bool
+ {
+ if (! $this->isSuperUser()) {
+ return false;
+ }
+
+ $allowed = array_map('mb_strtolower', (array) config('app.user_impersonation_usernames'));
+
+ return in_array(mb_strtolower((string) $this->username), $allowed, true);
+ }
+
/**
* Checks if the user is an admin
*
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 01c795a35f..eff02cb574 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -23,6 +23,7 @@ use App\Observers\LocationObserver;
use App\Observers\MaintenanceObserver;
use App\Observers\SettingObserver;
use App\Observers\UserObserver;
+use App\View\Composers\ImpersonationBannerComposer;
use App\View\Composers\SidebarComposer;
use Illuminate\Pagination\Paginator;
use Illuminate\Routing\UrlGenerator;
@@ -78,6 +79,7 @@ class AppServiceProvider extends ServiceProvider
Paginator::useBootstrap();
View::composer('layouts.default', SidebarComposer::class);
+ View::composer('partials.impersonation-banner', ImpersonationBannerComposer::class);
Schema::defaultStringLength(191);
Accessory::observe(AccessoryObserver::class);
diff --git a/app/View/Composers/ImpersonationBannerComposer.php b/app/View/Composers/ImpersonationBannerComposer.php
new file mode 100644
index 0000000000..4136e0a7ea
--- /dev/null
+++ b/app/View/Composers/ImpersonationBannerComposer.php
@@ -0,0 +1,28 @@
+withoutGlobalScope(CompanyableScope::class)
+ ->find($impersonatorId);
+ }
+
+ $view->with('impersonator', $impersonator);
+ }
+}
diff --git a/config/app.php b/config/app.php
index 6bef5896c7..2d1cc93d4e 100755
--- a/config/app.php
+++ b/config/app.php
@@ -357,6 +357,41 @@ return [
'lock_passwords' => env('APP_LOCKED', false),
+ /*
+ |--------------------------------------------------------------------------
+ | Superuser Impersonation
+ |--------------------------------------------------------------------------
+ |
+ | Comma-separated list of usernames allowed to impersonate other users.
+ | Users in this list must ALSO be superusers. Empty or unset means the
+ | feature is completely off. Wrap usernames that contain commas in double
+ | quotes. Example: ALLOW_USER_IMPERSONATION=admin,"jane, doe"
+ |
+ */
+
+ 'user_impersonation_usernames' => (function () {
+ $raw = env('ALLOW_USER_IMPERSONATION', '');
+
+ // Reject anything that isn't a string. env() converts literal "true"/"false"/"null"/etc.
+ // to PHP bool/null, which would otherwise stringify to garbage tokens.
+ if (! is_string($raw) || trim($raw) === '') {
+ return [];
+ }
+
+ $usernames = [];
+ $seen = [];
+ foreach (str_getcsv($raw, ',', '"', '\\') as $token) {
+ $token = trim((string) $token);
+ $lower = mb_strtolower($token);
+ if ($token !== '' && ! isset($seen[$lower])) {
+ $usernames[] = $token;
+ $seen[$lower] = true;
+ }
+ }
+
+ return $usernames;
+ })(),
+
/*
|--------------------------------------------------------------------------
| Minimum PHP version
diff --git a/resources/lang/en-US/admin/settings/general.php b/resources/lang/en-US/admin/settings/general.php
index 7129dfa9b2..d7e3b5dd62 100644
--- a/resources/lang/en-US/admin/settings/general.php
+++ b/resources/lang/en-US/admin/settings/general.php
@@ -444,6 +444,12 @@ return [
'mail_from' => 'Mail From Address',
'database_driver' => 'Database Driver',
'bs_table_storage' => 'Table Storage',
+ 'user_impersonation' => 'User Impersonation',
+ 'user_impersonation_disabled' => 'Disabled. Set ALLOW_USER_IMPERSONATION in your .env to a comma-separated list of usernames.',
+ 'user_impersonation_not_superuser' => 'Not a superuser',
+ 'user_impersonation_not_superuser_help' => 'This user is in the impersonation allowlist but is not a superuser, so they cannot actually impersonate anyone. Remove them from the .env or grant superuser access.',
+ 'user_impersonation_missing' => 'User not found',
+ 'user_impersonation_deactivated' => 'Deactivated',
'timezone' => 'Timezone',
'test_mail' => 'Test Mail',
'profile_edit' => 'Edit Profile',
diff --git a/resources/lang/en-US/admin/users/general.php b/resources/lang/en-US/admin/users/general.php
index 16e2ceaca5..813317334c 100644
--- a/resources/lang/en-US/admin/users/general.php
+++ b/resources/lang/en-US/admin/users/general.php
@@ -65,4 +65,8 @@ return [
'no_companies_assigned' => '(No companies assigned)',
'cannot_edit_privileged_user_companies' => 'Only an admin or super admin can modify the company assignments of an admin or super admin user.',
'cannot_manage_companies_without_membership' => 'Full multiple company support with floater mode is enabled, so you must be assigned to at least one company before you can manage another user\'s company assignments.',
+ 'impersonate_user' => 'Login as :name',
+ 'impersonating_banner_title' => 'Impersonating:',
+ 'impersonating_banner_text' => 'You are currently logged in as :name. Anything you do will be recorded as if :name did it. Your real account is :impersonator.',
+ 'impersonating_stop_link' => 'Switch back to :name',
];
diff --git a/resources/lang/en-US/admin/users/message.php b/resources/lang/en-US/admin/users/message.php
index f9c92ba424..767ca91a40 100644
--- a/resources/lang/en-US/admin/users/message.php
+++ b/resources/lang/en-US/admin/users/message.php
@@ -20,6 +20,14 @@ return [
'user_has_no_email' => 'This user does not have an email address in their profile.',
'log_record_not_found' => 'A matching log record for this user could not be found.',
+ 'impersonate' => [
+ 'started' => 'You are now logged in as :name.',
+ 'stopped' => 'You are back to your own account.',
+ 'cannot_impersonate_self' => 'You cannot log in as yourself.',
+ 'target_not_active' => 'That user is deactivated or deleted and cannot be logged in as.',
+ 'impersonator_missing' => 'The original account for this impersonation session no longer exists. Please log in again.',
+ ],
+
'success' => [
'create' => 'User was successfully created.',
'update' => 'User was successfully updated.',
diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php
index dbe2e9d85b..75c37b3619 100644
--- a/resources/lang/en-US/general.php
+++ b/resources/lang/en-US/general.php
@@ -281,6 +281,9 @@ return [
'reports' => 'Reports',
'restored' => 'restored',
'restore' => 'Restore',
+ 'impersonated' => 'Logged in as',
+ 'impersonate' => 'Login as This User',
+ 'stopped_impersonating' => 'Stopped impersonating',
'requestable_models' => 'Requestable Models',
'requestable_items' => 'Requestable Items',
'requestable' => 'Requestable',
diff --git a/resources/views/layouts/default.blade.php b/resources/views/layouts/default.blade.php
index bbdeea2c01..c2c2e06164 100644
--- a/resources/views/layouts/default.blade.php
+++ b/resources/views/layouts/default.blade.php
@@ -1939,6 +1939,8 @@
+ @include('partials.impersonation-banner')
+
@if ($debug_in_production)
+
+
+ {{ trans('admin/users/general.impersonating_banner_title') }}
+ {{ trans('admin/users/general.impersonating_banner_text', ['name' => Auth::user()->display_name, 'impersonator' => $impersonator->display_name]) }}
+
+
+
+@endif
diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php
index 93a7163cfa..4fe32da853 100755
--- a/resources/views/settings/index.blade.php
+++ b/resources/views/settings/index.blade.php
@@ -455,6 +455,43 @@
+
+
+
+
+ {{ trans('admin/settings/general.user_impersonation') }}:
+
+
+ @if ($impersonators->isEmpty() && empty($missingImpersonationUsernames))
+
{{ trans('admin/settings/general.user_impersonation_disabled') }}
+ @else
+
+ @foreach ($impersonators as $impersonator)
+ -
+ {{ $impersonator->display_name }}
+
{{ $impersonator->username }}
+ @if (! $impersonator->isSuperUser())
+
+ {{ trans('admin/settings/general.user_impersonation_not_superuser') }}
+
+ @endif
+ @if ($impersonator->deleted_at !== null)
+ {{ trans('general.deleted') }}
+ @elseif ($impersonator->activated != 1)
+ {{ trans('admin/settings/general.user_impersonation_deactivated') }}
+ @endif
+
+ @endforeach
+ @foreach ($missingImpersonationUsernames as $missingUsername)
+ -
+
{{ $missingUsername }}
+ {{ trans('admin/settings/general.user_impersonation_missing') }}
+
+ @endforeach
+
+ @endif
+
+
diff --git a/resources/views/users/view.blade.php b/resources/views/users/view.blade.php
index 4660889002..29f9f462b4 100755
--- a/resources/views/users/view.blade.php
+++ b/resources/views/users/view.blade.php
@@ -292,11 +292,25 @@
+
+ @if (Auth::check() && Auth::user()->canImpersonate() && ($user->id !== Auth::id()) && ($user->deleted_at === null) && ($user->activated == 1))
+
+ @endif
+
+
@if ( ($user->activated == '1') && (auth()->user()->isSuperUser()) && ($user->two_factor_active_and_enrolled()) && ($snipeSettings->two_factor_enabled!='0') && ($snipeSettings->two_factor_enabled!=''))
-
+
+
{{ trans('admin/settings/general.two_factor_reset') }}
@@ -312,11 +326,6 @@
@endif
- @if ($snipeSettings->isQrEnabled())
-
-
 }})
-
- @endif
@@ -543,7 +552,7 @@
-
+
{{ trans('general.eula') }}
@@ -611,7 +620,8 @@
- @if($user->allAssignedCount() != '0')
+
+ @if($user->allAssignedCount() != '0')
diff --git a/routes/web/users.php b/routes/web/users.php
index 878d40a324..f19c2c5995 100644
--- a/routes/web/users.php
+++ b/routes/web/users.php
@@ -90,6 +90,22 @@ Route::group(['prefix' => 'users', 'middleware' => ['auth']], function () {
]
)->name('users.acceptance_reminder')->withTrashed();
+ Route::post(
+ '{user}/impersonate',
+ [
+ Users\ImpersonateController::class,
+ 'start',
+ ]
+ )->name('users.impersonate.start');
+
+ Route::post(
+ 'impersonate/stop',
+ [
+ Users\ImpersonateController::class,
+ 'stop',
+ ]
+ )->name('users.impersonate.stop');
+
Route::post(
'bulkedit',
[
diff --git a/tests/Feature/Settings/ImpersonationSettingsRowTest.php b/tests/Feature/Settings/ImpersonationSettingsRowTest.php
new file mode 100644
index 0000000000..b92eab068f
--- /dev/null
+++ b/tests/Feature/Settings/ImpersonationSettingsRowTest.php
@@ -0,0 +1,79 @@
+ []]);
+
+ $admin = User::factory()->superuser()->create();
+
+ $this->actingAs($admin)
+ ->get(route('settings.index'))
+ ->assertOk()
+ ->assertSee(trans('admin/settings/general.user_impersonation'))
+ ->assertSee(trans('admin/settings/general.user_impersonation_disabled'));
+ }
+
+ public function test_row_lists_a_valid_superuser_without_warnings()
+ {
+ $allowed = User::factory()->superuser()->create(['username' => 'allowed_super']);
+ config(['app.user_impersonation_usernames' => [$allowed->username]]);
+
+ $admin = User::factory()->superuser()->create();
+
+ $this->actingAs($admin)
+ ->get(route('settings.index'))
+ ->assertOk()
+ ->assertSee($allowed->display_name)
+ ->assertSee($allowed->username)
+ ->assertDontSee(trans('admin/settings/general.user_impersonation_not_superuser'))
+ ->assertDontSee(trans('admin/settings/general.user_impersonation_missing'));
+ }
+
+ public function test_row_flags_non_superuser_in_allowlist()
+ {
+ $notSuper = User::factory()->admin()->create(['username' => 'not_super']);
+ config(['app.user_impersonation_usernames' => [$notSuper->username]]);
+
+ $admin = User::factory()->superuser()->create();
+
+ $this->actingAs($admin)
+ ->get(route('settings.index'))
+ ->assertOk()
+ ->assertSee($notSuper->display_name)
+ ->assertSee(trans('admin/settings/general.user_impersonation_not_superuser'));
+ }
+
+ public function test_row_flags_missing_username()
+ {
+ config(['app.user_impersonation_usernames' => ['ghost_admin']]);
+
+ $admin = User::factory()->superuser()->create();
+
+ $this->actingAs($admin)
+ ->get(route('settings.index'))
+ ->assertOk()
+ ->assertSee('ghost_admin')
+ ->assertSee(trans('admin/settings/general.user_impersonation_missing'));
+ }
+
+ public function test_row_case_insensitively_matches_usernames()
+ {
+ $allowed = User::factory()->superuser()->create(['username' => 'SnipeAdmin']);
+ config(['app.user_impersonation_usernames' => ['snipeadmin']]);
+
+ $admin = User::factory()->superuser()->create();
+
+ $this->actingAs($admin)
+ ->get(route('settings.index'))
+ ->assertOk()
+ ->assertSee($allowed->display_name)
+ ->assertDontSee(trans('admin/settings/general.user_impersonation_missing'));
+ }
+}
diff --git a/tests/Feature/Users/ImpersonateUserTest.php b/tests/Feature/Users/ImpersonateUserTest.php
new file mode 100644
index 0000000000..6dcad6f6c8
--- /dev/null
+++ b/tests/Feature/Users/ImpersonateUserTest.php
@@ -0,0 +1,241 @@
+ array_map(fn ($u) => $u->username, $users)]);
+ }
+
+ public function test_impersonate_endpoint_is_disabled_when_list_is_empty()
+ {
+ config(['app.user_impersonation_usernames' => []]);
+
+ $actor = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 1]);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target))
+ ->assertNotFound();
+
+ $this->assertNull(session('impersonator_id'));
+ }
+
+ public function test_non_superuser_cannot_impersonate_even_if_id_is_in_list()
+ {
+ $actor = User::factory()->admin()->create();
+ $target = User::factory()->create(['activated' => 1]);
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target))
+ ->assertForbidden();
+
+ $this->assertNull(session('impersonator_id'));
+ }
+
+ public function test_superuser_not_in_allowlist_cannot_impersonate()
+ {
+ $actor = User::factory()->superuser()->create();
+ $someoneElse = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 1]);
+ $this->allow($someoneElse);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target))
+ ->assertForbidden();
+
+ $this->assertNull(session('impersonator_id'));
+ }
+
+ public function test_allowlisted_superuser_can_impersonate_activated_user()
+ {
+ $actor = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 1]);
+ $this->allow($actor);
+
+ $response = $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target));
+
+ $response->assertRedirect(route('home'));
+ $this->assertSame($target->id, auth()->id());
+ $this->assertSame($actor->id, session('impersonator_id'));
+
+ $this->assertDatabaseHas('action_logs', [
+ 'item_type' => User::class,
+ 'item_id' => $target->id,
+ 'created_by' => $actor->id,
+ 'action_type' => 'impersonated',
+ ]);
+ }
+
+ public function test_allowlisted_superuser_cannot_impersonate_deactivated_user()
+ {
+ $actor = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 0]);
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target))
+ ->assertRedirect(route('users.show', $target));
+
+ $this->assertSame($actor->id, auth()->id());
+ $this->assertNull(session('impersonator_id'));
+ }
+
+ public function test_allowlisted_superuser_cannot_impersonate_themselves()
+ {
+ $actor = User::factory()->superuser()->create();
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $actor))
+ ->assertRedirect(route('users.show', $actor));
+
+ $this->assertNull(session('impersonator_id'));
+ }
+
+ public function test_stop_impersonation_restores_original_user()
+ {
+ $actor = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 1]);
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target))
+ ->assertRedirect(route('home'));
+
+ $this->assertSame($target->id, auth()->id());
+
+ $stop = $this->post(route('users.impersonate.stop'));
+
+ $stop->assertRedirect(route('users.show', $target));
+ $this->assertSame($actor->id, auth()->id());
+ $this->assertNull(session('impersonator_id'));
+
+ $this->assertDatabaseHas('action_logs', [
+ 'item_type' => User::class,
+ 'item_id' => $target->id,
+ 'created_by' => $actor->id,
+ 'action_type' => 'stopped impersonating',
+ ]);
+ }
+
+ public function test_banner_is_visible_after_impersonating_a_non_admin()
+ {
+ $actor = User::factory()->superuser()->create(['first_name' => 'Sooper', 'last_name' => 'Actor']);
+ $target = User::factory()->create(['activated' => 1, 'first_name' => 'Target', 'last_name' => 'User']);
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target))
+ ->assertRedirect(route('home'));
+
+ $this->assertSame($actor->id, session('impersonator_id'));
+
+ $follow = $this->followingRedirects()->get(route('home'));
+
+ $follow->assertOk()
+ ->assertSee(trans('admin/users/general.impersonating_banner_title'))
+ ->assertSee(route('users.impersonate.stop'), false);
+ }
+
+ public function test_banner_and_stop_work_across_company_scoping()
+ {
+ $this->settings->enableMultipleFullCompanySupport();
+
+ [$companyA, $companyB] = Company::factory()->count(2)->create();
+
+ $actor = User::factory()->superuser()->create();
+ $actor->companies()->sync([$companyA->id]);
+
+ $target = User::factory()->create(['activated' => 1, 'company_id' => $companyB->id]);
+ $target->companies()->sync([$companyB->id]);
+
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.start', $target))
+ ->assertRedirect(route('home'));
+
+ $this->assertSame($target->id, auth()->id());
+ $this->assertSame($actor->id, session('impersonator_id'));
+
+ $follow = $this->followingRedirects()->get(route('home'));
+ $follow->assertOk()
+ ->assertSee(trans('admin/users/general.impersonating_banner_title'))
+ ->assertSee(route('users.impersonate.stop'), false);
+
+ $this->post(route('users.impersonate.stop'))
+ ->assertRedirect(route('users.show', $target));
+
+ $this->assertSame($actor->id, auth()->id());
+ }
+
+ public function test_stop_impersonation_no_op_when_not_impersonating()
+ {
+ $actor = User::factory()->create();
+
+ $this->actingAs($actor)
+ ->post(route('users.impersonate.stop'))
+ ->assertRedirect(route('home'));
+
+ $this->assertSame($actor->id, auth()->id());
+ }
+
+ public function test_button_hidden_when_list_is_empty()
+ {
+ config(['app.user_impersonation_usernames' => []]);
+
+ $actor = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 1]);
+
+ $this->actingAs($actor)
+ ->get(route('users.show', $target))
+ ->assertOk()
+ ->assertDontSee(route('users.impersonate.start', $target));
+ }
+
+ public function test_button_visible_to_allowlisted_superuser()
+ {
+ $actor = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 1]);
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->get(route('users.show', $target))
+ ->assertOk()
+ ->assertSee(route('users.impersonate.start', $target), false);
+ }
+
+ public function test_button_hidden_from_non_allowlisted_superuser()
+ {
+ $actor = User::factory()->superuser()->create();
+ $someoneElse = User::factory()->superuser()->create();
+ $target = User::factory()->create(['activated' => 1]);
+ $this->allow($someoneElse);
+
+ $this->actingAs($actor)
+ ->get(route('users.show', $target))
+ ->assertOk()
+ ->assertDontSee(route('users.impersonate.start', $target));
+ }
+
+ public function test_button_hidden_from_non_superuser_in_allowlist()
+ {
+ $actor = User::factory()->admin()->create();
+ $target = User::factory()->create(['activated' => 1]);
+ $this->allow($actor);
+
+ $this->actingAs($actor)
+ ->get(route('users.show', $target))
+ ->assertOk()
+ ->assertDontSee(route('users.impersonate.start', $target));
+ }
+}