fix: resolve admin panel API path issues and ActionMenu overflow
- Fix duplicate /api/v1 paths in admin API calls - Fix ActionMenu dropdown being clipped by using React Portal - Fix TeamCategoriesPage API endpoints Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,91 +18,91 @@ import type {
|
|||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
// Dashboard
|
// Dashboard
|
||||||
getDashboardMetrics: () =>
|
getDashboardMetrics: () =>
|
||||||
api.get<DashboardMetrics>('/api/v1/admin/dashboard/metrics').then(r => r.data),
|
api.get<DashboardMetrics>('/admin/dashboard/metrics').then(r => r.data),
|
||||||
getDashboardActivity: () =>
|
getDashboardActivity: () =>
|
||||||
api.get<ActivityEntry[]>('/api/v1/admin/dashboard/activity').then(r => r.data),
|
api.get<ActivityEntry[]>('/admin/dashboard/activity').then(r => r.data),
|
||||||
|
|
||||||
// Users (existing endpoints)
|
// Users (existing endpoints)
|
||||||
listUsers: (params?: Record<string, unknown>) =>
|
listUsers: (params?: Record<string, unknown>) =>
|
||||||
api.get('/api/v1/admin/users', { params }).then(r => r.data),
|
api.get('/admin/users', { params }).then(r => r.data),
|
||||||
getUser: (id: string) =>
|
getUser: (id: string) =>
|
||||||
api.get(`/api/v1/admin/users/${id}`).then(r => r.data),
|
api.get(`/admin/users/${id}`).then(r => r.data),
|
||||||
updateUserRole: (id: string, role: string) =>
|
updateUserRole: (id: string, role: string) =>
|
||||||
api.put(`/api/v1/admin/users/${id}/role`, { role }).then(r => r.data),
|
api.put(`/admin/users/${id}/role`, { role }).then(r => r.data),
|
||||||
updateAccountRole: (id: string, account_role: string) =>
|
updateAccountRole: (id: string, account_role: string) =>
|
||||||
api.put(`/api/v1/admin/users/${id}/account-role`, { account_role }).then(r => r.data),
|
api.put(`/admin/users/${id}/account-role`, { account_role }).then(r => r.data),
|
||||||
deactivateUser: (id: string) =>
|
deactivateUser: (id: string) =>
|
||||||
api.put(`/api/v1/admin/users/${id}/deactivate`).then(r => r.data),
|
api.put(`/admin/users/${id}/deactivate`).then(r => r.data),
|
||||||
activateUser: (id: string) =>
|
activateUser: (id: string) =>
|
||||||
api.put(`/api/v1/admin/users/${id}/activate`).then(r => r.data),
|
api.put(`/admin/users/${id}/activate`).then(r => r.data),
|
||||||
moveUserAccount: (id: string, display_code: string) =>
|
moveUserAccount: (id: string, display_code: string) =>
|
||||||
api.put(`/api/v1/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
|
api.put(`/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
|
||||||
|
|
||||||
// Invite Codes (existing endpoints)
|
// Invite Codes (existing endpoints)
|
||||||
listInviteCodes: (params?: Record<string, unknown>) =>
|
listInviteCodes: (params?: Record<string, unknown>) =>
|
||||||
api.get('/api/v1/invite-codes', { params }).then(r => r.data),
|
api.get('/invite-codes', { params }).then(r => r.data),
|
||||||
createInviteCode: (data?: { expires_at?: string }) =>
|
createInviteCode: (data?: { expires_at?: string }) =>
|
||||||
api.post('/api/v1/invite-codes', data || {}).then(r => r.data),
|
api.post('/invite-codes', data || {}).then(r => r.data),
|
||||||
deleteInviteCode: (id: string) =>
|
deleteInviteCode: (id: string) =>
|
||||||
api.delete(`/api/v1/invite-codes/${id}`),
|
api.delete(`/invite-codes/${id}`),
|
||||||
|
|
||||||
// Audit Logs
|
// Audit Logs
|
||||||
listAuditLogs: (params?: Record<string, unknown>) =>
|
listAuditLogs: (params?: Record<string, unknown>) =>
|
||||||
api.get<AuditLogListResponse>('/api/v1/admin/audit-logs', { params }).then(r => r.data),
|
api.get<AuditLogListResponse>('/admin/audit-logs', { params }).then(r => r.data),
|
||||||
exportAuditLogs: (params?: Record<string, string>) =>
|
exportAuditLogs: (params?: Record<string, string>) =>
|
||||||
api.get('/api/v1/admin/audit-logs/export', { params, responseType: 'blob' }),
|
api.get('/admin/audit-logs/export', { params, responseType: 'blob' }),
|
||||||
|
|
||||||
// Plan Limits
|
// Plan Limits
|
||||||
listPlanLimits: () =>
|
listPlanLimits: () =>
|
||||||
api.get<PlanLimitConfig[]>('/api/v1/admin/plan-limits').then(r => r.data),
|
api.get<PlanLimitConfig[]>('/admin/plan-limits').then(r => r.data),
|
||||||
updatePlanLimits: (data: PlanLimitConfig) =>
|
updatePlanLimits: (data: PlanLimitConfig) =>
|
||||||
api.put<PlanLimitConfig>('/api/v1/admin/plan-limits', data).then(r => r.data),
|
api.put<PlanLimitConfig>('/admin/plan-limits', data).then(r => r.data),
|
||||||
|
|
||||||
// Account Overrides
|
// Account Overrides
|
||||||
listAccountOverrides: () =>
|
listAccountOverrides: () =>
|
||||||
api.get<AccountOverrideResponse[]>('/api/v1/admin/account-overrides').then(r => r.data),
|
api.get<AccountOverrideResponse[]>('/admin/account-overrides').then(r => r.data),
|
||||||
createAccountOverride: (data: AccountOverrideCreate) =>
|
createAccountOverride: (data: AccountOverrideCreate) =>
|
||||||
api.post<AccountOverrideResponse>('/api/v1/admin/account-overrides', data).then(r => r.data),
|
api.post<AccountOverrideResponse>('/admin/account-overrides', data).then(r => r.data),
|
||||||
updateAccountOverride: (id: string, data: Partial<AccountOverrideCreate>) =>
|
updateAccountOverride: (id: string, data: Partial<AccountOverrideCreate>) =>
|
||||||
api.put<AccountOverrideResponse>(`/api/v1/admin/account-overrides/${id}`, data).then(r => r.data),
|
api.put<AccountOverrideResponse>(`/admin/account-overrides/${id}`, data).then(r => r.data),
|
||||||
deleteAccountOverride: (id: string) =>
|
deleteAccountOverride: (id: string) =>
|
||||||
api.delete(`/api/v1/admin/account-overrides/${id}`),
|
api.delete(`/admin/account-overrides/${id}`),
|
||||||
|
|
||||||
// Feature Flags
|
// Feature Flags
|
||||||
listFeatureFlags: () =>
|
listFeatureFlags: () =>
|
||||||
api.get<FeatureFlagResponse[]>('/api/v1/admin/feature-flags').then(r => r.data),
|
api.get<FeatureFlagResponse[]>('/admin/feature-flags').then(r => r.data),
|
||||||
createFeatureFlag: (data: FeatureFlagCreate) =>
|
createFeatureFlag: (data: FeatureFlagCreate) =>
|
||||||
api.post<FeatureFlagResponse>('/api/v1/admin/feature-flags', data).then(r => r.data),
|
api.post<FeatureFlagResponse>('/admin/feature-flags', data).then(r => r.data),
|
||||||
updateFeatureFlag: (id: string, data: Partial<FeatureFlagCreate>) =>
|
updateFeatureFlag: (id: string, data: Partial<FeatureFlagCreate>) =>
|
||||||
api.put<FeatureFlagResponse>(`/api/v1/admin/feature-flags/${id}`, data).then(r => r.data),
|
api.put<FeatureFlagResponse>(`/admin/feature-flags/${id}`, data).then(r => r.data),
|
||||||
deleteFeatureFlag: (id: string) =>
|
deleteFeatureFlag: (id: string) =>
|
||||||
api.delete(`/api/v1/admin/feature-flags/${id}`),
|
api.delete(`/admin/feature-flags/${id}`),
|
||||||
updatePlanDefault: (data: PlanDefaultUpdate) =>
|
updatePlanDefault: (data: PlanDefaultUpdate) =>
|
||||||
api.put('/api/v1/admin/feature-flags/plan-defaults', data).then(r => r.data),
|
api.put('/admin/feature-flags/plan-defaults', data).then(r => r.data),
|
||||||
|
|
||||||
// Feature Flag Account Overrides
|
// Feature Flag Account Overrides
|
||||||
listFeatureFlagOverrides: () =>
|
listFeatureFlagOverrides: () =>
|
||||||
api.get<AccountFeatureOverrideResponse[]>('/api/v1/admin/feature-flags/account-overrides').then(r => r.data),
|
api.get<AccountFeatureOverrideResponse[]>('/admin/feature-flags/account-overrides').then(r => r.data),
|
||||||
createFeatureFlagOverride: (data: AccountFeatureOverrideCreate) =>
|
createFeatureFlagOverride: (data: AccountFeatureOverrideCreate) =>
|
||||||
api.post<AccountFeatureOverrideResponse>('/api/v1/admin/feature-flags/account-overrides', data).then(r => r.data),
|
api.post<AccountFeatureOverrideResponse>('/admin/feature-flags/account-overrides', data).then(r => r.data),
|
||||||
deleteFeatureFlagOverride: (id: string) =>
|
deleteFeatureFlagOverride: (id: string) =>
|
||||||
api.delete(`/api/v1/admin/feature-flags/account-overrides/${id}`),
|
api.delete(`/admin/feature-flags/account-overrides/${id}`),
|
||||||
|
|
||||||
// Platform Settings
|
// Platform Settings
|
||||||
listSettings: () =>
|
listSettings: () =>
|
||||||
api.get<{ settings: Record<string, unknown> }>('/api/v1/admin/settings').then(r => r.data),
|
api.get<{ settings: Record<string, unknown> }>('/admin/settings').then(r => r.data),
|
||||||
updateSettings: (settings: Record<string, unknown>) =>
|
updateSettings: (settings: Record<string, unknown>) =>
|
||||||
api.put<{ settings: Record<string, unknown> }>('/api/v1/admin/settings', { settings }).then(r => r.data),
|
api.put<{ settings: Record<string, unknown> }>('/admin/settings', { settings }).then(r => r.data),
|
||||||
|
|
||||||
// Global Categories
|
// Global Categories
|
||||||
listGlobalCategories: () =>
|
listGlobalCategories: () =>
|
||||||
api.get<AdminCategory[]>('/api/v1/admin/categories/global').then(r => r.data),
|
api.get<AdminCategory[]>('/admin/categories/global').then(r => r.data),
|
||||||
createGlobalCategory: (data: GlobalCategoryCreate) =>
|
createGlobalCategory: (data: GlobalCategoryCreate) =>
|
||||||
api.post<AdminCategory>('/api/v1/admin/categories/global', data).then(r => r.data),
|
api.post<AdminCategory>('/admin/categories/global', data).then(r => r.data),
|
||||||
updateGlobalCategory: (id: string, data: Partial<GlobalCategoryCreate>) =>
|
updateGlobalCategory: (id: string, data: Partial<GlobalCategoryCreate>) =>
|
||||||
api.put<AdminCategory>(`/api/v1/admin/categories/global/${id}`, data).then(r => r.data),
|
api.put<AdminCategory>(`/admin/categories/global/${id}`, data).then(r => r.data),
|
||||||
deleteGlobalCategory: (id: string) =>
|
deleteGlobalCategory: (id: string) =>
|
||||||
api.delete(`/api/v1/admin/categories/global/${id}`),
|
api.delete(`/admin/categories/global/${id}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default adminApi
|
export default adminApi
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, type ReactNode } from 'react'
|
import { useState, useRef, useEffect, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { MoreHorizontal } from 'lucide-react'
|
import { MoreHorizontal } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -16,12 +17,29 @@ interface ActionMenuProps {
|
|||||||
|
|
||||||
export function ActionMenu({ items }: ActionMenuProps) {
|
export function ActionMenu({ items }: ActionMenuProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [menuPosition, setMenuPosition] = useState({ top: 0, right: 0 })
|
||||||
|
|
||||||
|
// Calculate menu position when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect()
|
||||||
|
setMenuPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 4, // 4px gap (mt-1)
|
||||||
|
right: window.innerWidth - rect.right + window.scrollX,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
if (
|
||||||
|
buttonRef.current && !buttonRef.current.contains(e.target as Node) &&
|
||||||
|
menuRef.current && !menuRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,8 +48,9 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
||||||
@@ -40,11 +59,18 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
|||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && createPortal(
|
||||||
<div className={cn(
|
<div
|
||||||
'absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-md border border-border',
|
ref={menuRef}
|
||||||
'bg-card py-1 shadow-lg animate-scale-in'
|
className={cn(
|
||||||
)}>
|
'fixed z-50 min-w-[160px] rounded-md border border-border',
|
||||||
|
'bg-card py-1 shadow-lg animate-scale-in'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${menuPosition.top}px`,
|
||||||
|
right: `${menuPosition.right}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -62,9 +88,10 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function TeamCategoriesPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/api/v1/categories')
|
const res = await api.get('/categories')
|
||||||
setCategories(res.data)
|
setCategories(res.data)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load categories')
|
toast.error('Failed to load categories')
|
||||||
@@ -39,7 +39,7 @@ export function TeamCategoriesPage() {
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/api/v1/categories', form)
|
await api.post('/categories', form)
|
||||||
toast.success('Category created')
|
toast.success('Category created')
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setForm({ name: '', slug: '', description: '' })
|
setForm({ name: '', slug: '', description: '' })
|
||||||
@@ -52,7 +52,7 @@ export function TeamCategoriesPage() {
|
|||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
if (!editCategory) return
|
if (!editCategory) return
|
||||||
try {
|
try {
|
||||||
await api.put(`/api/v1/categories/${editCategory.id}`, form)
|
await api.put(`/categories/${editCategory.id}`, form)
|
||||||
toast.success('Category updated')
|
toast.success('Category updated')
|
||||||
setEditCategory(null)
|
setEditCategory(null)
|
||||||
setForm({ name: '', slug: '', description: '' })
|
setForm({ name: '', slug: '', description: '' })
|
||||||
@@ -64,7 +64,7 @@ export function TeamCategoriesPage() {
|
|||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/v1/categories/${id}`)
|
await api.delete(`/categories/${id}`)
|
||||||
toast.success('Category deleted')
|
toast.success('Category deleted')
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user