feat: transfers and sessions

This commit is contained in:
Alexander Medvedev 2026-08-27 23:13:46 +02:00
parent b162833243
commit 5ea35f6f8d
8 changed files with 832 additions and 84 deletions

View File

@ -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<any[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(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 = () => {
</div>
)}
</SettingsCard>
{/* Active Devices & Sessions */}
<SettingsCard
title="Active Devices & Sessions"
icon={Laptop}
description="Review browsers and devices currently logged into your account. You can revoke unrecognized sessions at any time."
>
{sessionsLoading ? (
<p className="settings-hint" style={{ textAlign: 'center', padding: '1.5rem 0' }}>Loading active devices…</p>
) : sessions.length === 0 ? (
<p className="settings-hint">No active sessions found.</p>
) : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{sessions.map((s: any) => {
const isMobile = s.device_type === 'mobile';
const isTablet = s.device_type === 'tablet';
const DeviceIcon = isMobile ? Smartphone : isTablet ? Tablet : Laptop;
return (
<div
key={s.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '1rem 1.15rem',
borderRadius: '10px',
background: s.is_current ? 'rgba(249, 115, 22, 0.06)' : 'rgba(255,255,255,0.02)',
border: s.is_current ? '1px solid rgba(249, 115, 22, 0.3)' : '1px solid rgba(255,255,255,0.07)',
flexWrap: 'wrap'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<div style={{
width: 40,
height: 40,
borderRadius: '8px',
background: s.is_current ? 'rgba(249, 115, 22, 0.15)' : 'rgba(255,255,255,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: s.is_current ? 'var(--mp-accent, #f97316)' : 'var(--dash-text-muted, #94a3b8)',
flexShrink: 0
}}>
<DeviceIcon size={20} />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--dash-text-primary, #fff)' }}>
{s.device_name || s.os || 'Web Device'}
</span>
{s.is_current && (
<span style={{
fontSize: '0.72rem',
padding: '2px 8px',
borderRadius: '12px',
background: 'rgba(16, 185, 129, 0.15)',
color: '#10b981',
border: '1px solid rgba(16, 185, 129, 0.3)',
fontWeight: 600
}}>
Current Device
</span>
)}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--dash-text-muted, #94a3b8)', marginTop: '0.25rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span>{s.browser || 'Web Browser'}</span>
{s.os && s.os !== s.device_name && (
<span>• {s.os}</span>
)}
{s.country && (
<span>• {s.country}</span>
)}
{s.ip_address && (
<span>• IP: {s.ip_address}</span>
)}
<span>
• {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' })}`}
</span>
</div>
</div>
</div>
{!s.is_current && (
<button
type="button"
className="settings-btn settings-btn-danger-outline"
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem', display: 'inline-flex', alignItems: 'center', gap: '5px' }}
onClick={() => handleRevokeSession(s.id)}
disabled={revokingSessionId === s.id}
>
<LogOut size={13} />
{revokingSessionId === s.id ? 'Revoking…' : 'Revoke'}
</button>
)}
</div>
);
})}
</div>
{sessions.length > 1 && (
<div style={{ marginTop: '1.25rem', display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
className="settings-btn settings-btn-danger-outline"
onClick={handleRevokeOtherSessions}
disabled={revokingAllSessions}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<LogOut size={14} />
{revokingAllSessions ? 'Signing out…' : 'Sign Out of All Other Devices'}
</button>
</div>
)}
</div>
)}
</SettingsCard>
</div>
)}

View File

@ -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<string>('all');
const [incomingTransfers, setIncomingTransfers] = useState<any[]>([]);
const [transferLoadingId, setTransferLoadingId] = useState<number | null>(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 = () => {
</button>
</div>
{/* Incoming Plugin Transfer Requests */}
{incomingTransfers.length > 0 && (
<div style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{incomingTransfers.map((req: any) => (
<div key={req.id} style={{
background: 'rgba(249, 115, 22, 0.08)',
border: '1px solid rgba(249, 115, 22, 0.35)',
borderRadius: '12px',
padding: '1.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '10px',
background: req.preview_url ? `url(${req.preview_url}) center/cover no-repeat` : 'rgba(249, 115, 22, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
color: '#f97316',
fontSize: '1.2rem',
flexShrink: 0,
border: '1px solid rgba(249, 115, 22, 0.3)'
}}>
{!req.preview_url && req.plugin_name.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--dash-text-primary)' }}>
{req.plugin_name}
</span>
<span className="dev-plugins-badge" style={{ background: 'rgba(249, 115, 22, 0.15)', color: '#f97316', border: '1px solid rgba(249, 115, 22, 0.3)' }}>
<ArrowRightLeft size={11} style={{ marginRight: 4 }} /> Ownership Transfer Request
</span>
</div>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.82rem', color: 'var(--dash-text-muted)' }}>
<strong>@{req.sender_name}</strong> has requested to transfer ownership of this plugin to your account.
</p>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<button
className="btn btn-primary"
style={{ padding: '0.5rem 1.1rem', fontSize: '0.82rem', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => handleAcceptTransfer(req.id)}
disabled={transferLoadingId === req.id}
>
<CheckCircle size={15} />
{transferLoadingId === req.id ? 'Accepting…' : 'Accept Transfer'}
</button>
<button
className="btn"
style={{
padding: '0.5rem 1rem', fontSize: '0.82rem',
background: 'var(--dash-surface-2)', border: '1px solid var(--dash-border)',
color: 'var(--dash-text-muted)', display: 'inline-flex', alignItems: 'center', gap: '6px'
}}
onClick={() => handleRejectTransfer(req.id)}
disabled={transferLoadingId === req.id}
>
<X size={15} /> Decline
</button>
</div>
</div>
))}
</div>
)}
{/* Toolbar */}
<div className="dev-plugins-toolbar">
<div className="dev-plugins-filter-box">

View File

@ -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<string | null>(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) => {
<p>Irreversible actions live here. Proceed carefully.</p>
</div>
{/* ── Unpublish ──
<div className="mp-card" style={{borderColor:'rgba(245,166,35,0.3)'}}>
<div className="mp-card-title">
<EyeOff size={14} style={{color:'var(--mp-warn)'}} />
<span style={{color:'var(--mp-warn)'}}>Unpublish Plugin</span>
</div>
<p style={{fontSize:'0.85rem', color:'var(--mp-text-2)', lineHeight:1.6, marginBottom:'1.25rem'}}>
Hides the plugin from the store. Existing users who have already installed it are
<strong> not affected</strong> — they can continue using it. You can re-publish at any time.
</p>
<button
className="mp-btn mp-btn-outline"
onClick={handleUnpublish}
disabled={unpublishing}
style={{borderColor:'var(--mp-warn)', color:'var(--mp-warn)'}}
>
<EyeOff size={15} />
{unpublishing ? 'Unpublishing…' : 'Unpublish Plugin'}
</button>
</div> */}
{/* ── Delete ── */}
<div className="mp-card" style={{borderColor:'rgba(242,65,90,0.3)'}}>
<div className="mp-card-title">
@ -67,13 +58,33 @@ const DangerZone = ({ plugin }: Props) => {
</div>
</div>
<p style={{fontSize:'0.85rem', color:'var(--mp-text-2)', lineHeight:1.6, marginBottom:'1rem'}}>
To confirm, type the plugin name <strong style={{color:'var(--mp-text)', fontFamily:'var(--font-mono)'}}>
{plugin.name}</strong> below:
</p>
<div className="mp-danger-input-row">
<div className="mp-form-group">
{user && !user.totp_enabled ? (
<div className="mp-banner warn" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', padding: '1.25rem', marginTop: '1.25rem', background: 'rgba(249, 115, 22, 0.1)', border: '1px solid rgba(249, 115, 22, 0.3)', borderRadius: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<ShieldAlert size={24} color="#f97316" style={{ flexShrink: 0 }} />
<div>
<strong style={{ color: '#f97316', display: 'block', fontSize: '0.95rem' }}>Two-Factor Authentication (2FA) Required</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--mp-text-2)' }}>
To protect against accidental or unauthorized deletion of published plugins, you must enable 2FA on your account before deleting.
</span>
</div>
</div>
<button
type="button"
className="mp-btn mp-btn-primary"
style={{ padding: '0.6rem 1.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA →
</button>
</div>
) : (
<>
<div style={{ marginTop: '1.25rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div>
<label className="mp-label" htmlFor="deleteConfirmInput">
1. Type the plugin name <strong style={{color:'var(--mp-text)', fontFamily:'var(--font-mono)'}}>{plugin.name}</strong> to confirm:
</label>
<input
id="deleteConfirmInput"
name="deleteConfirm"
@ -84,19 +95,54 @@ const DangerZone = ({ plugin }: Props) => {
placeholder={plugin.name}
autoComplete="off"
spellCheck={false}
style={{fontFamily:'var(--font-mono)'}}
style={{fontFamily:'var(--font-mono)', marginTop: '0.35rem'}}
/>
</div>
<div>
<label className="mp-label" htmlFor="twoFactorCodeInput">
2. Enter your 6-digit Two-Factor Authentication (2FA) code:
</label>
<div style={{ position: 'relative', marginTop: '0.35rem' }}>
<input
id="twoFactorCodeInput"
name="twoFactorCode"
className="mp-input"
type="text"
inputMode="numeric"
maxLength={6}
value={twoFactorCode}
onChange={e => 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'}}
/>
<Lock size={15} color="var(--mp-text-3)" style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)' }} />
</div>
</div>
</div>
{deleteError && (
<div className="mp-banner error" style={{ marginTop: '1.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertTriangle size={16} color="var(--mp-error)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.85rem', color: 'var(--mp-error)' }}>{deleteError}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1.5rem' }}>
<button
className="mp-btn mp-btn-danger"
onClick={handleDelete}
disabled={deleteConfirm !== plugin.name || deleting}
disabled={deleteConfirm !== plugin.name || twoFactorCode.trim().length !== 6 || deleting}
style={{flexShrink:0}}
>
<Trash2 size={15} />
{deleting ? 'Deleting…' : 'Delete Forever'}
{deleting ? 'Deleting…' : 'Delete Forever with 2FA'}
</button>
</div>
</>
)}
</div>
</div>
);

View File

@ -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,6 +37,7 @@ export type PluginData = {
is_preorder?: boolean;
preorder_release_date?: string;
youtube_video_url?: string;
dev_id?: number;
};
const NAV_ITEMS = [
@ -45,26 +48,35 @@ const NAV_ITEMS = [
{ 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<PluginData | null>(null);
useEffect(() => {
if (user) {
fetchPlugin();
}, [id]);
}
}, [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' && (
<Coupons plugin={plugin} />
)}
{activeTab === 'transfer' && (
<TransferOwnership plugin={plugin} />
)}
{activeTab === 'danger' && (
<DangerZone plugin={plugin} />
)}

View File

@ -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<string | null>(null);
const [licenseType, setLicenseType] = useState<LicenseType>(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) => {
</>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '2rem' }}>
{pricingError && (
<div className="mp-banner error" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', padding: '1.25rem', marginTop: '1.5rem', marginBottom: '1rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<Lock size={22} color="#ef4444" style={{ flexShrink: 0 }} />
<div>
<strong style={{ color: '#ef4444', display: 'block', fontSize: '0.92rem' }}>
{pricingError.toLowerCase().includes('2fa') || pricingError.toLowerCase().includes('two-factor') ? 'Two-Factor Authentication Required' : 'Pricing Error'}
</strong>
<span style={{ fontSize: '0.83rem', color: 'var(--mp-text-2)' }}>{pricingError}</span>
</div>
</div>
{(pricingError.toLowerCase().includes('2fa') || pricingError.toLowerCase().includes('two-factor') || !user?.totp_enabled) && (
<button
type="button"
className="mp-btn mp-btn-primary"
style={{ padding: '0.6rem 1.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA →
</button>
)}
</div>
)}
{pricingSuccess && (
<div className="mp-banner success" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', marginTop: '1.5rem', marginBottom: '1rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '10px' }}>
<CheckCircle size={20} color="#10b981" />
<span style={{ fontSize: '0.85rem', color: '#10b981', fontWeight: 600 }}>Pricing settings saved successfully!</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1.5rem' }}>
<button
type="submit"
className="mp-btn mp-btn-primary"

View File

@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { Upload, FileCode, Send, CheckCircle, AlertTriangle, Clock } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Upload, FileCode, Send, CheckCircle, AlertTriangle, Clock, ShieldAlert, ShieldCheck, Lock } from 'lucide-react';
import api from '../../../api';
import { useAuth } from '../../../App';
import type { PluginData } from './ManagePlugin';
import { validateWasmFile } from '../../../utils/fileValidation';
@ -13,6 +15,8 @@ const TRACKS = [
] as const;
const PublishUpdate = ({ plugin, onSaved }: Props) => {
const { user } = useAuth();
const navigate = useNavigate();
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [wasmValidationErr, setWasmValidationErr] = useState<string | null>(null);
const [wasmFormatInfo, setWasmFormatInfo] = useState<string | null>(null);
@ -23,6 +27,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [published, setPublished] = useState(false);
const [publishError, setPublishError] = useState<string | null>(null);
const handleFileValidation = async (file: File | null) => {
if (!file) {
@ -63,6 +68,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
const handlePublish = async () => {
if (!wasmFile) return;
setUploading(true);
setPublishError(null);
const fd = new FormData();
fd.append('wasm', wasmFile);
if (version) fd.append('version', version);
@ -79,8 +85,9 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
setVersion('');
setReleaseNotes('');
}, 3000);
} catch {
alert('Publish failed.');
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.message || (typeof err.response?.data === 'string' ? err.response.data : null) || 'Publish failed. Please check your inputs and try again.';
setPublishError(msg);
} finally {
setUploading(false);
}
@ -121,6 +128,28 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
<p>Ship a new version of your plugin. Choose your release track, upload the binary, and add release notes — like Google Play, but for your plugin marketplace.</p>
</div>
{user && !user.totp_enabled && (
<div className="mp-banner warn" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', padding: '1.25rem', marginBottom: '1.5rem', background: 'rgba(249, 115, 22, 0.1)', border: '1px solid rgba(249, 115, 22, 0.3)', borderRadius: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<ShieldAlert size={24} color="#f97316" style={{ flexShrink: 0 }} />
<div>
<strong style={{ color: '#f97316', display: 'block', fontSize: '0.95rem' }}>Two-Factor Authentication (2FA) Required</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--mp-text-2)' }}>
To protect servers and prevent unauthorized plugin uploads, 2FA is required before publishing updates.
</span>
</div>
</div>
<button
type="button"
className="mp-btn mp-btn-primary"
style={{ padding: '0.6rem 1.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA →
</button>
</div>
)}
{/* ── Release Track ── */}
<div className="mp-card">
<div className="mp-card-title">
@ -250,6 +279,30 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
)}
</div>
{publishError && (
<div className="mp-banner error" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', padding: '1.25rem', marginBottom: '1rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<Lock size={22} color="#ef4444" style={{ flexShrink: 0 }} />
<div>
<strong style={{ color: '#ef4444', display: 'block', fontSize: '0.92rem' }}>
{publishError.toLowerCase().includes('2fa') || publishError.toLowerCase().includes('two-factor') ? 'Two-Factor Authentication Required' : 'Publish Failed'}
</strong>
<span style={{ fontSize: '0.83rem', color: 'var(--mp-text-2)' }}>{publishError}</span>
</div>
</div>
{(publishError.toLowerCase().includes('2fa') || publishError.toLowerCase().includes('two-factor') || !user?.totp_enabled) && (
<button
type="button"
className="mp-btn mp-btn-primary"
style={{ padding: '0.6rem 1.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA →
</button>
)}
</div>
)}
<div style={{display:'flex', justifyContent:'flex-end', gap:'0.75rem'}}>
<button
className="mp-btn mp-btn-primary"

View File

@ -1,6 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import { Globe, ImageIcon, Plus, Save, Search, X, AlertTriangle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Globe, ImageIcon, Plus, Save, Search, X, AlertTriangle, CheckCircle, ShieldCheck, Lock } from 'lucide-react';
import api from '../../../api';
import { useAuth } from '../../../App';
import type { PluginData } from './ManagePlugin';
import { validateAndSanitizeImage } from '../../../utils/fileValidation';
@ -137,7 +139,11 @@ const LangPicker = ({ usedCodes, onAdd, onClose }: PickerProps) => {
type Props = { plugin: PluginData; onSaved: () => void };
const StoreListing = ({ plugin, onSaved }: Props) => {
const { user } = useAuth();
const navigate = useNavigate();
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [name, setName] = useState(plugin.name);
const [descriptions, setDescriptions] = useState<Record<string, string>>(() => {
const parsed = JSON.parse(plugin.translated_descriptions || '{}');
@ -211,6 +217,8 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
setSaveSuccess(false);
const fd = new FormData();
fd.append('name', name);
fd.append('translated_descriptions', JSON.stringify(descriptions));
@ -224,9 +232,12 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
}
try {
await api.put(`/plugins/${plugin.id}`, fd);
setSaveSuccess(true);
onSaved();
} catch {
alert('Failed to save.');
setTimeout(() => setSaveSuccess(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 plugin details.';
setSaveError(msg);
} finally {
setSaving(false);
}
@ -237,8 +248,9 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
try {
await api.delete(`/plugins/screenshots/${screenId}`);
setScreenshots(prev => prev.filter(s => s.id !== screenId));
} catch {
alert('Failed to delete screenshot.');
} 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 screenshot.';
setSaveError(msg);
}
};
@ -512,6 +524,37 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
</p>
</div>
{saveError && (
<div className="mp-banner error" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', padding: '1.25rem', marginBottom: '1.25rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<Lock size={22} color="#ef4444" style={{ flexShrink: 0 }} />
<div>
<strong style={{ color: '#ef4444', display: 'block', fontSize: '0.92rem' }}>
{saveError.toLowerCase().includes('2fa') || saveError.toLowerCase().includes('two-factor') ? 'Two-Factor Authentication Required' : 'Save Error'}
</strong>
<span style={{ fontSize: '0.83rem', color: 'var(--mp-text-2)' }}>{saveError}</span>
</div>
</div>
{(saveError.toLowerCase().includes('2fa') || saveError.toLowerCase().includes('two-factor') || !user?.totp_enabled) && (
<button
type="button"
className="mp-btn mp-btn-primary"
style={{ padding: '0.6rem 1.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA →
</button>
)}
</div>
)}
{saveSuccess && (
<div className="mp-banner success" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', marginBottom: '1.25rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '10px' }}>
<CheckCircle size={20} color="#10b981" />
<span style={{ fontSize: '0.85rem', color: '#10b981', fontWeight: 600 }}>Listing details saved successfully!</span>
</div>
)}
<div style={{display:'flex', justifyContent:'flex-end'}}>
<button type="submit" className="mp-btn mp-btn-primary" disabled={saving}>
<Save size={15} />

View File

@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowRightLeft, AlertTriangle, CheckCircle, Lock, ShieldAlert, ShieldCheck, Clock, XCircle, UserCheck } from 'lucide-react';
import api from '../../../api';
import { useAuth } from '../../../App';
import type { PluginData } from './ManagePlugin';
type Props = { plugin: PluginData };
type PendingTransfer = {
id: number;
plugin_id: number;
recipient_id: number;
recipient_name: string;
status: string;
created_at?: string;
};
const TransferOwnership = ({ plugin }: Props) => {
const { user } = useAuth();
const navigate = useNavigate();
const [pendingTransfer, setPendingTransfer] = useState<PendingTransfer | null>(null);
const [loading, setLoading] = useState(true);
const [recipient, setRecipient] = useState('');
const [twoFactorCode, setTwoFactorCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
fetchTransferStatus();
}, [plugin.id]);
const fetchTransferStatus = async () => {
setLoading(true);
try {
const res = await api.get(`/plugins/${plugin.id}/transfer`);
setPendingTransfer(res.data);
} catch {
setPendingTransfer(null);
} finally {
setLoading(false);
}
};
const handleInitiateTransfer = async (e: React.FormEvent) => {
e.preventDefault();
const cleanRecipient = recipient.trim();
const cleanCode = twoFactorCode.trim();
if (!cleanRecipient) {
setError('Please specify the recipient username or email.');
return;
}
if (cleanCode.length !== 6) {
setError('Please enter a valid 6-digit 2FA authenticator code.');
return;
}
setSubmitting(true);
setError(null);
setSuccess(null);
try {
const res = await api.post(`/plugins/${plugin.id}/transfer`, {
recipient: cleanRecipient,
code: cleanCode,
});
setPendingTransfer(res.data);
setRecipient('');
setTwoFactorCode('');
setSuccess(`Transfer request sent to @${res.data.recipient_name}. The recipient must accept it in their dashboard.`);
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.message || (typeof err.response?.data === 'string' ? err.response.data : null) || 'Failed to initiate transfer request.';
setError(msg);
} finally {
setSubmitting(false);
}
};
const handleCancelTransfer = async () => {
if (!window.confirm('Cancel this pending transfer request?')) return;
setCancelling(true);
setError(null);
setSuccess(null);
try {
await api.delete(`/plugins/${plugin.id}/transfer`);
setPendingTransfer(null);
setSuccess('Transfer request cancelled successfully.');
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.message || (typeof err.response?.data === 'string' ? err.response.data : null) || 'Failed to cancel transfer request.';
setError(msg);
} finally {
setCancelling(false);
}
};
return (
<div>
<div className="mp-tab-header">
<h2>Transfer Ownership</h2>
<p>Transfer this plugin to another developer on Pumpkin Market. The recipient must review and accept the request before ownership is transferred.</p>
</div>
{/* Current Status Card */}
{loading ? (
<div className="mp-card" style={{ padding: '2rem', textAlign: 'center', color: 'var(--mp-text-3)' }}>
Loading transfer status…
</div>
) : pendingTransfer ? (
<div className="mp-card" style={{ borderColor: 'rgba(249, 115, 22, 0.3)', background: 'rgba(249, 115, 22, 0.04)' }}>
<div className="mp-card-title" style={{ color: '#f97316' }}>
<Clock size={16} /> Pending Ownership Transfer
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '1rem', padding: '1.25rem', background: 'var(--mp-surface-2)', borderRadius: '10px', border: '1px solid var(--mp-border)' }}>
<div style={{
width: 44, height: 44, borderRadius: '50%', background: 'rgba(249, 115, 22, 0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#f97316', flexShrink: 0
}}>
<ArrowRightLeft size={20} />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--mp-text)' }}>
Pending transfer to <span style={{ color: 'var(--mp-accent)' }}>@{pendingTransfer.recipient_name}</span>
</span>
<span className="dev-plugins-badge" style={{ background: 'rgba(249, 115, 22, 0.15)', color: '#f97316', border: '1px solid rgba(249, 115, 22, 0.3)', textTransform: 'capitalize' }}>
{pendingTransfer.status}
</span>
</div>
<p style={{ margin: '0.35rem 0 0 0', fontSize: '0.82rem', color: 'var(--mp-text-2)', lineHeight: 1.5 }}>
The recipient has been invited to take over <strong>{plugin.name}</strong>. Ownership and management permissions remain with you until they accept.
</p>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1.5rem' }}>
<button
type="button"
className="mp-btn mp-btn-danger"
onClick={handleCancelTransfer}
disabled={cancelling}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<XCircle size={15} />
{cancelling ? 'Cancelling…' : 'Cancel Transfer Request'}
</button>
</div>
</div>
) : (
/* Form Card to Initiate Transfer */
<div className="mp-card">
<div className="mp-card-title">
<ArrowRightLeft size={16} /> Initiate Plugin Transfer
</div>
{user && !user.totp_enabled ? (
<div className="mp-banner warn" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', padding: '1.25rem', marginTop: '1rem', background: 'rgba(249, 115, 22, 0.1)', border: '1px solid rgba(249, 115, 22, 0.3)', borderRadius: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<ShieldAlert size={24} color="#f97316" style={{ flexShrink: 0 }} />
<div>
<strong style={{ color: '#f97316', display: 'block', fontSize: '0.95rem' }}>Two-Factor Authentication (2FA) Required</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--mp-text-2)' }}>
To protect against account takeovers, you must enable 2FA in your account settings before transferring plugins.
</span>
</div>
</div>
<button
type="button"
className="mp-btn mp-btn-primary"
style={{ padding: '0.6rem 1.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA →
</button>
</div>
) : (
<form onSubmit={handleInitiateTransfer} style={{ marginTop: '1rem' }}>
{/* Transfer Guidelines */}
<div style={{ padding: '1rem', background: 'var(--mp-surface-2)', borderRadius: '8px', border: '1px solid var(--mp-border)', marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.88rem', fontWeight: 600, color: 'var(--mp-text)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '6px' }}>
<UserCheck size={15} color="var(--mp-accent)" /> What happens during a transfer:
</div>
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.82rem', color: 'var(--mp-text-2)', lineHeight: 1.6 }}>
<li>All existing releases, store listings, reviews, and active customer licenses are transferred.</li>
<li>Future sales and subscription revenues will route to the recipient's payout account.</li>
<li><strong>The recipient must explicitly accept the request</strong> in their dashboard before ownership changes.</li>
</ul>
</div>
<div className="mp-form-group">
<label className="mp-label" htmlFor="transferRecipientInput">
Recipient Username or Email
</label>
<input
id="transferRecipientInput"
name="transferRecipient"
type="text"
className="mp-input"
placeholder="e.g. notch or dev@example.com"
value={recipient}
onChange={e => setRecipient(e.target.value)}
autoComplete="off"
required
/>
</div>
<div className="mp-form-group">
<label className="mp-label" htmlFor="transfer2faCodeInput">
Enter your 6-digit Two-Factor Authentication (2FA) Code
</label>
<div style={{ position: 'relative' }}>
<input
id="transfer2faCodeInput"
name="transfer2faCode"
type="text"
inputMode="numeric"
maxLength={6}
className="mp-input"
placeholder="123456"
value={twoFactorCode}
onChange={e => 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
/>
<Lock size={15} color="var(--mp-text-3)" style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)' }} />
</div>
</div>
{error && (
<div className="mp-banner error" style={{ marginTop: '1.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertTriangle size={16} color="var(--mp-error)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.85rem', color: 'var(--mp-error)' }}>{error}</span>
</div>
)}
{success && (
<div className="mp-banner success" style={{ marginTop: '1.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<CheckCircle size={16} color="#10b981" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.85rem', color: '#10b981' }}>{success}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1.5rem' }}>
<button
type="submit"
className="mp-btn mp-btn-primary"
disabled={submitting || !recipient.trim() || twoFactorCode.trim().length !== 6}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<ArrowRightLeft size={15} />
{submitting ? 'Sending Request…' : 'Send Transfer Request'}
</button>
</div>
</form>
)}
</div>
)}
</div>
);
};
export default TransferOwnership;