From d623dea1e17360112bb57904b3d2fde1e6b43d24 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sun, 6 Sep 2026 16:59:09 +0200 Subject: [PATCH] feat: add account keys --- src/App.tsx | 5 + src/pages/ProfilePage.tsx | 277 +++++++++++++++++- src/pages/dashboard/DashboardAudience.tsx | 173 ++++++++++- .../dashboard/plugin/PluginAnalyticsTab.tsx | 201 ++++++++++++- src/pages/dashboard/useAnalytics.ts | 42 ++- 5 files changed, 679 insertions(+), 19 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2d87830..5f96cae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ const DeveloperTermsPage = React.lazy(() => import('./pages/legal/DeveloperTerms const LegalNoticePage = React.lazy(() => import('./pages/legal/LegalNoticePage')); const AdminPanel = React.lazy(() => import('./pages/admin/AdminPanel')); + const PageLoader = () => (
Loading... @@ -184,6 +185,7 @@ const App = () => ( } /> + {/* Developer Studio layout (Developer Studio navbar replaces original navbar when in dev menu) */} }> } /> @@ -251,6 +253,7 @@ const DeveloperStudioNavbar = ({ user }: any) => { )}
+
{ {user && (user.role === 'admin' || user.role === 'moderator') && setIsMenuOpen(false)}>Staff}
+ {/* ── Center Search Bar with Suggestions ── */}
setIsMenuOpen(false)} /> @@ -428,6 +432,7 @@ const Footer = () => ( © 2026 PumpkinMC

+ · ; danger?: boolean }[] = [ { key: 'account', label: 'Account', icon: User }, { key: 'security', label: 'Security', icon: Shield }, + { key: 'api_keys', label: 'API Keys', icon: Key }, { key: 'developer', label: 'Developer Profile', icon: Code }, { key: 'notifications', label: 'Notifications', icon: Bell }, { key: 'library', label: 'Library', icon: BookOpen }, @@ -324,11 +325,89 @@ const ProfilePage = () => { const [verify2faError, setVerify2faError] = useState(''); const [verify2faLoading, setVerify2faLoading] = useState(false); + // API Keys / Personal Access Tokens + interface ApiKey { + id: number; + name: string; + keyPrefix: string; + scopes: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; + isRevoked: boolean; + } + + const [apiKeys, setApiKeys] = useState([]); + const [apiKeysLoading, setApiKeysLoading] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyExpiresIn, setNewKeyExpiresIn] = useState(90); + const [createdRawKey, setCreatedRawKey] = useState(null); + const [creatingKey, setCreatingKey] = useState(false); + const [revokingKeyId, setRevokingKeyId] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + + const fetchApiKeys = async () => { + setApiKeysLoading(true); + try { + const res = await api.get('/user/api-keys'); + setApiKeys(res.data); + } catch (err) { + console.error('Failed to load API keys:', err); + } finally { + setApiKeysLoading(false); + } + }; + + const handleCreateApiKey = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newKeyName.trim()) return; + setCreatingKey(true); + try { + const res = await api.post('/user/api-keys', { + name: newKeyName.trim(), + expiresInDays: newKeyExpiresIn > 0 ? newKeyExpiresIn : null, + scopes: ['plugins:download'], + }); + setCreatedRawKey(res.data.rawKey); + setNewKeyName(''); + setNewKeyExpiresIn(90); + await fetchApiKeys(); + showToast('API Key created successfully!'); + } catch (err: any) { + showToast(err.response?.data?.error || 'Failed to create API key', 'error'); + } finally { + setCreatingKey(false); + } + }; + + const handleRevokeApiKey = async (id: number) => { + if (!window.confirm('Are you sure you want to revoke this API key? External tools and servers using it will immediately lose access.')) return; + setRevokingKeyId(id); + try { + await api.delete(`/user/api-keys/${id}`); + setApiKeys(prev => prev.filter(k => k.id !== id)); + showToast('API Key revoked successfully.'); + } catch (err: any) { + showToast(err.response?.data?.error || 'Failed to revoke API key', 'error'); + } finally { + setRevokingKeyId(null); + } + }; + + const handleCopyRawKey = () => { + if (!createdRawKey) return; + navigator.clipboard.writeText(createdRawKey); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2500); + }; + useEffect(() => { if (activeTab === 'security') { fetchSessions(); fetchPasskeys(); fetchRecoveryStatus(); + } else if (activeTab === 'api_keys') { + fetchApiKeys(); } }, [activeTab]); @@ -1242,6 +1321,200 @@ const ProfilePage = () => {
)} + {/* ══ PERSONAL ACCESS TOKENS (API KEYS) ══════════════════════════ */} + {activeTab === 'api_keys' && ( +
+
+

+ API Keys & Automation Tokens +

+

+ Personal access tokens allow external tools, CI/CD pipelines, and Minecraft servers to securely call the Pumpkin Market REST API without exposing your account password. +

+
+ + {/* Newly Generated Raw Key Modal / Highlight Box */} + {createdRawKey && ( +
+
+ + Personal Access Token Created Successfully! +
+

+ Make sure to copy your token now. For your security, you will not be able to view this token again after leaving this page. +

+
+ + {createdRawKey} + + + +
+
+ )} + + {/* Generate New API Key Card */} + +
+
+ + setNewKeyName(e.target.value)} + maxLength={64} + required + /> + + + + + +
+ + +
+
+ + {/* Active API Keys List */} + + {apiKeysLoading ? ( +

Loading your API keys…

+ ) : apiKeys.length === 0 ? ( +
+ +

No API keys generated yet.

+

+ Generate your first token above to start automating plugin downloads with curl or scripts. +

+
+ ) : ( +
+ {apiKeys.map(k => ( +
+
+
+ + {k.name} + + + {k.keyPrefix} + +
+
+ Created: {new Date(k.createdAt).toLocaleDateString()} + Expires: {k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'} + Last used: {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never'} +
+
+ + +
+ ))} +
+ )} +
+ + {/* Usage Guide Box */} + +

+ Pass your token in the Authorization: Bearer header to authenticate directly with the REST API: +

+
+ + curl -H "Authorization: Bearer pmk_your_token_here" \
+   https://market.pumpkinmc.org/api/v1/rest/plugins/1/download -o my_plugin.wasm +
+
+
+
+ )} + {/* ══ DEVELOPER PROFILE ════════════════════════════════════════════ */} {activeTab === 'developer' && (
diff --git a/src/pages/dashboard/DashboardAudience.tsx b/src/pages/dashboard/DashboardAudience.tsx index 3915bf3..e0b37ef 100644 --- a/src/pages/dashboard/DashboardAudience.tsx +++ b/src/pages/dashboard/DashboardAudience.tsx @@ -1,16 +1,20 @@ import { useMemo, useState } from 'react'; import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps"; import { scaleLinear } from "d3-scale"; +import { Server, Users, Globe, Activity } from 'lucide-react'; import { useAnalytics } from './useAnalytics'; const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; const DashboardAudience = () => { - const { mapData, loading } = useAnalytics(); - const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors'); + const { mapData, telemetry, loading } = useAnalytics(); + const [mapFilter, setMapFilter] = useState<'servers' | 'players' | 'visitors' | 'free_downloads'>('servers'); + const [hoveredInfo, setHoveredInfo] = useState<{ name: string; servers: number; players: number; visitors: number } | null>(null); // Memoize scales to prevent recalculation on every render const colorScales = useMemo(() => ({ + servers: scaleLinear().domain([0, 20]).range(["#1a1a2e", "#10b981"]), + players: scaleLinear().domain([0, 100]).range(["#1a1a2e", "#06b6d4"]), visitors: scaleLinear().domain([0, 50]).range(["#1a1a2e", "#8884d8"]), free_downloads: scaleLinear().domain([0, 50]).range(["#1a1a2e", "#82ca9d"]), buyers: scaleLinear().domain([0, 50]).range(["#1a1a2e", "#FF7518"]), @@ -20,52 +24,152 @@ const DashboardAudience = () => { return (
-

Analyzing Global Distribution...

+

Analyzing Global Telemetry & Distribution...

); } return (
+ {/* ── Top Telemetry Audience KPIs ── */} +
+
+
+ + Live Game Servers (24h) +
+
+ {telemetry?.activeServers24h?.toLocaleString() ?? 0} +
+
+ {telemetry?.activeServers7d ?? 0} active past 7 days +
+
+ +
+
+ + Online Player Reach +
+
+ {telemetry?.totalPlayersOnline?.toLocaleString() ?? 0} +
+
+ Concurrent players on servers running your plugins +
+
+ +
+
+ + Peak 24h Players +
+
+ {telemetry?.peakPlayers24h?.toLocaleString() ?? 0} +
+
+ Max player concurrency +
+
+ +
+
+ + Server Locations +
+
+ {telemetry?.countries?.length ?? mapData.length} +
+
+ Countries with deployed servers +
+
+
+ + {/* ── Global Interactive Map ── */}
-
+
-

Geographic Distribution

-

Visualizing user activity across the globe

+

Plugin Audience & Server Geography

+

+ Visualizing active Minecraft servers and player reach reported via telemetry +

- {(['visitors', 'free_downloads', 'buyers'] as const).map(type => ( + {(['servers', 'players', 'visitors', 'free_downloads'] as const).map(type => ( ))}
-
+
+ {hoveredInfo && ( +
+
{hoveredInfo.name}
+
🖥️ {hoveredInfo.servers} active servers
+
👥 {hoveredInfo.players} online players
+
🌐 {hoveredInfo.visitors} web views
+
+ )} + {({ geographies }) => geographies.map((geo) => { - // Try different property keys used by various GeoJSON versions const countryCode = geo.properties.ISO_A2 || geo.properties.iso_a2 || geo.properties.IS_A2; - const d = mapData.find((s: any) => s.country === countryCode); + const d: any = mapData.find((s: any) => s.country === countryCode); + const val = d ? (d[mapFilter === 'free_downloads' ? 'freeDownloads' : mapFilter] || 0) : 0; return ( 0 ? colorScales[mapFilter](val) : "#1a1a2e"} stroke="#2a2a40" strokeWidth={0.5} + onMouseEnter={() => { + setHoveredInfo({ + name: d?.countryName || geo.properties.name || countryCode || 'Unknown', + servers: d?.servers || 0, + players: d?.players || 0, + visitors: d?.visitors || 0, + }); + }} + onMouseLeave={() => setHoveredInfo(null)} style={{ default: { outline: "none" }, - hover: { fill: "var(--primary)", outline: "none", cursor: 'pointer' }, + hover: { fill: "#f97316", outline: "none", cursor: 'pointer' }, pressed: { outline: "none" } }} /> @@ -77,10 +181,51 @@ const DashboardAudience = () => { {mapData.length === 0 && (
- No geographic data captured yet. (Note: Localhost/VPN IPs may appear as 'Unknown') + No server telemetry or geographic data captured yet.
)}
+ + {/* Table of Top Geographic Locations */} + {mapData.length > 0 && ( +
+

+ Country Telemetry Breakdown +

+ + + + + + + + + + + + {mapData.slice(0, 10).map((row, idx) => ( + + + + + + + + ))} + +
CountryGame ServersOnline PlayersWeb VisitorsDownloads
+ {row.countryName} ({row.country}) + + {row.servers} + + {row.players} + + {row.visitors} + + {row.freeDownloads} +
+
+ )}
diff --git a/src/pages/dashboard/plugin/PluginAnalyticsTab.tsx b/src/pages/dashboard/plugin/PluginAnalyticsTab.tsx index 28e27e6..0015d68 100644 --- a/src/pages/dashboard/plugin/PluginAnalyticsTab.tsx +++ b/src/pages/dashboard/plugin/PluginAnalyticsTab.tsx @@ -5,7 +5,8 @@ import { } from 'recharts'; import { TrendingUp, TrendingDown, Minus, DollarSign, - Download, MousePointer2, Star, Globe + Download, MousePointer2, Star, Globe, + Server, Users, Radio, Cpu, Layers, Activity } from 'lucide-react'; import { useAnalytics } from '../useAnalytics'; @@ -87,7 +88,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a }; export const PluginAnalyticsTab: React.FC = ({ pluginId, pluginName }) => { - const { processedData, totals, ratingSummary, referrers, timeframe, setTimeframe, loading } = useAnalytics(pluginId); + const { processedData, totals, ratingSummary, referrers, telemetry, timeframe, setTimeframe, loading } = useAnalytics(pluginId); const timeframes = ['Today', '7 Days', '1 Month', '1 Year', 'Lifetime']; if (loading) { @@ -164,6 +165,202 @@ export const PluginAnalyticsTab: React.FC = ({ pluginId, pluginName }) =>
+ {/* ── Live Game Server Telemetry (Pumpkin & Vine) ── */} +
+
+
+
+ +

+ Live Server Telemetry & Installations +

+ + + Real-Time Heartbeats + +
+

+ Direct metrics reported by Minecraft production servers running {pluginName} on Pumpkin & Vine. +

+
+
+ + {/* 4 Telemetry Metric Badges */} +
+
+
+ + Active Servers (24h) +
+
+ {telemetry?.activeServers24h?.toLocaleString() ?? 0} +
+
+ {telemetry?.activeServers7d ?? 0} weekly active servers (7d) +
+
+ +
+
+ + Total Player Reach +
+
+ {telemetry?.totalPlayersOnline?.toLocaleString() ?? 0} +
+
+ Concurrent players on active servers +
+
+ +
+
+ + Peak 24h Players +
+
+ {telemetry?.peakPlayers24h?.toLocaleString() ?? 0} +
+
+ Highest server player concurrency +
+
+ +
+
+ + Primary Platform +
+
+ {telemetry?.serverTypes?.[0]?.label || 'Pumpkin'} +
+
+ {telemetry?.serverTypes?.[0]?.percentage ? `${telemetry.serverTypes[0].percentage}% of instances` : 'Awaiting heartbeats'} +
+
+
+ + {/* Breakdown sub-grid: Installed Versions, Runtime Environment, Active Server Geography */} +
+ {/* Installed Version Adoption */} +
+
+ + Installed Version Adoption +
+ {telemetry?.versions && telemetry.versions.length > 0 ? ( +
+ {telemetry.versions.map((ver, idx) => ( +
+
+ + v{ver.label.replace(/^v/, '')} + + + {ver.count} {ver.count === 1 ? 'server' : 'servers'} ({ver.percentage}%) + +
+
+
+
+
+ ))} +
+ ) : ( +
+ Awaiting version heartbeats from active servers. +
+ )} +
+ + {/* Runtime Environment: Engine, Minecraft Version & OS */} +
+
+ + Server Runtime Environment +
+ + {/* Platform engines */} + {telemetry?.serverTypes && telemetry.serverTypes.length > 0 && ( +
+
+ Server Software +
+
+ {telemetry.serverTypes.map((st, i) => ( + + {st.label}: {st.count} ({st.percentage}%) + + ))} +
+
+ )} + + {/* Minecraft versions */} +
+
+ Minecraft Versions +
+ {telemetry?.minecraftVersions && telemetry.minecraftVersions.length > 0 ? ( +
+ {telemetry.minecraftVersions.slice(0, 4).map((mc, i) => ( + + MC {mc.label} ({mc.percentage}%) + + ))} +
+ ) : ( + MC 1.21.x + )} +
+ + {/* Operating System */} +
+
+ Server Host OS +
+ {telemetry?.operatingSystems && telemetry.operatingSystems.length > 0 ? ( +
+ {telemetry.operatingSystems.slice(0, 3).map((os, i) => ( + + {os.label} ({os.percentage}%) + + ))} +
+ ) : ( + Linux / Bare Metal + )} +
+
+ + {/* Active Server Geography */} +
+
+ + Active Server Locations +
+ {telemetry?.countries && telemetry.countries.length > 0 ? ( +
+ {telemetry.countries.slice(0, 6).map((c, i) => ( +
+ + {c.countryName} + + + {c.servers} {c.servers === 1 ? 'server' : 'servers'} • {c.players} players + +
+ ))} +
+ ) : ( +
+ Global server distribution will appear here as instances connect. +
+ )} +
+
+
+ {/* Grid: Charts & Rating Breakdown */}
diff --git a/src/pages/dashboard/useAnalytics.ts b/src/pages/dashboard/useAnalytics.ts index d615600..00df807 100644 --- a/src/pages/dashboard/useAnalytics.ts +++ b/src/pages/dashboard/useAnalytics.ts @@ -1,11 +1,48 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import api from '../../api'; +export interface TelemetryDistributionItem { + label: string; + count: number; + percentage: number; +} + +export interface TelemetryGeoItem { + country: string; + countryName: string; + servers: number; + players: number; + percentage: number; +} + +export interface PluginTelemetry { + activeServers24h: number; + activeServers7d: number; + totalPlayersOnline: number; + peakPlayers24h: number; + versions: TelemetryDistributionItem[]; + serverTypes: TelemetryDistributionItem[]; + minecraftVersions: TelemetryDistributionItem[]; + operatingSystems: TelemetryDistributionItem[]; + countries: TelemetryGeoItem[]; +} + +export interface DevMapItem { + country: string; + countryName: string; + servers: number; + players: number; + visitors: number; + freeDownloads: number; + buyers: number; +} + export const useAnalytics = (pluginId?: number) => { const [timeSeries, setTimeSeries] = useState([]); const [ratingSummary, setRatingSummary] = useState(null); const [referrers, setReferrers] = useState([]); - const [mapData, setMapData] = useState([]); + const [mapData, setMapData] = useState([]); + const [telemetry, setTelemetry] = useState(null); const [breakdowns, setBreakdowns] = useState([]); const [loading, setLoading] = useState(true); const [timeframe, setTimeframe] = useState('Lifetime'); @@ -23,6 +60,7 @@ export const useAnalytics = (pluginId?: number) => { setRatingSummary(res.data.ratingSummary || null); setReferrers(Array.isArray(res.data.referrers) ? res.data.referrers : []); setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []); + setTelemetry(res.data.telemetry || null); setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []); } catch (error) { console.error("Analytics Fetch Error:", error); @@ -30,6 +68,7 @@ export const useAnalytics = (pluginId?: number) => { setRatingSummary(null); setReferrers([]); setMapData([]); + setTelemetry(null); setBreakdowns([]); } finally { setLoading(false); @@ -88,6 +127,7 @@ export const useAnalytics = (pluginId?: number) => { ratingSummary, referrers, mapData, + telemetry, breakdowns, processedData, totals,