diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index c93f57e..e358869 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -12,7 +12,7 @@ import { BookOpen, AlertTriangle, LogOut, CheckCircle, AlertCircle, Eye, EyeOff, ChevronRight, Bell, Smartphone, Key, Trash2, Code, Sparkles, Building2, - ShieldCheck, Download, Sliders, Package + ShieldCheck, Download, Sliders, Package, Laptop, Tablet } from 'lucide-react'; interface LibraryEntry { @@ -246,6 +246,56 @@ const ProfilePage = () => { }); }; + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [revokingSessionId, setRevokingSessionId] = useState(null); + const [revokingAllSessions, setRevokingAllSessions] = useState(false); + + useEffect(() => { + if (activeTab === 'security') { + fetchSessions(); + } + }, [activeTab]); + + const fetchSessions = async () => { + setSessionsLoading(true); + try { + const res = await api.get('/user/sessions'); + setSessions(Array.isArray(res.data) ? res.data : []); + } catch { + setSessions([]); + } finally { + setSessionsLoading(false); + } + }; + + const handleRevokeSession = async (sessionId: string) => { + setRevokingSessionId(sessionId); + try { + await api.delete(`/user/sessions/${sessionId}`); + setSessions(prev => prev.filter(s => s.id !== sessionId)); + showToast('Session revoked successfully.'); + } catch (err: any) { + showToast(err.response?.data?.error || 'Failed to revoke session.', 'error'); + } finally { + setRevokingSessionId(null); + } + }; + + const handleRevokeOtherSessions = async () => { + if (!window.confirm('Are you sure you want to sign out of all other devices?')) return; + setRevokingAllSessions(true); + try { + await api.post('/user/sessions/revoke-others'); + setSessions(prev => prev.filter(s => s.is_current)); + showToast('Signed out of all other devices.'); + } catch (err: any) { + showToast(err.response?.data?.error || 'Failed to sign out of other sessions.', 'error'); + } finally { + setRevokingAllSessions(false); + } + }; + useEffect(() => { if (activeTab !== 'library') return; setLibraryLoading(true); @@ -676,6 +726,125 @@ const ProfilePage = () => { )} + + {/* Active Devices & Sessions */} + + {sessionsLoading ? ( +

Loading active devices…

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

No active sessions found.

+ ) : ( +
+
+ {sessions.map((s: any) => { + const isMobile = s.device_type === 'mobile'; + const isTablet = s.device_type === 'tablet'; + const DeviceIcon = isMobile ? Smartphone : isTablet ? Tablet : Laptop; + + return ( +
+
+
+ +
+
+
+ + {s.device_name || s.os || 'Web Device'} + + {s.is_current && ( + + Current Device + + )} +
+
+ {s.browser || 'Web Browser'} + {s.os && s.os !== s.device_name && ( + • {s.os} + )} + {s.country && ( + • {s.country} + )} + {s.ip_address && ( + • IP: {s.ip_address} + )} + + • {s.is_current ? 'Active now' : `Last active ${new Date(s.last_active_at).toLocaleDateString()} ${new Date(s.last_active_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`} + +
+
+
+ + {!s.is_current && ( + + )} +
+ ); + })} +
+ + {sessions.length > 1 && ( +
+ +
+ )} +
+ )} +
)} diff --git a/src/pages/dashboard/DashboardPlugins.tsx b/src/pages/dashboard/DashboardPlugins.tsx index 17ba6e2..6e84a97 100644 --- a/src/pages/dashboard/DashboardPlugins.tsx +++ b/src/pages/dashboard/DashboardPlugins.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useAnalytics } from './useAnalytics'; import { useNavigate } from 'react-router-dom'; -import { Settings, Plus, ExternalLink, Sparkles, Search, BarChart2, Package } from 'lucide-react'; +import { Settings, Plus, ExternalLink, Sparkles, Search, BarChart2, Package, CheckCircle, X, ArrowRightLeft } from 'lucide-react'; +import api from '../../api'; import { useAuth } from '../../App'; import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal'; import { getPluginUrl } from '../../utils/url'; @@ -16,6 +17,47 @@ const DashboardPlugins = () => { const [isDevModalOpen, setIsDevModalOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [filterType, setFilterType] = useState('all'); + const [incomingTransfers, setIncomingTransfers] = useState([]); + const [transferLoadingId, setTransferLoadingId] = useState(null); + + useEffect(() => { + fetchIncomingTransfers(); + }, []); + + const fetchIncomingTransfers = async () => { + try { + const res = await api.get('/user/transfers/incoming'); + setIncomingTransfers(Array.isArray(res.data) ? res.data : []); + } catch { + setIncomingTransfers([]); + } + }; + + const handleAcceptTransfer = async (transferId: number) => { + setTransferLoadingId(transferId); + try { + await api.post(`/user/transfers/${transferId}/accept`); + await fetchIncomingTransfers(); + window.location.reload(); + } catch (err: any) { + alert(err.response?.data?.error || 'Failed to accept transfer request.'); + } finally { + setTransferLoadingId(null); + } + }; + + const handleRejectTransfer = async (transferId: number) => { + if (!window.confirm('Are you sure you want to decline this transfer request?')) return; + setTransferLoadingId(transferId); + try { + await api.post(`/user/transfers/${transferId}/reject`); + setIncomingTransfers(prev => prev.filter(t => t.id !== transferId)); + } catch (err: any) { + alert(err.response?.data?.error || 'Failed to decline transfer request.'); + } finally { + setTransferLoadingId(null); + } + }; // Grouping analytics data by plugin ID const plugins = useMemo(() => { @@ -35,8 +77,8 @@ const DashboardPlugins = () => { earnings: 0, views: 0, avgRating: curr.avgRating || 0, - created_at: curr.created_at, - updated_at: curr.updated_at + created_at: curr.createdAt || curr.created_at, + updated_at: curr.updatedAt || curr.updated_at }; } acc[id].downloads += (curr.downloads || 0); @@ -133,6 +175,81 @@ const DashboardPlugins = () => { + {/* Incoming Plugin Transfer Requests */} + {incomingTransfers.length > 0 && ( +
+ {incomingTransfers.map((req: any) => ( +
+
+
+ {!req.preview_url && req.plugin_name.charAt(0).toUpperCase()} +
+
+
+ + {req.plugin_name} + + + Ownership Transfer Request + +
+

+ @{req.sender_name} has requested to transfer ownership of this plugin to your account. +

+
+
+ +
+ + +
+
+ ))} +
+ )} + {/* Toolbar */}
diff --git a/src/pages/dashboard/plugin/DangerZone.tsx b/src/pages/dashboard/plugin/DangerZone.tsx index 75456a8..70474c1 100644 --- a/src/pages/dashboard/plugin/DangerZone.tsx +++ b/src/pages/dashboard/plugin/DangerZone.tsx @@ -1,24 +1,36 @@ import { useState } from 'react'; -import { Trash2,AlertTriangle } from 'lucide-react'; +import { Trash2, AlertTriangle, Lock, ShieldAlert, ShieldCheck } from 'lucide-react'; import api from '../../../api'; +import { useAuth } from '../../../App'; import type { PluginData } from './ManagePlugin'; import { useNavigate } from 'react-router-dom'; type Props = { plugin: PluginData }; const DangerZone = ({ plugin }: Props) => { + const { user } = useAuth(); const navigate = useNavigate(); const [deleteConfirm, setDeleteConfirm] = useState(''); + const [twoFactorCode, setTwoFactorCode] = useState(''); const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const handleDelete = async () => { if (deleteConfirm !== plugin.name) return; + if (!twoFactorCode.trim()) { + setDeleteError('Please enter your 6-digit 2FA code.'); + return; + } setDeleting(true); + setDeleteError(null); try { - await api.delete(`/plugins/${plugin.id}`); + await api.delete(`/plugins/${plugin.id}`, { + data: { code: twoFactorCode.trim() } + }); navigate('/dashboard/plugins'); - } catch { - alert('Failed to delete plugin.'); + } catch (err: any) { + const msg = err.response?.data?.error || err.response?.data?.message || (typeof err.response?.data === 'string' ? err.response.data : null) || 'Failed to delete plugin.'; + setDeleteError(msg); setDeleting(false); } }; @@ -30,27 +42,6 @@ const DangerZone = ({ plugin }: Props) => {

Irreversible actions live here. Proceed carefully.

- {/* ── Unpublish ── -
-
- - Unpublish Plugin -
-

- Hides the plugin from the store. Existing users who have already installed it are - not affected — they can continue using it. You can re-publish at any time. -

- -
*/} - {/* ── Delete ── */}
@@ -67,36 +58,91 @@ const DangerZone = ({ plugin }: Props) => {
-

- To confirm, type the plugin name - {plugin.name} below: -

- -
-
- setDeleteConfirm(e.target.value)} - placeholder={plugin.name} - autoComplete="off" - spellCheck={false} - style={{fontFamily:'var(--font-mono)'}} - /> + {user && !user.totp_enabled ? ( +
+
+ +
+ Two-Factor Authentication (2FA) Required + + To protect against accidental or unauthorized deletion of published plugins, you must enable 2FA on your account before deleting. + +
+
+
- -
+ ) : ( + <> +
+
+ + setDeleteConfirm(e.target.value)} + placeholder={plugin.name} + autoComplete="off" + spellCheck={false} + style={{fontFamily:'var(--font-mono)', marginTop: '0.35rem'}} + /> +
+ +
+ +
+ setTwoFactorCode(e.target.value.replace(/\D/g, ''))} + placeholder="123456" + autoComplete="one-time-code" + spellCheck={false} + style={{fontFamily:'var(--font-mono)', letterSpacing: '0.25em', paddingLeft: '2.25rem', maxWidth: '240px'}} + /> + +
+
+
+ + {deleteError && ( +
+ + {deleteError} +
+ )} + +
+ +
+ + )}
); diff --git a/src/pages/dashboard/plugin/ManagePlugin.tsx b/src/pages/dashboard/plugin/ManagePlugin.tsx index d7a8c81..1afdc1a 100644 --- a/src/pages/dashboard/plugin/ManagePlugin.tsx +++ b/src/pages/dashboard/plugin/ManagePlugin.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import api from '../../../api'; +import { useAuth } from '../../../App'; import { LayoutGrid, Tag, Upload, DollarSign, Trash2, - Circle, BarChart3, Key, Star + Circle, BarChart3, Key, Star, ArrowRightLeft } from 'lucide-react'; import StoreListing from './StoreListing'; @@ -12,6 +13,7 @@ import Pricing from './Pricing'; import Coupons from './Coupons'; import Licenses from './Licenses'; import PluginReviewsTab from './PluginReviewsTab'; +import TransferOwnership from './TransferOwnership'; import DangerZone from './DangerZone'; import PluginAnalyticsTab from './PluginAnalyticsTab'; import './ManagePlugin.css'; @@ -35,36 +37,46 @@ export type PluginData = { is_preorder?: boolean; preorder_release_date?: string; youtube_video_url?: string; + dev_id?: number; }; const NAV_ITEMS = [ - { key: 'listing', label: 'Store Listing', icon: LayoutGrid }, - { key: 'update', label: 'Publish Update', icon: Upload }, - { key: 'analytics', label: 'Analytics', icon: BarChart3 }, - { key: 'reviews', label: 'Reviews', icon: Star }, - { key: 'pricing', label: 'Pricing', icon: DollarSign }, - { key: 'licenses', label: 'Licenses', icon: Key }, - { key: 'coupons', label: 'Coupons', icon: Tag }, - { key: 'danger', label: 'Danger Zone', icon: Trash2 }, + { key: 'listing', label: 'Store Listing', icon: LayoutGrid }, + { key: 'update', label: 'Publish Update', icon: Upload }, + { key: 'analytics', label: 'Analytics', icon: BarChart3 }, + { key: 'reviews', label: 'Reviews', icon: Star }, + { key: 'pricing', label: 'Pricing', icon: DollarSign }, + { key: 'licenses', label: 'Licenses', icon: Key }, + { key: 'coupons', label: 'Coupons', icon: Tag }, + { key: 'transfer', label: 'Transfer', icon: ArrowRightLeft }, + { key: 'danger', label: 'Danger Zone', icon: Trash2 }, ]; const ManagePlugin = () => { const { id } = useParams<{ id: string }>(); + const { user } = useAuth(); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('listing'); const [loading, setLoading] = useState(true); const [plugin, setPlugin] = useState(null); useEffect(() => { - fetchPlugin(); - }, [id]); + if (user) { + fetchPlugin(); + } + }, [id, user]); const fetchPlugin = async () => { try { const res = await api.get(`/plugins/${id}`); - setPlugin(res.data); + const data = res.data; + if (!user || (data.dev_id !== user.id && user.role !== 'admin')) { + navigate('/dashboard/plugins', { replace: true }); + return; + } + setPlugin(data); } catch { - navigate('/dashboard/plugins'); + navigate('/dashboard/plugins', { replace: true }); } finally { setLoading(false); } @@ -154,6 +166,9 @@ const ManagePlugin = () => { {activeTab === 'coupons' && ( )} + {activeTab === 'transfer' && ( + + )} {activeTab === 'danger' && ( )} diff --git a/src/pages/dashboard/plugin/Pricing.tsx b/src/pages/dashboard/plugin/Pricing.tsx index 199b21e..cb2dee5 100644 --- a/src/pages/dashboard/plugin/Pricing.tsx +++ b/src/pages/dashboard/plugin/Pricing.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { DollarSign, ExternalLink, Save, Zap, Lock, Percent, Tag, AlertTriangle } from 'lucide-react'; +import { DollarSign, ExternalLink, Save, Zap, Lock, Percent, Tag, AlertTriangle, CheckCircle, ShieldCheck } from 'lucide-react'; import api from '../../../api'; import type { PluginData } from './ManagePlugin'; import { useAuth } from '../../../App'; @@ -27,6 +27,8 @@ const Pricing = ({ plugin, onSaved }: Props) => { const navigate = useNavigate(); const { user } = useAuth(); const [saving, setSaving] = useState(false); + const [pricingSuccess, setPricingSuccess] = useState(false); + const [pricingError, setPricingError] = useState(null); const [licenseType, setLicenseType] = useState(plugin.type === 'paid' ? 'paid' : 'free'); const [price, setPrice] = useState((plugin.price_cents ?? 0) / 100 || 4.99); @@ -84,10 +86,15 @@ const Pricing = ({ plugin, onSaved }: Props) => { } try { + setPricingError(null); + setPricingSuccess(false); await api.put(`/plugins/${plugin.id}`, fd); + setPricingSuccess(true); onSaved(); - } catch { - alert('Failed to save pricing.'); + setTimeout(() => setPricingSuccess(false), 3000); + } catch (err: any) { + const msg = err.response?.data?.error || err.response?.data?.message || (typeof err.response?.data === 'string' ? err.response.data : null) || 'Failed to save pricing.'; + setPricingError(msg); } finally { setSaving(false); } @@ -325,7 +332,38 @@ const Pricing = ({ plugin, onSaved }: Props) => { )} -
+ {pricingError && ( +
+
+ +
+ + {pricingError.toLowerCase().includes('2fa') || pricingError.toLowerCase().includes('two-factor') ? 'Two-Factor Authentication Required' : 'Pricing Error'} + + {pricingError} +
+
+ {(pricingError.toLowerCase().includes('2fa') || pricingError.toLowerCase().includes('two-factor') || !user?.totp_enabled) && ( + + )} +
+ )} + + {pricingSuccess && ( +
+ + Pricing settings saved successfully! +
+ )} + +
+ {user && !user.totp_enabled && ( +
+
+ +
+ Two-Factor Authentication (2FA) Required + + To protect servers and prevent unauthorized plugin uploads, 2FA is required before publishing updates. + +
+
+ +
+ )} + {/* ── Release Track ── */}
@@ -250,6 +279,30 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => { )}
+ {publishError && ( +
+
+ +
+ + {publishError.toLowerCase().includes('2fa') || publishError.toLowerCase().includes('two-factor') ? 'Two-Factor Authentication Required' : 'Publish Failed'} + + {publishError} +
+
+ {(publishError.toLowerCase().includes('2fa') || publishError.toLowerCase().includes('two-factor') || !user?.totp_enabled) && ( + + )} +
+ )} +
+ {saveError && ( +
+
+ +
+ + {saveError.toLowerCase().includes('2fa') || saveError.toLowerCase().includes('two-factor') ? 'Two-Factor Authentication Required' : 'Save Error'} + + {saveError} +
+
+ {(saveError.toLowerCase().includes('2fa') || saveError.toLowerCase().includes('two-factor') || !user?.totp_enabled) && ( + + )} +
+ )} + + {saveSuccess && ( +
+ + Listing details saved successfully! +
+ )} +
+
+
+ ) : ( + /* Form Card to Initiate Transfer */ +
+
+ Initiate Plugin Transfer +
+ + {user && !user.totp_enabled ? ( +
+
+ +
+ Two-Factor Authentication (2FA) Required + + To protect against account takeovers, you must enable 2FA in your account settings before transferring plugins. + +
+
+ +
+ ) : ( +
+ {/* Transfer Guidelines */} +
+
+ What happens during a transfer: +
+
    +
  • All existing releases, store listings, reviews, and active customer licenses are transferred.
  • +
  • Future sales and subscription revenues will route to the recipient's payout account.
  • +
  • The recipient must explicitly accept the request in their dashboard before ownership changes.
  • +
+
+ +
+ + setRecipient(e.target.value)} + autoComplete="off" + required + /> +
+ +
+ +
+ setTwoFactorCode(e.target.value.replace(/\D/g, ''))} + autoComplete="one-time-code" + style={{ fontFamily: 'var(--font-mono)', letterSpacing: '0.2em', paddingLeft: '2.25rem', maxWidth: '240px' }} + required + /> + +
+
+ + {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + +
+ +
+
+ )} +
+ )} +
+ ); +}; + +export default TransferOwnership;