Files
Momento/memento-note/app/(admin)/admin/user-list.tsx
Antigravity bb75b2e763
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
docs: add comprehensive Stripe billing guide
Covers architecture, configuration steps, user flows, API routes,
webhooks, pricing, testing with Stripe CLI, production checklist,
and troubleshooting.
2026-05-16 21:10:26 +00:00

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')}</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>
)
}