fix: impersonation UX — banner contrast, route blocking, nav filtering

- Banner: white elevated button for contrast, fixed 48px height,
  layout top padding offset so content isn't obscured
- Middleware: allow GET me/profile (viewing), block mutations only;
  add auth/refresh to blocked routes
- Navigation: hide Platform section during impersonation; hide
  org-dependent items when impersonated user has no organisation
- Test: add read-only routes allowed test, auth/refresh blocked test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 02:51:50 +02:00
parent 4df668b5b8
commit 67ce1e9d9d
4 changed files with 64 additions and 37 deletions

View File

@@ -14,15 +14,14 @@ use Symfony\Component\HttpFoundation\Response;
class HandleImpersonation
{
/**
* Routes that are blocked during impersonation.
* These are prefix-matched against the request path (without api/v1 prefix).
* Routes that are blocked during impersonation (all methods).
* Prefix-matched against the request path (without api/v1 prefix).
*/
private const SENSITIVE_ROUTE_PREFIXES = [
'auth/password',
private const BLOCKED_ROUTE_PREFIXES = [
'auth/logout',
'auth/refresh',
'auth/mfa',
'auth/trusted-devices',
'me/profile',
'me/change-password',
'me/change-email',
'admin/impersonate',
@@ -101,7 +100,12 @@ class HandleImpersonation
$path = $request->path();
$path = preg_replace('#^api/v1/#', '', $path);
foreach (self::SENSITIVE_ROUTE_PREFIXES as $prefix) {
// Block profile mutations but allow GET (viewing)
if (str_starts_with($path, 'me/profile') && $request->method() !== 'GET') {
return true;
}
foreach (self::BLOCKED_ROUTE_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return true;
}

View File

@@ -391,6 +391,7 @@ class AdminImpersonationTest extends TestCase
['POST', '/api/v1/auth/mfa/setup/totp'],
['GET', '/api/v1/auth/mfa/status'],
['GET', '/api/v1/auth/trusted-devices'],
['POST', '/api/v1/auth/refresh'],
];
foreach ($sensitiveRoutes as [$method, $url]) {
@@ -406,6 +407,23 @@ class AdminImpersonationTest extends TestCase
}
}
public function test_read_only_routes_allowed_during_impersonation(): void
{
Sanctum::actingAs($this->superAdmin);
$this->postJson(
"/api/v1/admin/impersonate/{$this->targetUser->id}",
$this->startPayload(),
)->assertOk();
// GET /auth/me should be allowed (profile viewing)
$response = $this->withHeader('X-Impersonate-User', $this->targetUser->id)
->getJson('/api/v1/auth/me');
$response->assertOk();
$response->assertJsonPath('data.id', $this->targetUser->id);
}
public function test_ip_change_terminates_session(): void
{
Sanctum::actingAs($this->superAdmin);

View File

@@ -65,54 +65,43 @@ onUnmounted(() => {
<VSystemBar
v-if="impersonationStore.isImpersonating"
color="warning"
class="impersonation-banner"
height="48"
style="z-index: 9999; position: fixed; top: 0; left: 0; right: 0;"
class="px-4"
>
<VIcon
icon="tabler-user-exclamation"
icon="tabler-user-search"
size="20"
class="me-2"
/>
<span>
<span class="text-body-2 font-weight-medium">
Je bekijkt het platform als
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
({{ impersonationStore.impersonatedUser?.email }})
</span>
<VSpacer />
<VChip
size="small"
variant="tonal"
color="warning"
class="me-3"
<span
v-if="remainingFormatted"
class="text-caption me-4"
>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
{{ remainingFormatted }}
</VChip>
</span>
<VBtn
variant="tonal"
color="warning"
size="small"
variant="elevated"
color="white"
class="text-warning"
:loading="isStopping"
prepend-icon="tabler-arrow-back"
@click="handleStop"
>
<VIcon
icon="tabler-arrow-back"
size="16"
class="me-1"
/>
Terug naar admin
</VBtn>
</VSystemBar>
</template>
<style scoped>
/* VSystemBar uses a fixed height by default; override to accommodate the button */
.impersonation-banner {
z-index: 9999;
block-size: auto;
min-block-size: 36px;
padding-block: 4px;
padding-inline: 16px;
}
</style>

View File

@@ -1,22 +1,36 @@
<script lang="ts" setup>
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
import { useAuthStore } from '@/stores/useAuthStore'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
const authStore = useAuthStore()
const impersonationStore = useImpersonationStore()
const hasOrganisation = computed(() => !!authStore.organisations.length)
const navItems = computed(() => {
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
const orgItems = orgNavItems.map(item => {
let orgItems = orgNavItems.map(item => {
if ('heading' in item && item.heading === 'Beheer') {
return { ...item, heading: orgName }
}
return item
})
if (authStore.isSuperAdmin) {
// During impersonation: hide org-dependent items if user has no organisation
if (impersonationStore.isImpersonating && !hasOrganisation.value) {
orgItems = orgItems.filter(item => {
if ('heading' in item) return false
return 'to' in item && item.to?.name === 'dashboard'
})
}
// Platform items: only for super_admin AND only when NOT impersonating
if (authStore.isSuperAdmin && !impersonationStore.isImpersonating) {
return [...orgItems, ...platformNavItems]
}
return orgItems
})
@@ -35,6 +49,7 @@ import { VerticalNavLayout } from '@layouts'
</script>
<template>
<div :style="{ paddingTop: impersonationStore.isImpersonating ? '48px' : '0' }">
<VerticalNavLayout :nav-items="navItems">
<!-- 👉 Organisation switcher -->
<template #before-vertical-nav-items>
@@ -82,4 +97,5 @@ import { VerticalNavLayout } from '@layouts'
<!-- 👉 Customizer -->
<!-- <TheCustomizer /> -->
</VerticalNavLayout>
</div>
</template>