All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
192 lines
8.1 KiB
TypeScript
192 lines
8.1 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Button } from '@/components/ui/button'
|
|
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
|
|
import { toast } from 'sonner'
|
|
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
|
|
import { format } from 'date-fns'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
const TIERS = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] as const
|
|
|
|
const tierColors: Record<string, string> = {
|
|
BASIC: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
PRO: 'bg-brand-accent/10 text-brand-accent',
|
|
BUSINESS: 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
ENTERPRISE: 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
|
}
|
|
|
|
function TierDropdown({ userId, tier, isOpen, onToggle, onClose, onChange }: {
|
|
userId: string
|
|
tier: string
|
|
isOpen: boolean
|
|
onToggle: () => void
|
|
onClose: () => void
|
|
onChange: (tier: string) => void
|
|
}) {
|
|
const btnRef = useRef<HTMLButtonElement>(null)
|
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
|
|
const updatePos = useCallback(() => {
|
|
if (btnRef.current) {
|
|
const r = btnRef.current.getBoundingClientRect()
|
|
setPos({ top: r.bottom + 4, left: r.left })
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
updatePos()
|
|
window.addEventListener('scroll', updatePos, true)
|
|
window.addEventListener('resize', updatePos)
|
|
}
|
|
return () => {
|
|
window.removeEventListener('scroll', updatePos, true)
|
|
window.removeEventListener('resize', updatePos)
|
|
}
|
|
}, [isOpen, updatePos])
|
|
|
|
return (
|
|
<td className="p-4 align-middle">
|
|
<button
|
|
ref={btnRef}
|
|
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors cursor-pointer hover:opacity-80 ${tierColors[tier] || tierColors.BASIC}`}
|
|
>
|
|
{tier === 'ENTERPRISE' && <Crown size={10} />}
|
|
{tier}
|
|
<ChevronDown size={10} />
|
|
</button>
|
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
|
<>
|
|
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
|
<div
|
|
className="fixed z-[9999] bg-white dark:bg-zinc-900 border border-border rounded-xl shadow-xl py-1 min-w-[140px]"
|
|
style={{ top: pos.top, left: pos.left }}
|
|
>
|
|
{TIERS.map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={(e) => { e.stopPropagation(); onChange(t) }}
|
|
className={`w-full text-left px-4 py-2 text-xs font-medium transition-colors hover:bg-muted flex items-center gap-2 ${tier === t ? 'text-brand-accent font-bold' : 'text-foreground'}`}
|
|
>
|
|
{tier === t && '✓'}
|
|
<span className={`inline-flex items-center gap-1 ${tier === t ? '' : 'ml-4'}`}>
|
|
{t === 'ENTERPRISE' && <Crown size={10} />}
|
|
{t}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
</td>
|
|
)
|
|
}
|
|
|
|
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|
const { t } = useLanguage()
|
|
const [users, setUsers] = useState(initialUsers)
|
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm(t('admin.users.confirmDelete'))) return
|
|
try {
|
|
await deleteUser(id)
|
|
toast.success(t('admin.users.deleteSuccess'))
|
|
setUsers(prev => prev.filter(u => u.id !== id))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.deleteFailed'))
|
|
}
|
|
}
|
|
|
|
const handleRoleToggle = async (user: any) => {
|
|
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
|
try {
|
|
await updateUserRole(user.id, newRole)
|
|
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
|
|
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, role: newRole } : u))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.roleUpdateFailed'))
|
|
}
|
|
}
|
|
|
|
const handleTierChange = async (userId: string, tier: string) => {
|
|
setOpenDropdown(null)
|
|
try {
|
|
await updateUserSubscription(userId, tier)
|
|
toast.success(t('admin.users.tierUpdateSuccess', { tier }))
|
|
setUsers(prev => prev.map(u => u.id === userId ? { ...u, subscription: { tier, status: 'ACTIVE' } } : u))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.tierUpdateFailed'))
|
|
}
|
|
}
|
|
|
|
const getUserTier = (user: any) => user.subscription?.tier || 'BASIC'
|
|
|
|
return (
|
|
<div className="w-full overflow-auto">
|
|
<table className="w-full caption-bottom text-sm text-left">
|
|
<thead className="[&_tr]:border-b">
|
|
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.subscription') || 'Abonnement'}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="[&_tr:last-child]:border-0">
|
|
{users.map((user) => {
|
|
const tier = getUserTier(user)
|
|
return (
|
|
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
|
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
|
|
<td className="p-4 align-middle">{user.email}</td>
|
|
<td className="p-4 align-middle">
|
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
|
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
|
|
</span>
|
|
</td>
|
|
<TierDropdown
|
|
userId={user.id}
|
|
tier={tier}
|
|
isOpen={openDropdown === user.id}
|
|
onToggle={() => setOpenDropdown(openDropdown === user.id ? null : user.id)}
|
|
onClose={() => setOpenDropdown(null)}
|
|
onChange={(t) => handleTierChange(user.id, t)}
|
|
/>
|
|
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
|
<td className="p-4 align-middle text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRoleToggle(user)}
|
|
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
|
|
>
|
|
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(user.id)}
|
|
className="text-red-600 hover:text-red-900"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|