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:
2026-04-14 23:49:36 +02:00
parent 07ba791405
commit 9e7f28420c
15 changed files with 2262 additions and 2 deletions

View 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,
}
})