|
| 1 | +import { useState, useEffect, useCallback } from 'react'; |
| 2 | +import { |
| 3 | + Activity, Search, Filter, Loader2, RefreshCw, ChevronDown, |
| 4 | + LogIn, CreditCard, Users, Key, Settings, FileText, Link2, Shield, |
| 5 | +} from 'lucide-react'; |
| 6 | +import { cn } from '../../utils/cn'; |
| 7 | +import { supabase } from '../../config/supabase'; |
| 8 | +import { useLanguage } from '../../contexts/LanguageContext'; |
| 9 | + |
| 10 | +interface ActivityLogViewProps { |
| 11 | + isDark: boolean; |
| 12 | +} |
| 13 | + |
| 14 | +interface ActivityEntry { |
| 15 | + id: string; |
| 16 | + event_type: string; |
| 17 | + action: string; |
| 18 | + description: string; |
| 19 | + metadata: Record<string, unknown>; |
| 20 | + ip_address: string | null; |
| 21 | + user_agent: string | null; |
| 22 | + created_at: string; |
| 23 | +} |
| 24 | + |
| 25 | +const EVENT_TYPE_CONFIG: Record<string, { icon: React.ReactNode; color: string; label: string }> = { |
| 26 | + auth: { icon: <LogIn className="w-3.5 h-3.5" />, color: 'text-blue-400 bg-blue-500/10 border-blue-500/20', label: 'Auth' }, |
| 27 | + billing: { icon: <CreditCard className="w-3.5 h-3.5" />, color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', label: 'Billing' }, |
| 28 | + team: { icon: <Users className="w-3.5 h-3.5" />, color: 'text-purple-400 bg-purple-500/10 border-purple-500/20', label: 'Team' }, |
| 29 | + api_keys: { icon: <Key className="w-3.5 h-3.5" />, color: 'text-orange-400 bg-orange-500/10 border-orange-500/20', label: 'API Keys' }, |
| 30 | + config: { icon: <Settings className="w-3.5 h-3.5" />, color: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20', label: 'Config' }, |
| 31 | + documents: { icon: <FileText className="w-3.5 h-3.5" />, color: 'text-pink-400 bg-pink-500/10 border-pink-500/20', label: 'Documents' }, |
| 32 | + integrations: { icon: <Link2 className="w-3.5 h-3.5" />, color: 'text-indigo-400 bg-indigo-500/10 border-indigo-500/20', label: 'Integrations' }, |
| 33 | + security: { icon: <Shield className="w-3.5 h-3.5" />, color: 'text-red-400 bg-red-500/10 border-red-500/20', label: 'Security' }, |
| 34 | + system: { icon: <Activity className="w-3.5 h-3.5" />, color: 'text-gray-400 bg-gray-500/10 border-gray-500/20', label: 'System' }, |
| 35 | +}; |
| 36 | + |
| 37 | +function timeAgo(dateStr: string): string { |
| 38 | + const now = new Date(); |
| 39 | + const date = new Date(dateStr); |
| 40 | + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); |
| 41 | + if (seconds < 60) return 'just now'; |
| 42 | + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; |
| 43 | + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; |
| 44 | + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; |
| 45 | + return date.toLocaleDateString(); |
| 46 | +} |
| 47 | + |
| 48 | +export default function ActivityLogView({ isDark }: ActivityLogViewProps) { |
| 49 | + const { t } = useLanguage(); |
| 50 | + const [loading, setLoading] = useState(true); |
| 51 | + const [activities, setActivities] = useState<ActivityEntry[]>([]); |
| 52 | + const [totalCount, setTotalCount] = useState(0); |
| 53 | + const [search, setSearch] = useState(''); |
| 54 | + const [filterType, setFilterType] = useState(''); |
| 55 | + const [showFilter, setShowFilter] = useState(false); |
| 56 | + const [page, setPage] = useState(0); |
| 57 | + const pageSize = 30; |
| 58 | + |
| 59 | + const fetchActivities = useCallback(async () => { |
| 60 | + setLoading(true); |
| 61 | + try { |
| 62 | + const session = await supabase.auth.getSession(); |
| 63 | + const token = session.data.session?.access_token; |
| 64 | + if (!token) return; |
| 65 | + |
| 66 | + const params = new URLSearchParams({ |
| 67 | + limit: String(pageSize), |
| 68 | + offset: String(page * pageSize), |
| 69 | + }); |
| 70 | + if (filterType) params.set('type', filterType); |
| 71 | + if (search) params.set('search', search); |
| 72 | + |
| 73 | + const res = await fetch( |
| 74 | + `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/api-activity-log?${params}`, |
| 75 | + { headers: { Authorization: `Bearer ${token}` } } |
| 76 | + ); |
| 77 | + |
| 78 | + if (res.ok) { |
| 79 | + const data = await res.json(); |
| 80 | + setActivities(data.activities || []); |
| 81 | + setTotalCount(data.count || 0); |
| 82 | + } |
| 83 | + } catch (err) { |
| 84 | + console.error('[ActivityLogView] fetch error:', err); |
| 85 | + } finally { |
| 86 | + setLoading(false); |
| 87 | + } |
| 88 | + }, [page, filterType, search]); |
| 89 | + |
| 90 | + useEffect(() => { fetchActivities(); }, [fetchActivities]); |
| 91 | + |
| 92 | + // Group activities by date |
| 93 | + const grouped: Record<string, ActivityEntry[]> = {}; |
| 94 | + for (const a of activities) { |
| 95 | + const day = new Date(a.created_at).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); |
| 96 | + if (!grouped[day]) grouped[day] = []; |
| 97 | + grouped[day].push(a); |
| 98 | + } |
| 99 | + |
| 100 | + const totalPages = Math.ceil(totalCount / pageSize); |
| 101 | + |
| 102 | + return ( |
| 103 | + <div className="space-y-6"> |
| 104 | + {/* Header */} |
| 105 | + <div className="flex items-center justify-between flex-wrap gap-4"> |
| 106 | + <div> |
| 107 | + <h2 className={cn("text-2xl font-bold tracking-tight", isDark ? "text-white" : "text-gray-900")}> |
| 108 | + {t('activityLog.title') || 'Activity Log'} |
| 109 | + </h2> |
| 110 | + <p className={cn("text-sm mt-1", isDark ? "text-white/50" : "text-gray-500")}> |
| 111 | + {t('activityLog.subtitle') || 'Track all account actions and events'} |
| 112 | + </p> |
| 113 | + </div> |
| 114 | + <button |
| 115 | + onClick={fetchActivities} |
| 116 | + className={cn("p-2 rounded-lg border transition-colors", isDark ? "border-white/10 hover:bg-white/5 text-white/60" : "border-gray-200 hover:bg-gray-50 text-gray-500")} |
| 117 | + > |
| 118 | + <RefreshCw className="w-4 h-4" /> |
| 119 | + </button> |
| 120 | + </div> |
| 121 | + |
| 122 | + {/* Search & Filter */} |
| 123 | + <div className="flex items-center gap-3 flex-wrap"> |
| 124 | + <div className={cn("flex items-center gap-2 flex-1 min-w-[200px] px-3 py-2 rounded-lg border", isDark ? "bg-white/5 border-white/10" : "bg-white border-gray-200")}> |
| 125 | + <Search className={cn("w-4 h-4 shrink-0", isDark ? "text-white/40" : "text-gray-400")} /> |
| 126 | + <input |
| 127 | + type="text" |
| 128 | + value={search} |
| 129 | + onChange={(e) => { setSearch(e.target.value); setPage(0); }} |
| 130 | + placeholder="Search activities..." |
| 131 | + className={cn("flex-1 bg-transparent text-sm outline-none", isDark ? "text-white placeholder:text-white/30" : "text-gray-900 placeholder:text-gray-400")} |
| 132 | + /> |
| 133 | + </div> |
| 134 | + |
| 135 | + <div className="relative"> |
| 136 | + <button |
| 137 | + onClick={() => setShowFilter(!showFilter)} |
| 138 | + className={cn( |
| 139 | + "flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors", |
| 140 | + filterType |
| 141 | + ? isDark ? "border-purple-500/30 bg-purple-500/10 text-purple-400" : "border-purple-500/30 bg-purple-50 text-purple-600" |
| 142 | + : isDark ? "border-white/10 hover:bg-white/5 text-white/60" : "border-gray-200 hover:bg-gray-50 text-gray-500" |
| 143 | + )} |
| 144 | + > |
| 145 | + <Filter className="w-4 h-4" /> |
| 146 | + {filterType ? EVENT_TYPE_CONFIG[filterType]?.label || filterType : 'Filter'} |
| 147 | + <ChevronDown className="w-3 h-3" /> |
| 148 | + </button> |
| 149 | + |
| 150 | + {showFilter && ( |
| 151 | + <div className={cn( |
| 152 | + "absolute top-full mt-1 right-0 z-50 w-48 rounded-lg border shadow-lg overflow-hidden", |
| 153 | + isDark ? "bg-[#1a1a1a] border-white/10" : "bg-white border-gray-200" |
| 154 | + )}> |
| 155 | + <button |
| 156 | + onClick={() => { setFilterType(''); setShowFilter(false); setPage(0); }} |
| 157 | + className={cn("w-full text-left px-3 py-2 text-sm transition-colors", isDark ? "hover:bg-white/5 text-white/70" : "hover:bg-gray-50 text-gray-700")} |
| 158 | + > |
| 159 | + All Events |
| 160 | + </button> |
| 161 | + {Object.entries(EVENT_TYPE_CONFIG).map(([key, config]) => ( |
| 162 | + <button |
| 163 | + key={key} |
| 164 | + onClick={() => { setFilterType(key); setShowFilter(false); setPage(0); }} |
| 165 | + className={cn( |
| 166 | + "w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2", |
| 167 | + isDark ? "hover:bg-white/5 text-white/70" : "hover:bg-gray-50 text-gray-700", |
| 168 | + filterType === key && (isDark ? "bg-white/5" : "bg-gray-50") |
| 169 | + )} |
| 170 | + > |
| 171 | + {config.icon} |
| 172 | + {config.label} |
| 173 | + </button> |
| 174 | + ))} |
| 175 | + </div> |
| 176 | + )} |
| 177 | + </div> |
| 178 | + </div> |
| 179 | + |
| 180 | + {/* Activity List */} |
| 181 | + {loading ? ( |
| 182 | + <div className="flex items-center justify-center py-20"> |
| 183 | + <Loader2 className={cn("w-6 h-6 animate-spin", isDark ? "text-purple-400" : "text-purple-600")} /> |
| 184 | + </div> |
| 185 | + ) : activities.length === 0 ? ( |
| 186 | + <div className={cn("text-center py-20 rounded-2xl border", isDark ? "bg-[#09090B] border-white/10" : "bg-white border-black/10")}> |
| 187 | + <Activity className={cn("w-10 h-10 mx-auto mb-3", isDark ? "text-white/20" : "text-gray-300")} /> |
| 188 | + <p className={cn("text-sm font-medium", isDark ? "text-white/50" : "text-gray-500")}>No activity yet</p> |
| 189 | + <p className={cn("text-xs mt-1", isDark ? "text-white/30" : "text-gray-400")}>Account actions will appear here</p> |
| 190 | + </div> |
| 191 | + ) : ( |
| 192 | + <div className="space-y-6"> |
| 193 | + {Object.entries(grouped).map(([date, entries]) => ( |
| 194 | + <div key={date}> |
| 195 | + <h3 className={cn("text-xs font-semibold uppercase tracking-wider mb-3", isDark ? "text-white/30" : "text-gray-400")}>{date}</h3> |
| 196 | + <div className={cn("rounded-2xl border overflow-hidden divide-y", isDark ? "bg-[#09090B] border-white/10 divide-white/5" : "bg-white border-black/10 divide-gray-100")}> |
| 197 | + {entries.map((entry) => { |
| 198 | + const config = EVENT_TYPE_CONFIG[entry.event_type] || EVENT_TYPE_CONFIG.system; |
| 199 | + return ( |
| 200 | + <div key={entry.id} className={cn("px-4 py-3 flex items-start gap-3 transition-colors", isDark ? "hover:bg-white/[0.02]" : "hover:bg-gray-50/50")}> |
| 201 | + <div className={cn("p-1.5 rounded-lg border shrink-0 mt-0.5", config.color)}> |
| 202 | + {config.icon} |
| 203 | + </div> |
| 204 | + <div className="flex-1 min-w-0"> |
| 205 | + <div className="flex items-center gap-2 mb-0.5"> |
| 206 | + <span className={cn("text-sm font-medium", isDark ? "text-white" : "text-gray-900")}>{entry.action.replace(/_/g, ' ')}</span> |
| 207 | + <span className={cn("text-xs px-1.5 py-0.5 rounded-md", isDark ? "bg-white/5 text-white/40" : "bg-gray-100 text-gray-500")}>{config.label}</span> |
| 208 | + </div> |
| 209 | + {entry.description && ( |
| 210 | + <p className={cn("text-xs truncate", isDark ? "text-white/40" : "text-gray-500")}>{entry.description}</p> |
| 211 | + )} |
| 212 | + </div> |
| 213 | + <span className={cn("text-xs shrink-0", isDark ? "text-white/30" : "text-gray-400")}>{timeAgo(entry.created_at)}</span> |
| 214 | + </div> |
| 215 | + ); |
| 216 | + })} |
| 217 | + </div> |
| 218 | + </div> |
| 219 | + ))} |
| 220 | + |
| 221 | + {/* Pagination */} |
| 222 | + {totalPages > 1 && ( |
| 223 | + <div className="flex items-center justify-center gap-2 pt-4"> |
| 224 | + <button |
| 225 | + onClick={() => setPage(p => Math.max(0, p - 1))} |
| 226 | + disabled={page === 0} |
| 227 | + className={cn("px-3 py-1.5 rounded-lg text-sm border transition-colors", isDark ? "border-white/10 text-white/60 hover:bg-white/5 disabled:opacity-30" : "border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-30")} |
| 228 | + > |
| 229 | + Previous |
| 230 | + </button> |
| 231 | + <span className={cn("text-xs", isDark ? "text-white/40" : "text-gray-400")}> |
| 232 | + Page {page + 1} of {totalPages} |
| 233 | + </span> |
| 234 | + <button |
| 235 | + onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} |
| 236 | + disabled={page >= totalPages - 1} |
| 237 | + className={cn("px-3 py-1.5 rounded-lg text-sm border transition-colors", isDark ? "border-white/10 text-white/60 hover:bg-white/5 disabled:opacity-30" : "border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-30")} |
| 238 | + > |
| 239 | + Next |
| 240 | + </button> |
| 241 | + </div> |
| 242 | + )} |
| 243 | + </div> |
| 244 | + )} |
| 245 | + </div> |
| 246 | + ); |
| 247 | +} |
0 commit comments