diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 75cbc381..3642cb50 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -64,6 +64,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'] + 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'] InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default'] diff --git a/apps/app/src/components/platform/ImpersonationBanner.vue b/apps/app/src/components/platform/ImpersonationBanner.vue new file mode 100644 index 00000000..05964a5c --- /dev/null +++ b/apps/app/src/components/platform/ImpersonationBanner.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/apps/app/src/composables/api/useAdmin.ts b/apps/app/src/composables/api/useAdmin.ts new file mode 100644 index 00000000..4a5ba5c3 --- /dev/null +++ b/apps/app/src/composables/api/useAdmin.ts @@ -0,0 +1,198 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { + ActivityLogEntry, + AdminOrganisation, + AdminUser, + ImpersonationResponse, + PlatformStats, + UpdateAdminOrganisationPayload, + UpdateAdminUserPayload, +} from '@/types/admin' + +interface ApiResponse { + success: boolean + data: T + message?: string +} + +interface PaginatedResponse { + data: T[] + links: Record + meta: { + current_page: number + per_page: number + total: number + last_page: number + } +} + +// ─── Organisations ────────────────────────────────────────── + +export function useAdminOrganisations(params: Ref>) { + return useQuery({ + queryKey: ['admin', 'organisations', params], + queryFn: async () => { + const { data } = await apiClient.get>( + '/admin/organisations', + { params: params.value }, + ) + return data + }, + }) +} + +export function useAdminOrganisation(id: Ref) { + return useQuery({ + queryKey: ['admin', 'organisations', id], + queryFn: async () => { + const { data } = await apiClient.get>( + `/admin/organisations/${id.value}`, + ) + return data.data + }, + enabled: () => !!id.value, + }) +} + +export function useUpdateAdminOrganisation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: UpdateAdminOrganisationPayload }) => { + const { data } = await apiClient.put>( + `/admin/organisations/${id}`, + payload, + ) + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'organisations'] }) + }, + }) +} + +export function useDeleteAdminOrganisation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/admin/organisations/${id}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'organisations'] }) + }, + }) +} + +// ─── Users ────────────────────────────────────────────────── + +export function useAdminUsers(params: Ref>) { + return useQuery({ + queryKey: ['admin', 'users', params], + queryFn: async () => { + const { data } = await apiClient.get>( + '/admin/users', + { params: params.value }, + ) + return data + }, + }) +} + +export function useAdminUser(id: Ref) { + return useQuery({ + queryKey: ['admin', 'users', id], + queryFn: async () => { + const { data } = await apiClient.get>( + `/admin/users/${id.value}`, + ) + return data.data + }, + enabled: () => !!id.value, + }) +} + +export function useUpdateAdminUser() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: UpdateAdminUserPayload }) => { + const { data } = await apiClient.put>( + `/admin/users/${id}`, + payload, + ) + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useDeleteAdminUser() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/admin/users/${id}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +// ─── Stats ────────────────────────────────────────────────── + +export function usePlatformStats() { + return useQuery({ + queryKey: ['admin', 'stats'], + queryFn: async () => { + const { data } = await apiClient.get<{ data: PlatformStats }>( + '/admin/stats', + ) + return data.data + }, + }) +} + +// ─── Activity Log ─────────────────────────────────────────── + +export function useAdminActivityLog(params: Ref>) { + return useQuery({ + queryKey: ['admin', 'activity-log', params], + queryFn: async () => { + const { data } = await apiClient.get>( + '/admin/activity-log', + { params: params.value }, + ) + return data + }, + }) +} + +// ─── Impersonation ────────────────────────────────────────── + +export function useStartImpersonation() { + return useMutation({ + mutationFn: async (userId: string) => { + const { data } = await apiClient.post>( + `/admin/impersonate/${userId}`, + ) + return data.data + }, + }) +} + +export function useStopImpersonation() { + return useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post>( + '/admin/stop-impersonation', + ) + return data.data + }, + }) +} diff --git a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue index a9a6588e..abf61d97 100644 --- a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue +++ b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue @@ -1,7 +1,18 @@ + + diff --git a/apps/app/src/pages/platform/index.vue b/apps/app/src/pages/platform/index.vue new file mode 100644 index 00000000..7dbbd26a --- /dev/null +++ b/apps/app/src/pages/platform/index.vue @@ -0,0 +1,239 @@ + + + diff --git a/apps/app/src/pages/platform/organisations/[id].vue b/apps/app/src/pages/platform/organisations/[id].vue new file mode 100644 index 00000000..ff32251e --- /dev/null +++ b/apps/app/src/pages/platform/organisations/[id].vue @@ -0,0 +1,384 @@ + + + diff --git a/apps/app/src/pages/platform/organisations/index.vue b/apps/app/src/pages/platform/organisations/index.vue new file mode 100644 index 00000000..2f54df23 --- /dev/null +++ b/apps/app/src/pages/platform/organisations/index.vue @@ -0,0 +1,178 @@ + + + diff --git a/apps/app/src/pages/platform/users/[id].vue b/apps/app/src/pages/platform/users/[id].vue new file mode 100644 index 00000000..0c6ca8ba --- /dev/null +++ b/apps/app/src/pages/platform/users/[id].vue @@ -0,0 +1,404 @@ + + + diff --git a/apps/app/src/pages/platform/users/index.vue b/apps/app/src/pages/platform/users/index.vue new file mode 100644 index 00000000..043eed31 --- /dev/null +++ b/apps/app/src/pages/platform/users/index.vue @@ -0,0 +1,364 @@ + + + diff --git a/apps/app/src/plugins/1.router/guards.ts b/apps/app/src/plugins/1.router/guards.ts index 33cb04f8..f00e7ea5 100644 --- a/apps/app/src/plugins/1.router/guards.ts +++ b/apps/app/src/plugins/1.router/guards.ts @@ -44,6 +44,17 @@ export function setupGuards(router: Router) { return { path: '/login', query: { to: to.fullPath } } } + // Platform admin routes — require super_admin role + if (to.path.startsWith('/platform')) { + if (!authStore.isSuperAdmin) { + if (import.meta.env.DEV) console.log('🚫 Not a super admin, redirecting to dashboard') + return { name: 'dashboard' } + } + // Platform routes don't require organisation selection + if (import.meta.env.DEV) console.log('✅ Super admin access to platform route') + return + } + // Authenticated — check organisation selection for routes that need it const orgStore = useOrganisationStore() const isSelectOrgPage = to.path === '/select-organisation' diff --git a/apps/app/src/stores/useImpersonationStore.ts b/apps/app/src/stores/useImpersonationStore.ts new file mode 100644 index 00000000..d8659ae1 --- /dev/null +++ b/apps/app/src/stores/useImpersonationStore.ts @@ -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(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, + } +}) diff --git a/apps/app/src/types/admin.ts b/apps/app/src/types/admin.ts new file mode 100644 index 00000000..d83ac15a --- /dev/null +++ b/apps/app/src/types/admin.ts @@ -0,0 +1,89 @@ +export type BillingStatus = 'trial' | 'active' | 'suspended' | 'cancelled' + +export interface AdminOrganisation { + id: string + name: string + slug: string + billing_status: BillingStatus + billing_status_label: string + settings: Record | null + events_count: number + users_count: number + total_persons: number + created_at: string + updated_at: string + deleted_at: string | null +} + +export interface AdminUser { + id: string + first_name: string + last_name: string + full_name: string + email: string + avatar: string | null + timezone: string + locale: string + email_verified_at: string | null + created_at: string + is_super_admin: boolean + roles: string[] + organisations: Array<{ + id: string + name: string + slug: string + role: string + }> +} + +export interface PlatformStats { + organisations: { + total: number + by_billing_status: Record + } + events: { + total: number + by_status: Record + } + users: { + total: number + verified: number + } + persons: { + total: number + } +} + +export interface ActivityLogEntry { + id: number + log_name: string | null + description: string + event: string | null + causer: { id: string; name: string; email: string } | null + subject_type: string | null + subject_id: string | null + properties: Record + created_at: string +} + +export interface ImpersonationResponse { + token: string + user: AdminUser + admin_id: string +} + +export interface UpdateAdminOrganisationPayload { + name?: string + slug?: string + billing_status?: BillingStatus + settings?: Record | null +} + +export interface UpdateAdminUserPayload { + first_name?: string + last_name?: string + email?: string + timezone?: string + locale?: string + roles?: string[] | null +} diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts index 1b9ac509..166ffce4 100644 --- a/apps/app/typed-router.d.ts +++ b/apps/app/typed-router.d.ts @@ -40,6 +40,12 @@ declare module 'vue-router/auto-routes' { 'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record, Record>, 'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record, Record>, 'organisation-settings': RouteRecordInfo<'organisation-settings', '/organisation/settings', Record, Record>, + 'platform': RouteRecordInfo<'platform', '/platform', Record, Record>, + 'platform-activity-log': RouteRecordInfo<'platform-activity-log', '/platform/activity-log', Record, Record>, + 'platform-organisations': RouteRecordInfo<'platform-organisations', '/platform/organisations', Record, Record>, + 'platform-organisations-id': RouteRecordInfo<'platform-organisations-id', '/platform/organisations/:id', { id: ParamValue }, { id: ParamValue }>, + 'platform-users': RouteRecordInfo<'platform-users', '/platform/users', Record, Record>, + 'platform-users-id': RouteRecordInfo<'platform-users-id', '/platform/users/:id', { id: ParamValue }, { id: ParamValue }>, 'reset-password': RouteRecordInfo<'reset-password', '/reset-password', Record, Record>, 'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record, Record>, 'verify-email-change': RouteRecordInfo<'verify-email-change', '/verify-email-change', Record, Record>,