feat: transfers and sessions
This commit is contained in:
parent
b162833243
commit
5ea35f6f8d
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,36 +58,91 @@ 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">
|
||||
<input
|
||||
id="deleteConfirmInput"
|
||||
name="deleteConfirm"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={deleteConfirm}
|
||||
onChange={e => setDeleteConfirm(e.target.value)}
|
||||
placeholder={plugin.name}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{fontFamily:'var(--font-mono)'}}
|
||||
/>
|
||||
{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>
|
||||
<button
|
||||
className="mp-btn mp-btn-danger"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteConfirm !== plugin.name || deleting}
|
||||
style={{flexShrink:0}}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
{deleting ? 'Deleting…' : 'Delete Forever'}
|
||||
</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"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={deleteConfirm}
|
||||
onChange={e => setDeleteConfirm(e.target.value)}
|
||||
placeholder={plugin.name}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
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 || twoFactorCode.trim().length !== 6 || deleting}
|
||||
style={{flexShrink:0}}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
{deleting ? 'Deleting…' : 'Delete Forever with 2FA'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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<PluginData | null>(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' && (
|
||||
<Coupons plugin={plugin} />
|
||||
)}
|
||||
{activeTab === 'transfer' && (
|
||||
<TransferOwnership plugin={plugin} />
|
||||
)}
|
||||
{activeTab === 'danger' && (
|
||||
<DangerZone plugin={plugin} />
|
||||
)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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} />
|
||||
|
||||
267
src/pages/dashboard/plugin/TransferOwnership.tsx
Normal file
267
src/pages/dashboard/plugin/TransferOwnership.tsx
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user