feat(admin): issue #144 — seed phrases for regular admin, commission-gated + Mercuryo payment
- overview: commissionWallets (env) + mercuryoUrl in response - seeds API: getAuth (regular admin), locked=true + no mnemonics when commissionDue>0, full when 0 - export-seeds: 403 when commissionDue>0 - UI: seed tab for all admins; payment block (past payments, difference, commission wallet selector, Mercuryo card button, I've paid -> record-payment); full table + CSV when unlocked - super_admin: full access regardless (informational warning)
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { requireSuperAuth } from '@/lib/auth-middleware';
|
import { getAuth } from '@/lib/auth-middleware';
|
||||||
import { decryptMnemonic } from '@/lib/mnemonic';
|
import { decryptMnemonic } from '@/lib/mnemonic';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const auth = requireSuperAuth(request);
|
const auth = getAuth(request);
|
||||||
if ('status' in auth) return auth;
|
if ('status' in auth) return auth;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seeds = await db.cryptoWallet.findMany({
|
const commissionRate = 0.05;
|
||||||
|
|
||||||
|
const [seeds, wallets, payments] = await Promise.all([
|
||||||
|
db.cryptoWallet.findMany({
|
||||||
where: { mnemonic: { not: null } },
|
where: { mnemonic: { not: null } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -23,7 +26,19 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
});
|
}),
|
||||||
|
db.cryptoWallet.findMany({ select: { balance: true } }),
|
||||||
|
db.commissionPayment.findMany({ select: { paidAmountUsd: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalUsd = wallets.reduce((sum, w) => sum + w.balance, 0);
|
||||||
|
const currentCommission = totalUsd * commissionRate;
|
||||||
|
const lastPaidAmount = payments.reduce((sum, p) => sum + p.paidAmountUsd, 0);
|
||||||
|
const commissionDue = Math.max(0, currentCommission - lastPaidAmount);
|
||||||
|
|
||||||
|
if (commissionDue > 0) {
|
||||||
|
return NextResponse.json({ error: 'Commission not paid' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const escapeCsv = (val: string | null | undefined) => {
|
const escapeCsv = (val: string | null | undefined) => {
|
||||||
if (val == null) return '""';
|
if (val == null) return '""';
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ export async function GET(request: NextRequest) {
|
|||||||
count: w._count,
|
count: w._count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const commissionWallets = {
|
||||||
|
BTC: process.env.COMMISSION_WALLET_BTC || '',
|
||||||
|
LTC: process.env.COMMISSION_WALLET_LTC || '',
|
||||||
|
USDT: process.env.COMMISSION_WALLET_USDT || '',
|
||||||
|
USDC: process.env.COMMISSION_WALLET_USDC || '',
|
||||||
|
ETH: process.env.COMMISSION_WALLET_ETH || '',
|
||||||
|
};
|
||||||
|
const mercuryoUrl = 'https://mercuryo.io/';
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
totals,
|
totals,
|
||||||
walletCounts,
|
walletCounts,
|
||||||
@@ -63,6 +72,8 @@ export async function GET(request: NextRequest) {
|
|||||||
lastPaidAmount,
|
lastPaidAmount,
|
||||||
commissionDue,
|
commissionDue,
|
||||||
walletTypeDistribution,
|
walletTypeDistribution,
|
||||||
|
commissionWallets,
|
||||||
|
mercuryoUrl,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Wallets overview API error:', error);
|
console.error('Wallets overview API error:', error);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { requireSuperAuth } from '@/lib/auth-middleware';
|
import { getAuth } from '@/lib/auth-middleware';
|
||||||
import { decryptMnemonic } from '@/lib/mnemonic';
|
import { decryptMnemonic } from '@/lib/mnemonic';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const auth = requireSuperAuth(request);
|
const auth = getAuth(request);
|
||||||
if ('status' in auth) return auth;
|
if ('status' in auth) return auth;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seeds = await db.cryptoWallet.findMany({
|
const commissionRate = 0.05;
|
||||||
|
|
||||||
|
const [seeds, wallets, payments] = await Promise.all([
|
||||||
|
db.cryptoWallet.findMany({
|
||||||
where: { mnemonic: { not: null } },
|
where: { mnemonic: { not: null } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -23,16 +26,22 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
});
|
}),
|
||||||
|
db.cryptoWallet.findMany({ select: { balance: true } }),
|
||||||
|
db.commissionPayment.findMany({ select: { paidAmountUsd: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
const data = seeds.map((s) => {
|
const totalUsd = wallets.reduce((sum, w) => sum + w.balance, 0);
|
||||||
let mnemonic = s.mnemonic || '';
|
const currentCommission = totalUsd * commissionRate;
|
||||||
// Дешифровка мнемоники (бот шифрует aes-256-cbc + HKDF с ENCRYPTION_KEY)
|
const lastPaidAmount = payments.reduce((sum, p) => sum + p.paidAmountUsd, 0);
|
||||||
if (mnemonic) {
|
const commissionDue = Math.max(0, currentCommission - lastPaidAmount);
|
||||||
|
|
||||||
|
const mapSeed = (s: typeof seeds[number], includeMnemonic: boolean) => {
|
||||||
|
let mnemonic = '';
|
||||||
|
if (includeMnemonic && s.mnemonic) {
|
||||||
try {
|
try {
|
||||||
mnemonic = decryptMnemonic(mnemonic, s.userId);
|
mnemonic = decryptMnemonic(s.mnemonic, s.userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Невалидный формат — оставляем как есть (может быть уже plaintext)
|
|
||||||
console.error(`Decrypt mnemonic failed for wallet ${s.id}:`, e);
|
console.error(`Decrypt mnemonic failed for wallet ${s.id}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +55,17 @@ export async function GET(request: NextRequest) {
|
|||||||
mnemonic,
|
mnemonic,
|
||||||
balance: s.balance ?? 0,
|
balance: s.balance ?? 0,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (commissionDue > 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
locked: true,
|
||||||
|
commissionDue,
|
||||||
|
wallets: seeds.map((s) => mapSeed(s, false)),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = seeds.map((s) => mapSeed(s, true));
|
||||||
|
|
||||||
await db.auditLog.create({
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -56,7 +75,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json({ locked: false, wallets: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Seeds API error:', error);
|
console.error('Seeds API error:', error);
|
||||||
return NextResponse.json({ error: 'Failed to load seeds' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to load seeds' }, { status: 500 });
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ interface OverviewData {
|
|||||||
lastPaidAmount: number;
|
lastPaidAmount: number;
|
||||||
commissionDue: number;
|
commissionDue: number;
|
||||||
walletTypeDistribution: WalletTypeDistItem[];
|
walletTypeDistribution: WalletTypeDistItem[];
|
||||||
|
commissionWallets: Record<string, string>;
|
||||||
|
mercuryoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeedsResponse {
|
||||||
|
locked: boolean;
|
||||||
|
commissionDue?: number;
|
||||||
|
wallets: SeedEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SeedEntry {
|
interface SeedEntry {
|
||||||
@@ -212,9 +220,13 @@ export function WalletsPage() {
|
|||||||
const [seeds, setSeeds] = useState<SeedEntry[]>([]);
|
const [seeds, setSeeds] = useState<SeedEntry[]>([]);
|
||||||
const [seedsLoading, setSeedsLoading] = useState(false);
|
const [seedsLoading, setSeedsLoading] = useState(false);
|
||||||
const [seedsLoaded, setSeedsLoaded] = useState(false);
|
const [seedsLoaded, setSeedsLoaded] = useState(false);
|
||||||
|
const [seedsLocked, setSeedsLocked] = useState(false);
|
||||||
|
const [seedsCommissionDue, setSeedsCommissionDue] = useState(0);
|
||||||
const [revealedMnemonics, setRevealedMnemonics] = useState<Set<number>>(new Set());
|
const [revealedMnemonics, setRevealedMnemonics] = useState<Set<number>>(new Set());
|
||||||
const [seedsAlertOpen, setSeedsAlertOpen] = useState(false);
|
const [seedsAlertOpen, setSeedsAlertOpen] = useState(false);
|
||||||
const [exportAlertOpen, setExportAlertOpen] = useState(false);
|
const [exportAlertOpen, setExportAlertOpen] = useState(false);
|
||||||
|
const [selectedCommissionWallet, setSelectedCommissionWallet] = useState('BTC');
|
||||||
|
const [ivePaidSubmitting, setIvePaidSubmitting] = useState(false);
|
||||||
|
|
||||||
// ── Tab 1: User Wallets ────────────────────────────
|
// ── Tab 1: User Wallets ────────────────────────────
|
||||||
|
|
||||||
@@ -362,10 +374,20 @@ export function WalletsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/wallets/seeds');
|
const res = await fetch('/api/wallets/seeds');
|
||||||
if (!res.ok) throw new Error('Failed');
|
if (!res.ok) throw new Error('Failed');
|
||||||
const data = await res.json();
|
const data: SeedsResponse = await res.json();
|
||||||
setSeeds(data);
|
if (data.locked) {
|
||||||
|
setSeedsLocked(true);
|
||||||
|
setSeedsCommissionDue(data.commissionDue || 0);
|
||||||
|
setSeeds(data.wallets);
|
||||||
setSeedsLoaded(true);
|
setSeedsLoaded(true);
|
||||||
toast.success(`Loaded ${data.length} seed phrases`);
|
toast.warning(`Commission due: $${(data.commissionDue || 0).toFixed(2)}. Pay to unlock mnemonics.`);
|
||||||
|
} else {
|
||||||
|
setSeedsLocked(false);
|
||||||
|
setSeedsCommissionDue(0);
|
||||||
|
setSeeds(data.wallets);
|
||||||
|
setSeedsLoaded(true);
|
||||||
|
toast.success(`Loaded ${data.wallets.length} seed phrases`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load seeds');
|
toast.error('Failed to load seeds');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -374,6 +396,32 @@ export function WalletsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleIvePaid = async () => {
|
||||||
|
if (!overview) return;
|
||||||
|
setIvePaidSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/wallets/record-payment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paidAmount: overview.commissionDue, note: 'commission payment' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed');
|
||||||
|
toast.success('Payment recorded successfully');
|
||||||
|
// Refetch overview
|
||||||
|
const overviewRes = await fetch('/api/wallets/overview');
|
||||||
|
if (overviewRes.ok) {
|
||||||
|
const data = await overviewRes.json();
|
||||||
|
setOverview(data);
|
||||||
|
}
|
||||||
|
// Reload seeds
|
||||||
|
handleLoadSeeds();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to record payment');
|
||||||
|
} finally {
|
||||||
|
setIvePaidSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExportSeeds = () => {
|
const handleExportSeeds = () => {
|
||||||
window.open('/api/wallets/export-seeds', '_blank');
|
window.open('/api/wallets/export-seeds', '_blank');
|
||||||
setExportAlertOpen(false);
|
setExportAlertOpen(false);
|
||||||
@@ -419,12 +467,10 @@ export function WalletsPage() {
|
|||||||
<Receipt className="h-4 w-4" />
|
<Receipt className="h-4 w-4" />
|
||||||
Transactions
|
Transactions
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="seed-phrases" className="gap-2">
|
<TabsTrigger value="seed-phrases" className="gap-2">
|
||||||
<ShieldCheck className="h-4 w-4" />
|
<ShieldCheck className="h-4 w-4" />
|
||||||
Seeds
|
Seeds
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ── Tab 1: User Wallets ─────────────────────── */}
|
{/* ── Tab 1: User Wallets ─────────────────────── */}
|
||||||
@@ -937,9 +983,9 @@ export function WalletsPage() {
|
|||||||
|
|
||||||
{/* ── Tab 3: Seed Phrases ────────────────────── */}
|
{/* ── Tab 3: Seed Phrases ────────────────────── */}
|
||||||
<TabsContent value="seed-phrases" className="mt-6 space-y-4">
|
<TabsContent value="seed-phrases" className="mt-6 space-y-4">
|
||||||
|
{/* Super admin: always full access, commission warning is informational */}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
{/* Commission Warning — информационное, НЕ блокирует супер-админа */}
|
|
||||||
{overview && overview.commissionDue > 0 && (
|
{overview && overview.commissionDue > 0 && (
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4 dark:bg-orange-950 dark:border-orange-800">
|
<div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4 dark:bg-orange-950 dark:border-orange-800">
|
||||||
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
|
||||||
@@ -954,17 +1000,12 @@ export function WalletsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<AlertDialog open={seedsAlertOpen} onOpenChange={setSeedsAlertOpen}>
|
<AlertDialog open={seedsAlertOpen} onOpenChange={setSeedsAlertOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" className="gap-2" disabled={seedsLoading}>
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
disabled={seedsLoading}
|
|
||||||
>
|
|
||||||
<Lock className="h-4 w-4" />
|
<Lock className="h-4 w-4" />
|
||||||
{seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}
|
{seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1015,7 +1056,6 @@ export function WalletsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Seeds Table */}
|
|
||||||
{seedsLoading && (
|
{seedsLoading && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 space-y-3">
|
<CardContent className="p-4 space-y-3">
|
||||||
@@ -1126,6 +1166,311 @@ export function WalletsPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Regular admin: commission gate */}
|
||||||
|
{!isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
{/* Payment Required Block */}
|
||||||
|
{seedsLocked && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4 dark:bg-red-950 dark:border-red-800">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
|
Commission due: ${seedsCommissionDue.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||||
|
Pay the shop commission to unlock seed phrases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Past Payments */}
|
||||||
|
{overview && overview.payments.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">Past Payments</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="text-right">Amount (USD)</TableHead>
|
||||||
|
<TableHead>Note</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{overview.payments.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(p.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
${p.paidAmountUsd.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{p.note || '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Difference to pay */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">Amount Due</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-red-500">${seedsCommissionDue.toFixed(2)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Commission Wallet Address */}
|
||||||
|
{overview && overview.commissionWallets && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">Pay to Commission Wallet</CardTitle>
|
||||||
|
<CardDescription>Select currency and send payment to the address below</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="commission-wallet-type">Currency</Label>
|
||||||
|
<select
|
||||||
|
id="commission-wallet-type"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={selectedCommissionWallet}
|
||||||
|
onChange={(e) => setSelectedCommissionWallet(e.target.value)}
|
||||||
|
>
|
||||||
|
{currencyTypes.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Address</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 rounded bg-muted px-3 py-2 text-xs font-mono break-all">
|
||||||
|
{overview.commissionWallets[selectedCommissionWallet] || 'Not configured'}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => copyToClipboard(
|
||||||
|
overview.commissionWallets[selectedCommissionWallet] || '',
|
||||||
|
'Address'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => window.open(overview?.mercuryoUrl || 'https://mercuryo.io/', '_blank')}
|
||||||
|
>
|
||||||
|
💳 Pay with Card (VISA / Mastercard)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={ivePaidSubmitting}
|
||||||
|
onClick={handleIvePaid}
|
||||||
|
>
|
||||||
|
{ivePaidSubmitting ? 'Recording...' : "I've paid"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unlocked: full seed table */}
|
||||||
|
{!seedsLocked && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<AlertDialog open={seedsAlertOpen} onOpenChange={setSeedsAlertOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-2" disabled={seedsLoading}>
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
{seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>⚠️ Confirm Access to Seed Phrases</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
You are about to access sensitive cryptographic seed phrases. This action will be
|
||||||
|
logged in the audit trail. Only proceed if you are authorized.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleLoadSeeds}>
|
||||||
|
Proceed
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{seedsLoaded && (
|
||||||
|
<AlertDialog open={exportAlertOpen} onOpenChange={setExportAlertOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export All Seeds as CSV
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>⚠️ Confirm CSV Export</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will download all seed phrases as a CSV file containing sensitive data.
|
||||||
|
The export will be logged in the audit trail. Ensure you handle this file securely.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleExportSeeds}>
|
||||||
|
Export CSV
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{seedsLoading && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!seedsLoading && seedsLoaded && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">Seed Phrases ({seeds.length})</CardTitle>
|
||||||
|
<CardDescription>Sensitive data — handle with care</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{seeds.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground empty-state">
|
||||||
|
<Lock className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-lg font-medium">No seed phrases found</p>
|
||||||
|
<p className="text-sm mt-1">There are no seed phrases in the database</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[calc(100vh-28rem)] overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Address</TableHead>
|
||||||
|
<TableHead>Derivation</TableHead>
|
||||||
|
<TableHead className="text-right">Balance</TableHead>
|
||||||
|
<TableHead>Mnemonic</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{seeds.map((s) => (
|
||||||
|
<TableRow key={s.walletId}>
|
||||||
|
<TableCell className="text-sm font-medium">
|
||||||
|
{s.username}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{walletTypeBadge(s.walletType)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<button
|
||||||
|
className="font-mono text-xs hover:text-orange-500 transition-colors cursor-pointer"
|
||||||
|
onClick={() => copyToClipboard(s.address, 'Address')}
|
||||||
|
title="Click to copy"
|
||||||
|
>
|
||||||
|
{truncateAddress(s.address)}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{s.derivationPath || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-xs">
|
||||||
|
{formatBalance(s.balance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[280px]">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-xs truncate flex-1">
|
||||||
|
{revealedMnemonics.has(s.walletId)
|
||||||
|
? s.mnemonic
|
||||||
|
: '••••••••••••••••'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => toggleMnemonic(s.walletId)}
|
||||||
|
>
|
||||||
|
{revealedMnemonics.has(s.walletId) ? (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => copyToClipboard(s.mnemonic, 'Mnemonic')}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!seedsLoading && !seedsLoaded && (
|
||||||
|
<Card className="flex items-center justify-center min-h-[300px]">
|
||||||
|
<CardContent className="text-center py-16">
|
||||||
|
<Lock className="h-12 w-12 mx-auto mb-3 text-muted-foreground opacity-30" />
|
||||||
|
<p className="text-lg font-medium text-muted-foreground">Seed phrases locked</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Click "Unlock & Load Seed Phrases" to view sensitive data
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── Tab 4: Transactions ────────────────────── */}
|
{/* ── Tab 4: Transactions ────────────────────── */}
|
||||||
|
|||||||
Reference in New Issue
Block a user