WS-3 session 1b-iii follow-up — sonarjs/no-collapsible-if. useImpersonationStore.ts:103: collapsed nested 'if (state.value)' into the parent 'else if (data.data.session)' clause. Both legs are AND-conditions on the same path, so the merge is semantically identical. Brings the apps/app lint baseline to 0 problems. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
173 lines
4.6 KiB
TypeScript
173 lines
4.6 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
import { apiClient } from '@/lib/axios'
|
|
import type { AdminUser, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin'
|
|
|
|
const SESSION_STORAGE_KEY = 'crewli_impersonation'
|
|
const BROADCAST_CHANNEL_NAME = 'crewli_impersonation_sync'
|
|
|
|
interface ImpersonationState {
|
|
sessionId: string
|
|
adminId: string
|
|
targetUserId: string
|
|
impersonatedUser: AdminUser
|
|
expiresAt: string
|
|
}
|
|
|
|
export const useImpersonationStore = defineStore('impersonation', () => {
|
|
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
|
const state = ref<ImpersonationState | null>(stored ? (JSON.parse(stored) as ImpersonationState) : 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 persistState(): void {
|
|
if (state.value)
|
|
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value))
|
|
|
|
else
|
|
sessionStorage.removeItem(SESSION_STORAGE_KEY)
|
|
}
|
|
|
|
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 {
|
|
// 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 API call fails, state is already cleared
|
|
}
|
|
|
|
broadcastChange('stopped')
|
|
|
|
// Full reload to restore admin session
|
|
window.location.href = '/platform'
|
|
}
|
|
|
|
function clearState(): void {
|
|
state.value = null
|
|
persistState()
|
|
}
|
|
|
|
async function checkStatus(): Promise<void> {
|
|
try {
|
|
const { data } = await apiClient.get<{ data: ImpersonationStatusResponse }>(
|
|
'/admin/impersonate/status',
|
|
)
|
|
|
|
if (!data.data.active && state.value) {
|
|
clearState()
|
|
window.location.href = '/platform'
|
|
}
|
|
else if (data.data.session && state.value) {
|
|
// Update expiry from server
|
|
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 storedSnapshot = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
|
if (storedSnapshot) {
|
|
try {
|
|
state.value = JSON.parse(storedSnapshot) as ImpersonationState
|
|
}
|
|
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,
|
|
sessionId,
|
|
targetUserId,
|
|
expiresAt,
|
|
start,
|
|
stop,
|
|
clearState,
|
|
checkStatus,
|
|
restoreFromStorage,
|
|
listenForBroadcasts,
|
|
}
|
|
})
|