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:
@@ -14,15 +14,14 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
class HandleImpersonation
|
class HandleImpersonation
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Routes that are blocked during impersonation.
|
* Routes that are blocked during impersonation (all methods).
|
||||||
* These are prefix-matched against the request path (without api/v1 prefix).
|
* Prefix-matched against the request path (without api/v1 prefix).
|
||||||
*/
|
*/
|
||||||
private const SENSITIVE_ROUTE_PREFIXES = [
|
private const BLOCKED_ROUTE_PREFIXES = [
|
||||||
'auth/password',
|
|
||||||
'auth/logout',
|
'auth/logout',
|
||||||
|
'auth/refresh',
|
||||||
'auth/mfa',
|
'auth/mfa',
|
||||||
'auth/trusted-devices',
|
'auth/trusted-devices',
|
||||||
'me/profile',
|
|
||||||
'me/change-password',
|
'me/change-password',
|
||||||
'me/change-email',
|
'me/change-email',
|
||||||
'admin/impersonate',
|
'admin/impersonate',
|
||||||
@@ -101,7 +100,12 @@ class HandleImpersonation
|
|||||||
$path = $request->path();
|
$path = $request->path();
|
||||||
$path = preg_replace('#^api/v1/#', '', $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)) {
|
if (str_starts_with($path, $prefix)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ class AdminImpersonationTest extends TestCase
|
|||||||
['POST', '/api/v1/auth/mfa/setup/totp'],
|
['POST', '/api/v1/auth/mfa/setup/totp'],
|
||||||
['GET', '/api/v1/auth/mfa/status'],
|
['GET', '/api/v1/auth/mfa/status'],
|
||||||
['GET', '/api/v1/auth/trusted-devices'],
|
['GET', '/api/v1/auth/trusted-devices'],
|
||||||
|
['POST', '/api/v1/auth/refresh'],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($sensitiveRoutes as [$method, $url]) {
|
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
|
public function test_ip_change_terminates_session(): void
|
||||||
{
|
{
|
||||||
Sanctum::actingAs($this->superAdmin);
|
Sanctum::actingAs($this->superAdmin);
|
||||||
|
|||||||
@@ -65,54 +65,43 @@ onUnmounted(() => {
|
|||||||
<VSystemBar
|
<VSystemBar
|
||||||
v-if="impersonationStore.isImpersonating"
|
v-if="impersonationStore.isImpersonating"
|
||||||
color="warning"
|
color="warning"
|
||||||
class="impersonation-banner"
|
height="48"
|
||||||
|
style="z-index: 9999; position: fixed; top: 0; left: 0; right: 0;"
|
||||||
|
class="px-4"
|
||||||
>
|
>
|
||||||
<VIcon
|
<VIcon
|
||||||
icon="tabler-user-exclamation"
|
icon="tabler-user-search"
|
||||||
|
size="20"
|
||||||
class="me-2"
|
class="me-2"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span class="text-body-2 font-weight-medium">
|
||||||
Je bekijkt het platform als
|
Je bekijkt het platform als
|
||||||
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
|
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
|
||||||
({{ impersonationStore.impersonatedUser?.email }})
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<VChip
|
<span
|
||||||
size="small"
|
v-if="remainingFormatted"
|
||||||
variant="tonal"
|
class="text-caption me-4"
|
||||||
color="warning"
|
|
||||||
class="me-3"
|
|
||||||
>
|
>
|
||||||
<VIcon
|
|
||||||
icon="tabler-clock"
|
|
||||||
size="14"
|
|
||||||
class="me-1"
|
|
||||||
/>
|
|
||||||
{{ remainingFormatted }}
|
{{ remainingFormatted }}
|
||||||
</VChip>
|
</span>
|
||||||
|
|
||||||
<VBtn
|
<VBtn
|
||||||
variant="tonal"
|
|
||||||
color="warning"
|
|
||||||
size="small"
|
size="small"
|
||||||
|
variant="elevated"
|
||||||
|
color="white"
|
||||||
|
class="text-warning"
|
||||||
:loading="isStopping"
|
:loading="isStopping"
|
||||||
prepend-icon="tabler-arrow-back"
|
|
||||||
@click="handleStop"
|
@click="handleStop"
|
||||||
>
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-arrow-back"
|
||||||
|
size="16"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
Terug naar admin
|
Terug naar admin
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VSystemBar>
|
</VSystemBar>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
|
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const impersonationStore = useImpersonationStore()
|
||||||
|
|
||||||
|
const hasOrganisation = computed(() => !!authStore.organisations.length)
|
||||||
|
|
||||||
const navItems = computed(() => {
|
const navItems = computed(() => {
|
||||||
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
|
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
|
||||||
|
|
||||||
const orgItems = orgNavItems.map(item => {
|
let orgItems = orgNavItems.map(item => {
|
||||||
if ('heading' in item && item.heading === 'Beheer') {
|
if ('heading' in item && item.heading === 'Beheer') {
|
||||||
return { ...item, heading: orgName }
|
return { ...item, heading: orgName }
|
||||||
}
|
}
|
||||||
return item
|
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, ...platformNavItems]
|
||||||
}
|
}
|
||||||
|
|
||||||
return orgItems
|
return orgItems
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -35,6 +49,7 @@ import { VerticalNavLayout } from '@layouts'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div :style="{ paddingTop: impersonationStore.isImpersonating ? '48px' : '0' }">
|
||||||
<VerticalNavLayout :nav-items="navItems">
|
<VerticalNavLayout :nav-items="navItems">
|
||||||
<!-- 👉 Organisation switcher -->
|
<!-- 👉 Organisation switcher -->
|
||||||
<template #before-vertical-nav-items>
|
<template #before-vertical-nav-items>
|
||||||
@@ -82,4 +97,5 @@ import { VerticalNavLayout } from '@layouts'
|
|||||||
<!-- 👉 Customizer -->
|
<!-- 👉 Customizer -->
|
||||||
<!-- <TheCustomizer /> -->
|
<!-- <TheCustomizer /> -->
|
||||||
</VerticalNavLayout>
|
</VerticalNavLayout>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user