feat: replace token-based impersonation with enterprise-grade header-based system
Replaces the insecure token-in-localStorage approach with a header-based impersonation system backed by cache sessions and MFA verification. Key changes: - New impersonation_sessions audit table (immutable, ULID PK) - MFA verification required to start impersonation (TOTP/email/backup) - X-Impersonate-User header + HandleImpersonation middleware - Per-request auth context swap (admin session never modified) - IP pinning, sensitive route blocking, no nesting, sliding 60-min TTL - Activity log auto-tagged with impersonated_by during sessions - Frontend: sessionStorage, BroadcastChannel sync, countdown timer - ImpersonateDialog with reason + MFA verification flow - 26 comprehensive tests covering core, middleware, audit, lifecycle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -67,6 +67,7 @@ declare module 'vue' {
|
||||
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
|
||||
EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default']
|
||||
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
||||
ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default']
|
||||
ImpersonationBanner: typeof import('./src/components/platform/ImpersonationBanner.vue')['default']
|
||||
ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default']
|
||||
InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default']
|
||||
|
||||
@@ -5,6 +5,7 @@ import initCore from '@core/initCore'
|
||||
import { initConfigStore, useConfigStore } from '@core/stores/config'
|
||||
import { hexToRgb } from '@core/utils/colorConverter'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore'
|
||||
|
||||
const { global } = useTheme()
|
||||
@@ -14,8 +15,13 @@ initConfigStore()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// Restore impersonation state and listen for cross-tab sync
|
||||
impersonationStore.restoreFromStorage()
|
||||
impersonationStore.listenForBroadcasts()
|
||||
|
||||
// Validate stored token on app startup — must complete before rendering protected content
|
||||
authStore.initialize()
|
||||
</script>
|
||||
|
||||
183
apps/app/src/components/platform/ImpersonateDialog.vue
Normal file
183
apps/app/src/components/platform/ImpersonateDialog.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import type { AdminUser, StartImpersonationPayload } from '@/types/admin'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: AdminUser | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const reason = ref('')
|
||||
const mfaCode = ref('')
|
||||
const mfaMethod = ref<StartImpersonationPayload['mfa_method']>('totp')
|
||||
const errorMessage = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const isSendingEmailCode = ref(false)
|
||||
const emailCodeSent = ref(false)
|
||||
|
||||
const mfaMethodOptions = [
|
||||
{ title: 'Authenticator app', value: 'totp' as const },
|
||||
{ title: 'E-mailcode', value: 'email' as const },
|
||||
{ title: 'Backup code', value: 'backup_code' as const },
|
||||
]
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
reason.value.length >= 5 && mfaCode.value.length > 0,
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
reason.value = ''
|
||||
mfaCode.value = ''
|
||||
mfaMethod.value = 'totp'
|
||||
errorMessage.value = ''
|
||||
emailCodeSent.value = false
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
async function sendEmailCode() {
|
||||
isSendingEmailCode.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await apiClient.post('/admin/impersonate/send-mfa-code')
|
||||
emailCodeSent.value = true
|
||||
}
|
||||
catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
errorMessage.value = error.response?.data?.message ?? 'Kon e-mailcode niet versturen.'
|
||||
}
|
||||
finally {
|
||||
isSendingEmailCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.user || !isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await impersonationStore.start(props.user.id, {
|
||||
reason: reason.value,
|
||||
mfa_code: mfaCode.value,
|
||||
mfa_method: mfaMethod.value,
|
||||
})
|
||||
// start() triggers page reload — no further action needed
|
||||
}
|
||||
catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
errorMessage.value = error.response?.data?.message ?? 'Impersonation mislukt.'
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="isOpen"
|
||||
max-width="500"
|
||||
>
|
||||
<VCard title="Inloggen als gebruiker">
|
||||
<VCardText>
|
||||
<!-- Target user info -->
|
||||
<VAlert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
Je gaat het platform bekijken als
|
||||
<strong>{{ user?.full_name }}</strong>
|
||||
({{ user?.email }}).
|
||||
Verificatie met tweestapsverificatie is vereist.
|
||||
</VAlert>
|
||||
|
||||
<!-- Error -->
|
||||
<VAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<!-- Reason -->
|
||||
<AppTextField
|
||||
v-model="reason"
|
||||
label="Reden"
|
||||
placeholder="Waarom log je in als deze gebruiker?"
|
||||
:rules="[(v: string) => v.length >= 5 || 'Minimaal 5 tekens']"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- MFA method -->
|
||||
<AppSelect
|
||||
v-model="mfaMethod"
|
||||
:items="mfaMethodOptions"
|
||||
label="Verificatiemethode"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Send email code button -->
|
||||
<VBtn
|
||||
v-if="mfaMethod === 'email'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:loading="isSendingEmailCode"
|
||||
:disabled="emailCodeSent"
|
||||
class="mb-4"
|
||||
@click="sendEmailCode"
|
||||
>
|
||||
{{ emailCodeSent ? 'Code verstuurd' : 'Verstuur e-mailcode' }}
|
||||
</VBtn>
|
||||
|
||||
<!-- MFA code -->
|
||||
<AppTextField
|
||||
v-model="mfaCode"
|
||||
:label="mfaMethod === 'backup_code' ? 'Backup code' : 'Verificatiecode'"
|
||||
:placeholder="mfaMethod === 'backup_code' ? 'XXXX-XXXX' : '000000'"
|
||||
autocomplete="one-time-code"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!isFormValid"
|
||||
@click="submit"
|
||||
>
|
||||
Inloggen als gebruiker
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -4,46 +4,115 @@ import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const isStopping = ref(false)
|
||||
const remainingSeconds = ref(0)
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const remainingFormatted = computed(() => {
|
||||
const mins = Math.floor(remainingSeconds.value / 60)
|
||||
const secs = remainingSeconds.value % 60
|
||||
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
function updateCountdown() {
|
||||
if (!impersonationStore.expiresAt) {
|
||||
remainingSeconds.value = 0
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const diff = Math.max(0, Math.floor((impersonationStore.expiresAt.getTime() - Date.now()) / 1000))
|
||||
remainingSeconds.value = diff
|
||||
|
||||
if (diff <= 0) {
|
||||
handleExpired()
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpired() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
impersonationStore.clearState()
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
isStopping.value = true
|
||||
await impersonationStore.stopImpersonation()
|
||||
await impersonationStore.stop()
|
||||
}
|
||||
|
||||
watch(() => impersonationStore.isImpersonating, (active) => {
|
||||
if (active) {
|
||||
updateCountdown()
|
||||
timerInterval = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
else if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBanner
|
||||
<VSystemBar
|
||||
v-if="impersonationStore.isImpersonating"
|
||||
color="warning"
|
||||
sticky
|
||||
class="impersonation-banner"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-user-exclamation" />
|
||||
</template>
|
||||
|
||||
<VBannerText>
|
||||
<VIcon
|
||||
icon="tabler-user-exclamation"
|
||||
class="me-2"
|
||||
/>
|
||||
<span>
|
||||
Je bekijkt het platform als
|
||||
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
|
||||
({{ impersonationStore.impersonatedUser?.email }})
|
||||
</VBannerText>
|
||||
</span>
|
||||
|
||||
<template #actions>
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
:loading="isStopping"
|
||||
prepend-icon="tabler-arrow-back"
|
||||
@click="handleStop"
|
||||
>
|
||||
Terug naar admin
|
||||
</VBtn>
|
||||
</template>
|
||||
</VBanner>
|
||||
<VSpacer />
|
||||
|
||||
<VChip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
class="me-3"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-clock"
|
||||
size="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ remainingFormatted }}
|
||||
</VChip>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
size="small"
|
||||
:loading="isStopping"
|
||||
prepend-icon="tabler-arrow-back"
|
||||
@click="handleStop"
|
||||
>
|
||||
Terug naar admin
|
||||
</VBtn>
|
||||
</VSystemBar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* VSystemBar uses a fixed height by default; override to accommodate the button */
|
||||
.impersonation-banner {
|
||||
z-index: 1050;
|
||||
z-index: 9999;
|
||||
block-size: auto;
|
||||
min-block-size: 36px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
AdminOrganisationMember,
|
||||
AdminUser,
|
||||
CreateOrganisationPayload,
|
||||
ImpersonationResponse,
|
||||
InviteMemberPayload,
|
||||
PlatformStats,
|
||||
UpdateAdminOrganisationPayload,
|
||||
@@ -245,25 +244,4 @@ export function useAdminActivityLog(params: Ref<Record<string, string | number |
|
||||
}
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────
|
||||
|
||||
export function useStartImpersonation() {
|
||||
return useMutation({
|
||||
mutationFn: async (userId: string) => {
|
||||
const { data } = await apiClient.post<ApiResponse<ImpersonationResponse>>(
|
||||
`/admin/impersonate/${userId}`,
|
||||
)
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopImpersonation() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<ApiResponse<{ user: AdminUser }>>(
|
||||
'/admin/stop-impersonation',
|
||||
)
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
// Impersonation API calls are now handled directly by useImpersonationStore.
|
||||
|
||||
@@ -21,6 +21,21 @@ apiClient.interceptors.request.use(
|
||||
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
|
||||
}
|
||||
|
||||
// Add impersonation header when active
|
||||
// Lazy import to avoid circular dependency with store
|
||||
const impersonationData = sessionStorage.getItem('crewli_impersonation')
|
||||
if (impersonationData) {
|
||||
try {
|
||||
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
|
||||
if (parsed.targetUserId) {
|
||||
config.headers['X-Impersonate-User'] = parsed.targetUserId
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Invalid data — ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
|
||||
}
|
||||
@@ -46,6 +61,17 @@ apiClient.interceptors.response.use(
|
||||
const status = error.response?.status
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// Handle impersonation session expiry
|
||||
if (status === 403 && error.response?.data?.impersonation_ended) {
|
||||
import('@/stores/useImpersonationStore').then(({ useImpersonationStore }) => {
|
||||
const impersonationStore = useImpersonationStore()
|
||||
impersonationStore.clearState()
|
||||
window.location.href = '/platform'
|
||||
})
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
// Lazy import to avoid circular dependency
|
||||
import('@/stores/useAuthStore').then(({ useAuthStore }) => {
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import {
|
||||
useAdminUser,
|
||||
useUpdateAdminUser,
|
||||
useStartImpersonation,
|
||||
} from '@/composables/api/useAdmin'
|
||||
import { useAdminResetMfa } from '@/composables/api/useMfa'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
|
||||
import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue'
|
||||
import type { UpdateAdminUserPayload } from '@/types/admin'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
@@ -16,7 +15,6 @@ definePage({
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const userId = computed(() => String((route.params as { id: string }).id))
|
||||
|
||||
@@ -76,16 +74,6 @@ function submitEdit() {
|
||||
|
||||
// Impersonation
|
||||
const isImpersonateDialogOpen = ref(false)
|
||||
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
|
||||
|
||||
function confirmImpersonate() {
|
||||
startImpersonation(userId.value, {
|
||||
onSuccess: (result) => {
|
||||
isImpersonateDialogOpen.value = false
|
||||
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MFA Reset
|
||||
const isMfaResetDialogOpen = ref(false)
|
||||
@@ -416,33 +404,10 @@ function getInitials(name: string): string {
|
||||
</VDialog>
|
||||
|
||||
<!-- Impersonate Dialog -->
|
||||
<VDialog
|
||||
<ImpersonateDialog
|
||||
v-model="isImpersonateDialogOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Inloggen als gebruiker">
|
||||
<VCardText>
|
||||
Je gaat inloggen als <strong>{{ user?.full_name }}</strong>
|
||||
({{ user?.email }}). Wil je doorgaan?
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isImpersonateDialogOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
:loading="isImpersonating"
|
||||
@click="confirmImpersonate"
|
||||
>
|
||||
Doorgaan
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
:user="user ?? null"
|
||||
/>
|
||||
|
||||
<!-- MFA Reset Dialog -->
|
||||
<VDialog
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useAdminUsers,
|
||||
useStartImpersonation,
|
||||
useDeleteAdminUser,
|
||||
} from '@/composables/api/useAdmin'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
|
||||
definePage({
|
||||
@@ -14,7 +13,6 @@ definePage({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const search = ref('')
|
||||
const searchDebounced = refDebounced(search, 400)
|
||||
@@ -57,24 +55,12 @@ const headers = [
|
||||
// Impersonation
|
||||
const isImpersonateDialogOpen = ref(false)
|
||||
const userToImpersonate = ref<AdminUser | null>(null)
|
||||
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
|
||||
|
||||
function openImpersonateDialog(user: AdminUser) {
|
||||
userToImpersonate.value = user
|
||||
isImpersonateDialogOpen.value = true
|
||||
}
|
||||
|
||||
function confirmImpersonate() {
|
||||
if (!userToImpersonate.value) return
|
||||
|
||||
startImpersonation(userToImpersonate.value.id, {
|
||||
onSuccess: (result) => {
|
||||
isImpersonateDialogOpen.value = false
|
||||
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Delete
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
const userToDelete = ref<AdminUser | null>(null)
|
||||
@@ -296,33 +282,10 @@ function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
|
||||
</VCard>
|
||||
|
||||
<!-- Impersonate Dialog -->
|
||||
<VDialog
|
||||
<ImpersonateDialog
|
||||
v-model="isImpersonateDialogOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Inloggen als gebruiker">
|
||||
<VCardText>
|
||||
Je gaat inloggen als <strong>{{ userToImpersonate?.full_name }}</strong>
|
||||
({{ userToImpersonate?.email }}). Wil je doorgaan?
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isImpersonateDialogOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
:loading="isImpersonating"
|
||||
@click="confirmImpersonate"
|
||||
>
|
||||
Doorgaan
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
:user="userToImpersonate"
|
||||
/>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<VDialog
|
||||
|
||||
@@ -1,74 +1,176 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import type { AdminUser, ImpersonationSession, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin'
|
||||
|
||||
const IMPERSONATION_KEY = 'crewli_impersonation'
|
||||
const SESSION_STORAGE_KEY = 'crewli_impersonation'
|
||||
const BROADCAST_CHANNEL_NAME = 'crewli_impersonation_sync'
|
||||
|
||||
interface ImpersonationState {
|
||||
sessionId: string
|
||||
adminId: string
|
||||
originalToken: string
|
||||
targetUserId: string
|
||||
impersonatedUser: AdminUser
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export const useImpersonationStore = defineStore('impersonation', () => {
|
||||
const stored = localStorage.getItem(IMPERSONATION_KEY)
|
||||
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
||||
const state = ref<ImpersonationState | null>(stored ? JSON.parse(stored) : null)
|
||||
let broadcastChannel: BroadcastChannel | null = null
|
||||
|
||||
const isImpersonating = computed(() => !!state.value)
|
||||
const originalAdminId = computed(() => state.value?.adminId ?? null)
|
||||
const impersonatedUser = computed(() => state.value?.impersonatedUser ?? null)
|
||||
const sessionId = computed(() => state.value?.sessionId ?? null)
|
||||
const targetUserId = computed(() => state.value?.targetUserId ?? null)
|
||||
const expiresAt = computed(() => state.value?.expiresAt ? new Date(state.value.expiresAt) : null)
|
||||
|
||||
function startImpersonation(token: string, user: AdminUser, adminId: string) {
|
||||
// Store the current cookie token reference (we'll restore it on stop)
|
||||
// Since the app uses httpOnly cookies, we store the admin ID to know we're impersonating
|
||||
state.value = {
|
||||
adminId,
|
||||
originalToken: '', // httpOnly cookie — we can't read it, but we track the state
|
||||
impersonatedUser: user,
|
||||
function persistState(): void {
|
||||
if (state.value) {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value))
|
||||
}
|
||||
else {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
}
|
||||
localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state.value))
|
||||
|
||||
// The impersonation token from the API is a plain Sanctum token.
|
||||
// Set it as a Bearer token header for subsequent requests.
|
||||
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`
|
||||
|
||||
// Reload user state to reflect the impersonated user
|
||||
const authStore = useAuthStore()
|
||||
authStore.initialize()
|
||||
}
|
||||
|
||||
async function stopImpersonation() {
|
||||
async function start(
|
||||
userId: string,
|
||||
payload: StartImpersonationPayload,
|
||||
): Promise<ImpersonationStartResponse> {
|
||||
const { data } = await apiClient.post<{ data: ImpersonationStartResponse }>(
|
||||
`/admin/impersonate/${userId}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
const result = data.data
|
||||
const session = result.session
|
||||
|
||||
state.value = {
|
||||
sessionId: session.id,
|
||||
adminId: session.admin_id,
|
||||
targetUserId: session.target_user_id,
|
||||
impersonatedUser: result.user,
|
||||
expiresAt: session.expires_at,
|
||||
}
|
||||
|
||||
persistState()
|
||||
broadcastChange('started')
|
||||
|
||||
// Reload to apply impersonated context
|
||||
window.location.href = '/'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/admin/stop-impersonation')
|
||||
// Call stop WITHOUT the X-Impersonate-User header
|
||||
// The interceptor won't add it because we clear state first
|
||||
const currentState = state.value
|
||||
state.value = null
|
||||
persistState()
|
||||
|
||||
if (currentState) {
|
||||
await apiClient.post('/admin/stop-impersonation')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Even if the API call fails, restore local state
|
||||
// Even if API call fails, state is already cleared
|
||||
}
|
||||
|
||||
// Remove the Bearer token so httpOnly cookie takes over again
|
||||
delete apiClient.defaults.headers.common.Authorization
|
||||
broadcastChange('stopped')
|
||||
|
||||
state.value = null
|
||||
localStorage.removeItem(IMPERSONATION_KEY)
|
||||
|
||||
// Full reload to restore admin session from httpOnly cookie
|
||||
// Full reload to restore admin session
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
|
||||
function clearWithoutReload() {
|
||||
function clearState(): void {
|
||||
state.value = null
|
||||
localStorage.removeItem(IMPERSONATION_KEY)
|
||||
delete apiClient.defaults.headers.common.Authorization
|
||||
persistState()
|
||||
}
|
||||
|
||||
async function checkStatus(): Promise<void> {
|
||||
try {
|
||||
const { data } = await apiClient.get<{ data: ImpersonationStatusResponse }>(
|
||||
'/admin/impersonate/status',
|
||||
)
|
||||
|
||||
if (!data.data.active) {
|
||||
if (state.value) {
|
||||
clearState()
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
}
|
||||
else if (data.data.session) {
|
||||
// Update expiry from server
|
||||
if (state.value) {
|
||||
state.value.expiresAt = data.data.session.expires_at
|
||||
persistState()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If status check fails, don't clear — might be a network issue
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFromStorage(): void {
|
||||
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
state.value = JSON.parse(stored)
|
||||
}
|
||||
catch {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
state.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listenForBroadcasts(): void {
|
||||
if (broadcastChannel) return
|
||||
|
||||
try {
|
||||
broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME)
|
||||
broadcastChannel.onmessage = (event: MessageEvent<{ type: string }>) => {
|
||||
if (event.data.type === 'stopped') {
|
||||
state.value = null
|
||||
persistState()
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
else if (event.data.type === 'started') {
|
||||
restoreFromStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// BroadcastChannel not supported — no cross-tab sync
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastChange(type: string): void {
|
||||
try {
|
||||
broadcastChannel?.postMessage({ type })
|
||||
}
|
||||
catch {
|
||||
// Ignore broadcast errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isImpersonating,
|
||||
originalAdminId,
|
||||
impersonatedUser,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
clearWithoutReload,
|
||||
sessionId,
|
||||
targetUserId,
|
||||
expiresAt,
|
||||
start,
|
||||
stop,
|
||||
clearState,
|
||||
checkStatus,
|
||||
restoreFromStorage,
|
||||
listenForBroadcasts,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -67,10 +67,34 @@ export interface ActivityLogEntry {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ImpersonationResponse {
|
||||
token: string
|
||||
user: AdminUser
|
||||
export interface ImpersonationSession {
|
||||
id: string
|
||||
admin_id: string
|
||||
target_user_id: string
|
||||
target_user?: AdminUser
|
||||
reason: string
|
||||
mfa_method: string
|
||||
started_at: string
|
||||
expires_at: string
|
||||
ended_at: string | null
|
||||
end_reason: string | null
|
||||
actions_count: number
|
||||
}
|
||||
|
||||
export interface ImpersonationStartResponse {
|
||||
session: ImpersonationSession
|
||||
user: AdminUser
|
||||
}
|
||||
|
||||
export interface ImpersonationStatusResponse {
|
||||
active: boolean
|
||||
session?: ImpersonationSession
|
||||
}
|
||||
|
||||
export interface StartImpersonationPayload {
|
||||
reason: string
|
||||
mfa_code: string
|
||||
mfa_method: 'totp' | 'email' | 'backup_code'
|
||||
}
|
||||
|
||||
export interface AdminOrganisationMember {
|
||||
|
||||
Reference in New Issue
Block a user