Files
crewli/apps/app/src/stores/useImpersonationStore.ts
bert.hausmans b4f5bbe7c2 fix(app): resolve Bucket A/C/D lint items (trivial / style / Vuetify class)
WS-3 session 1b-ii Task 4 (audit Buckets A, C, D — 26 items resolved
this commit; 24 indent items in useTimeSlotDropdown.ts remain — see
deviations).

Bucket A — Trivial fixes (12 items resolved):
- A.1: second-pass eslint --fix on App.vue resolved 4 multi-attribute
  warnings. AppKpiCard / PortalLayout / PublicLayout
  lines-around-comment items were attempted via blank-line addition,
  but that introduced an equal number of vue/block-tag-newline
  errors (the rules conflict at the SFC <script>-tag boundary). The
  blank-line additions were reverted; net-zero, the 3 items remain
  for a 1b-iii .eslintrc.cjs override decision.
- A.3: 6 unused-imports / unused-vars manual deletes:
  * OrganisationSwitcher.vue: removed orphan toggleMenu() function
  * CreateShiftDialog.vue: removed unused 'scenario' from destructure
  * pages/events/[id]/time-slots/index.vue: removed unused 'event'
    slot scope binding (template <#default="{ event }"> → <#default>)
  * pages/organisation/companies.vue: removed unused authStore
    declaration + import
  * pages/platform/activity-log/index.vue: removed unused
    search/searchDebounced pair
  * PersonDetailPanel.vue:77: removed redundant single-statement
    if-braces (curly autofix that the original pass didn't reach)

Bucket C — Style preference (8 items resolved):
- DismissFailureDialog.vue:43: collapsed two consecutive `if cond return false`
  branches into `return !(cond)`
- FormFailureDetail.vue:44: replaced `void clipboard.writeText(...)` with
  `clipboard.writeText(...).catch(() => {})` — fire-and-forget with
  silent rejection (the no-void rule wants the void operator gone;
  .catch() handles it semantically).
- AssignShiftDialog.vue:40-46: hasOverlapWarning collapsed from
  always-false branching to `computed(() => false)` (the early-return
  was dead code; backend enforces the constraint).
- SectionsShiftsPanel.vue:333 + registration-fields.vue:335: rewrote
  `:delay-on-touch-only="true"` to attribute-shorthand `delay-on-touch-only`.
- AssignPersonDialog.vue:120-128: collapsed two `if outer { if inner ... }`
  pairs into single `if (outer && inner)` form (sonarjs/no-collapsible-if).
- useImpersonationStore.ts:99-104: collapsed the same nested-if pattern
  into `if (!data.data.active && state.value)`.

Bucket D — Vuetify utility class rename (5 items, 3 files):
- ml-1 → ms-1 (PersonDetailPanel:271, SectionsShiftsPanel:357,
  AssignPersonDialog:496)
- pl-4 → ps-4 (AssignPersonDialog:457)
- ml-auto → ms-auto (AssignPersonDialog:471)
LTR/RTL-aware Vuetify utilities, matching the Vuexy reference idiom.

Tests + typecheck verified green.

Lint baseline: 62 → 36.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:20:34 +02:00

175 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) {
// 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 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,
}
})