feat: transfers and sessions
This commit is contained in:
parent
b162833243
commit
5ea35f6f8d
@ -12,7 +12,7 @@ import {
|
|||||||
BookOpen, AlertTriangle, LogOut, CheckCircle,
|
BookOpen, AlertTriangle, LogOut, CheckCircle,
|
||||||
AlertCircle, Eye, EyeOff, ChevronRight, Bell,
|
AlertCircle, Eye, EyeOff, ChevronRight, Bell,
|
||||||
Smartphone, Key, Trash2, Code, Sparkles, Building2,
|
Smartphone, Key, Trash2, Code, Sparkles, Building2,
|
||||||
ShieldCheck, Download, Sliders, Package
|
ShieldCheck, Download, Sliders, Package, Laptop, Tablet
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LibraryEntry {
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'library') return;
|
if (activeTab !== 'library') return;
|
||||||
setLibraryLoading(true);
|
setLibraryLoading(true);
|
||||||
@ -676,6 +726,125 @@ const ProfilePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsCard>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalytics } from './useAnalytics';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { useAuth } from '../../App';
|
||||||
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
|
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
|
||||||
import { getPluginUrl } from '../../utils/url';
|
import { getPluginUrl } from '../../utils/url';
|
||||||
@ -16,6 +17,47 @@ const DashboardPlugins = () => {
|
|||||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string>('all');
|
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
|
// Grouping analytics data by plugin ID
|
||||||
const plugins = useMemo(() => {
|
const plugins = useMemo(() => {
|
||||||
@ -35,8 +77,8 @@ const DashboardPlugins = () => {
|
|||||||
earnings: 0,
|
earnings: 0,
|
||||||
views: 0,
|
views: 0,
|
||||||
avgRating: curr.avgRating || 0,
|
avgRating: curr.avgRating || 0,
|
||||||
created_at: curr.created_at,
|
created_at: curr.createdAt || curr.created_at,
|
||||||
updated_at: curr.updated_at
|
updated_at: curr.updatedAt || curr.updated_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
acc[id].downloads += (curr.downloads || 0);
|
acc[id].downloads += (curr.downloads || 0);
|
||||||
@ -133,6 +175,81 @@ const DashboardPlugins = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Toolbar */}
|
||||||
<div className="dev-plugins-toolbar">
|
<div className="dev-plugins-toolbar">
|
||||||
<div className="dev-plugins-filter-box">
|
<div className="dev-plugins-filter-box">
|
||||||
|
|||||||
@ -1,24 +1,36 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Trash2,AlertTriangle } from 'lucide-react';
|
import { Trash2, AlertTriangle, Lock, ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
import { useAuth } from '../../../App';
|
||||||
import type { PluginData } from './ManagePlugin';
|
import type { PluginData } from './ManagePlugin';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
type Props = { plugin: PluginData };
|
type Props = { plugin: PluginData };
|
||||||
|
|
||||||
const DangerZone = ({ plugin }: Props) => {
|
const DangerZone = ({ plugin }: Props) => {
|
||||||
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState('');
|
const [deleteConfirm, setDeleteConfirm] = useState('');
|
||||||
|
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (deleteConfirm !== plugin.name) return;
|
if (deleteConfirm !== plugin.name) return;
|
||||||
|
if (!twoFactorCode.trim()) {
|
||||||
|
setDeleteError('Please enter your 6-digit 2FA code.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/plugins/${plugin.id}`);
|
await api.delete(`/plugins/${plugin.id}`, {
|
||||||
|
data: { code: twoFactorCode.trim() }
|
||||||
|
});
|
||||||
navigate('/dashboard/plugins');
|
navigate('/dashboard/plugins');
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
alert('Failed to delete plugin.');
|
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);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -30,27 +42,6 @@ const DangerZone = ({ plugin }: Props) => {
|
|||||||
<p>Irreversible actions live here. Proceed carefully.</p>
|
<p>Irreversible actions live here. Proceed carefully.</p>
|
||||||
</div>
|
</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 ── */}
|
{/* ── Delete ── */}
|
||||||
<div className="mp-card" style={{borderColor:'rgba(242,65,90,0.3)'}}>
|
<div className="mp-card" style={{borderColor:'rgba(242,65,90,0.3)'}}>
|
||||||
<div className="mp-card-title">
|
<div className="mp-card-title">
|
||||||
@ -67,36 +58,91 @@ const DangerZone = ({ plugin }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{fontSize:'0.85rem', color:'var(--mp-text-2)', lineHeight:1.6, marginBottom:'1rem'}}>
|
{user && !user.totp_enabled ? (
|
||||||
To confirm, type the plugin name <strong style={{color:'var(--mp-text)', fontFamily:'var(--font-mono)'}}>
|
<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' }}>
|
||||||
{plugin.name}</strong> below:
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
|
||||||
</p>
|
<ShieldAlert size={24} color="#f97316" style={{ flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
<div className="mp-danger-input-row">
|
<strong style={{ color: '#f97316', display: 'block', fontSize: '0.95rem' }}>Two-Factor Authentication (2FA) Required</strong>
|
||||||
<div className="mp-form-group">
|
<span style={{ fontSize: '0.85rem', color: 'var(--mp-text-2)' }}>
|
||||||
<input
|
To protect against accidental or unauthorized deletion of published plugins, you must enable 2FA on your account before deleting.
|
||||||
id="deleteConfirmInput"
|
</span>
|
||||||
name="deleteConfirm"
|
</div>
|
||||||
className="mp-input"
|
</div>
|
||||||
type="text"
|
<button
|
||||||
value={deleteConfirm}
|
type="button"
|
||||||
onChange={e => setDeleteConfirm(e.target.value)}
|
className="mp-btn mp-btn-primary"
|
||||||
placeholder={plugin.name}
|
style={{ padding: '0.6rem 1.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
autoComplete="off"
|
onClick={() => navigate('/settings?tab=security')}
|
||||||
spellCheck={false}
|
>
|
||||||
style={{fontFamily:'var(--font-mono)'}}
|
<ShieldCheck size={16} /> Enable 2FA →
|
||||||
/>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
) : (
|
||||||
className="mp-btn mp-btn-danger"
|
<>
|
||||||
onClick={handleDelete}
|
<div style={{ marginTop: '1.25rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
disabled={deleteConfirm !== plugin.name || deleting}
|
<div>
|
||||||
style={{flexShrink:0}}
|
<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:
|
||||||
<Trash2 size={15} />
|
</label>
|
||||||
{deleting ? 'Deleting…' : 'Delete Forever'}
|
<input
|
||||||
</button>
|
id="deleteConfirmInput"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
import { useAuth } from '../../../App';
|
||||||
import {
|
import {
|
||||||
LayoutGrid, Tag, Upload, DollarSign, Trash2,
|
LayoutGrid, Tag, Upload, DollarSign, Trash2,
|
||||||
Circle, BarChart3, Key, Star
|
Circle, BarChart3, Key, Star, ArrowRightLeft
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import StoreListing from './StoreListing';
|
import StoreListing from './StoreListing';
|
||||||
@ -12,6 +13,7 @@ import Pricing from './Pricing';
|
|||||||
import Coupons from './Coupons';
|
import Coupons from './Coupons';
|
||||||
import Licenses from './Licenses';
|
import Licenses from './Licenses';
|
||||||
import PluginReviewsTab from './PluginReviewsTab';
|
import PluginReviewsTab from './PluginReviewsTab';
|
||||||
|
import TransferOwnership from './TransferOwnership';
|
||||||
import DangerZone from './DangerZone';
|
import DangerZone from './DangerZone';
|
||||||
import PluginAnalyticsTab from './PluginAnalyticsTab';
|
import PluginAnalyticsTab from './PluginAnalyticsTab';
|
||||||
import './ManagePlugin.css';
|
import './ManagePlugin.css';
|
||||||
@ -35,36 +37,46 @@ export type PluginData = {
|
|||||||
is_preorder?: boolean;
|
is_preorder?: boolean;
|
||||||
preorder_release_date?: string;
|
preorder_release_date?: string;
|
||||||
youtube_video_url?: string;
|
youtube_video_url?: string;
|
||||||
|
dev_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },
|
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },
|
||||||
{ key: 'update', label: 'Publish Update', icon: Upload },
|
{ key: 'update', label: 'Publish Update', icon: Upload },
|
||||||
{ key: 'analytics', label: 'Analytics', icon: BarChart3 },
|
{ key: 'analytics', label: 'Analytics', icon: BarChart3 },
|
||||||
{ key: 'reviews', label: 'Reviews', icon: Star },
|
{ key: 'reviews', label: 'Reviews', icon: Star },
|
||||||
{ key: 'pricing', label: 'Pricing', icon: DollarSign },
|
{ key: 'pricing', label: 'Pricing', icon: DollarSign },
|
||||||
{ key: 'licenses', label: 'Licenses', icon: Key },
|
{ key: 'licenses', label: 'Licenses', icon: Key },
|
||||||
{ key: 'coupons', label: 'Coupons', icon: Tag },
|
{ key: 'coupons', label: 'Coupons', icon: Tag },
|
||||||
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
|
{ key: 'transfer', label: 'Transfer', icon: ArrowRightLeft },
|
||||||
|
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ManagePlugin = () => {
|
const ManagePlugin = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeTab, setActiveTab] = useState('listing');
|
const [activeTab, setActiveTab] = useState('listing');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [plugin, setPlugin] = useState<PluginData | null>(null);
|
const [plugin, setPlugin] = useState<PluginData | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlugin();
|
if (user) {
|
||||||
}, [id]);
|
fetchPlugin();
|
||||||
|
}
|
||||||
|
}, [id, user]);
|
||||||
|
|
||||||
const fetchPlugin = async () => {
|
const fetchPlugin = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/plugins/${id}`);
|
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 {
|
} catch {
|
||||||
navigate('/dashboard/plugins');
|
navigate('/dashboard/plugins', { replace: true });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -154,6 +166,9 @@ const ManagePlugin = () => {
|
|||||||
{activeTab === 'coupons' && (
|
{activeTab === 'coupons' && (
|
||||||
<Coupons plugin={plugin} />
|
<Coupons plugin={plugin} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'transfer' && (
|
||||||
|
<TransferOwnership plugin={plugin} />
|
||||||
|
)}
|
||||||
{activeTab === 'danger' && (
|
{activeTab === 'danger' && (
|
||||||
<DangerZone plugin={plugin} />
|
<DangerZone plugin={plugin} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 api from '../../../api';
|
||||||
import type { PluginData } from './ManagePlugin';
|
import type { PluginData } from './ManagePlugin';
|
||||||
import { useAuth } from '../../../App';
|
import { useAuth } from '../../../App';
|
||||||
@ -27,6 +27,8 @@ const Pricing = ({ plugin, onSaved }: Props) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [saving, setSaving] = useState(false);
|
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 [licenseType, setLicenseType] = useState<LicenseType>(plugin.type === 'paid' ? 'paid' : 'free');
|
||||||
const [price, setPrice] = useState((plugin.price_cents ?? 0) / 100 || 4.99);
|
const [price, setPrice] = useState((plugin.price_cents ?? 0) / 100 || 4.99);
|
||||||
@ -84,10 +86,15 @@ const Pricing = ({ plugin, onSaved }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setPricingError(null);
|
||||||
|
setPricingSuccess(false);
|
||||||
await api.put(`/plugins/${plugin.id}`, fd);
|
await api.put(`/plugins/${plugin.id}`, fd);
|
||||||
|
setPricingSuccess(true);
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch {
|
setTimeout(() => setPricingSuccess(false), 3000);
|
||||||
alert('Failed to save pricing.');
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mp-btn mp-btn-primary"
|
className="mp-btn mp-btn-primary"
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
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 api from '../../../api';
|
||||||
|
import { useAuth } from '../../../App';
|
||||||
import type { PluginData } from './ManagePlugin';
|
import type { PluginData } from './ManagePlugin';
|
||||||
import { validateWasmFile } from '../../../utils/fileValidation';
|
import { validateWasmFile } from '../../../utils/fileValidation';
|
||||||
|
|
||||||
@ -13,6 +15,8 @@ const TRACKS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||||
const [wasmValidationErr, setWasmValidationErr] = useState<string | null>(null);
|
const [wasmValidationErr, setWasmValidationErr] = useState<string | null>(null);
|
||||||
const [wasmFormatInfo, setWasmFormatInfo] = 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 [dragOver, setDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [published, setPublished] = useState(false);
|
const [published, setPublished] = useState(false);
|
||||||
|
const [publishError, setPublishError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleFileValidation = async (file: File | null) => {
|
const handleFileValidation = async (file: File | null) => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@ -63,6 +68,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!wasmFile) return;
|
if (!wasmFile) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setPublishError(null);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('wasm', wasmFile);
|
fd.append('wasm', wasmFile);
|
||||||
if (version) fd.append('version', version);
|
if (version) fd.append('version', version);
|
||||||
@ -79,8 +85,9 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
setVersion('');
|
setVersion('');
|
||||||
setReleaseNotes('');
|
setReleaseNotes('');
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
alert('Publish failed.');
|
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 {
|
} finally {
|
||||||
setUploading(false);
|
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>
|
<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>
|
</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 ── */}
|
{/* ── Release Track ── */}
|
||||||
<div className="mp-card">
|
<div className="mp-card">
|
||||||
<div className="mp-card-title">
|
<div className="mp-card-title">
|
||||||
@ -250,6 +279,30 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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'}}>
|
<div style={{display:'flex', justifyContent:'flex-end', gap:'0.75rem'}}>
|
||||||
<button
|
<button
|
||||||
className="mp-btn mp-btn-primary"
|
className="mp-btn mp-btn-primary"
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
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 api from '../../../api';
|
||||||
|
import { useAuth } from '../../../App';
|
||||||
import type { PluginData } from './ManagePlugin';
|
import type { PluginData } from './ManagePlugin';
|
||||||
import { validateAndSanitizeImage } from '../../../utils/fileValidation';
|
import { validateAndSanitizeImage } from '../../../utils/fileValidation';
|
||||||
|
|
||||||
@ -137,7 +139,11 @@ const LangPicker = ({ usedCodes, onAdd, onClose }: PickerProps) => {
|
|||||||
type Props = { plugin: PluginData; onSaved: () => void };
|
type Props = { plugin: PluginData; onSaved: () => void };
|
||||||
|
|
||||||
const StoreListing = ({ plugin, onSaved }: Props) => {
|
const StoreListing = ({ plugin, onSaved }: Props) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [name, setName] = useState(plugin.name);
|
const [name, setName] = useState(plugin.name);
|
||||||
const [descriptions, setDescriptions] = useState<Record<string, string>>(() => {
|
const [descriptions, setDescriptions] = useState<Record<string, string>>(() => {
|
||||||
const parsed = JSON.parse(plugin.translated_descriptions || '{}');
|
const parsed = JSON.parse(plugin.translated_descriptions || '{}');
|
||||||
@ -211,6 +217,8 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setSaveSuccess(false);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('name', name);
|
fd.append('name', name);
|
||||||
fd.append('translated_descriptions', JSON.stringify(descriptions));
|
fd.append('translated_descriptions', JSON.stringify(descriptions));
|
||||||
@ -224,9 +232,12 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.put(`/plugins/${plugin.id}`, fd);
|
await api.put(`/plugins/${plugin.id}`, fd);
|
||||||
|
setSaveSuccess(true);
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch {
|
setTimeout(() => setSaveSuccess(false), 3000);
|
||||||
alert('Failed to save.');
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@ -237,8 +248,9 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/plugins/screenshots/${screenId}`);
|
await api.delete(`/plugins/screenshots/${screenId}`);
|
||||||
setScreenshots(prev => prev.filter(s => s.id !== screenId));
|
setScreenshots(prev => prev.filter(s => s.id !== screenId));
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
alert('Failed to delete screenshot.');
|
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>
|
</p>
|
||||||
</div>
|
</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'}}>
|
<div style={{display:'flex', justifyContent:'flex-end'}}>
|
||||||
<button type="submit" className="mp-btn mp-btn-primary" disabled={saving}>
|
<button type="submit" className="mp-btn mp-btn-primary" disabled={saving}>
|
||||||
<Save size={15} />
|
<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