feat: platform admin frontend — pages, composables, navigation, impersonation
Build the frontend for platform admin in apps/app/: - TypeScript types (admin.ts) and API composable (useAdmin.ts) with TanStack Query for all admin endpoints - ImpersonationStore (Pinia) + ImpersonationBanner component integrated in the main layout, with token-based session management - Platform navigation section (conditionally shown for super_admin users) - Route guard blocking /platform/* for non-super_admin users - 6 pages: dashboard with stats cards, organisations list/detail, users list/detail with impersonation, activity log with expandable rows - All pages implement loading/error/empty states per conventions - Vite build passes cleanly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
74
apps/app/src/stores/useImpersonationStore.ts
Normal file
74
apps/app/src/stores/useImpersonationStore.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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'
|
||||
|
||||
const IMPERSONATION_KEY = 'crewli_impersonation'
|
||||
|
||||
interface ImpersonationState {
|
||||
adminId: string
|
||||
originalToken: string
|
||||
impersonatedUser: AdminUser
|
||||
}
|
||||
|
||||
export const useImpersonationStore = defineStore('impersonation', () => {
|
||||
const stored = localStorage.getItem(IMPERSONATION_KEY)
|
||||
const state = ref<ImpersonationState | null>(stored ? JSON.parse(stored) : null)
|
||||
|
||||
const isImpersonating = computed(() => !!state.value)
|
||||
const originalAdminId = computed(() => state.value?.adminId ?? null)
|
||||
const impersonatedUser = computed(() => state.value?.impersonatedUser ?? 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,
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
await apiClient.post('/admin/stop-impersonation')
|
||||
}
|
||||
catch {
|
||||
// Even if the API call fails, restore local state
|
||||
}
|
||||
|
||||
// Remove the Bearer token so httpOnly cookie takes over again
|
||||
delete apiClient.defaults.headers.common.Authorization
|
||||
|
||||
state.value = null
|
||||
localStorage.removeItem(IMPERSONATION_KEY)
|
||||
|
||||
// Full reload to restore admin session from httpOnly cookie
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
|
||||
function clearWithoutReload() {
|
||||
state.value = null
|
||||
localStorage.removeItem(IMPERSONATION_KEY)
|
||||
delete apiClient.defaults.headers.common.Authorization
|
||||
}
|
||||
|
||||
return {
|
||||
isImpersonating,
|
||||
originalAdminId,
|
||||
impersonatedUser,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
clearWithoutReload,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user