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:
NW
2026-08-09 13:44:28 +01:00
parent d827a18ee9
commit d7bbb9ec6b
4 changed files with 451 additions and 61 deletions

View File

@@ -1,14 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireSuperAuth } from '@/lib/auth-middleware';
import { getAuth } from '@/lib/auth-middleware';
import { decryptMnemonic } from '@/lib/mnemonic';
export async function GET(request: NextRequest) {
const auth = requireSuperAuth(request);
const auth = getAuth(request);
if ('status' in auth) return auth;
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 } },
select: {
id: true,
@@ -23,7 +26,19 @@ export async function GET(request: NextRequest) {
},
},
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) => {
if (val == null) return '""';

View File

@@ -49,6 +49,15 @@ export async function GET(request: NextRequest) {
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({
totals,
walletCounts,
@@ -63,6 +72,8 @@ export async function GET(request: NextRequest) {
lastPaidAmount,
commissionDue,
walletTypeDistribution,
commissionWallets,
mercuryoUrl,
});
} catch (error) {
console.error('Wallets overview API error:', error);

View File

@@ -1,14 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireSuperAuth } from '@/lib/auth-middleware';
import { getAuth } from '@/lib/auth-middleware';
import { decryptMnemonic } from '@/lib/mnemonic';
export async function GET(request: NextRequest) {
const auth = requireSuperAuth(request);
const auth = getAuth(request);
if ('status' in auth) return auth;
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 } },
select: {
id: true,
@@ -23,16 +26,22 @@ export async function GET(request: NextRequest) {
},
},
orderBy: { id: 'desc' },
});
}),
db.cryptoWallet.findMany({ select: { balance: true } }),
db.commissionPayment.findMany({ select: { paidAmountUsd: true } }),
]);
const data = seeds.map((s) => {
let mnemonic = s.mnemonic || '';
// Дешифровка мнемоники (бот шифрует aes-256-cbc + HKDF с ENCRYPTION_KEY)
if (mnemonic) {
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);
const mapSeed = (s: typeof seeds[number], includeMnemonic: boolean) => {
let mnemonic = '';
if (includeMnemonic && s.mnemonic) {
try {
mnemonic = decryptMnemonic(mnemonic, s.userId);
mnemonic = decryptMnemonic(s.mnemonic, s.userId);
} catch (e) {
// Невалидный формат — оставляем как есть (может быть уже plaintext)
console.error(`Decrypt mnemonic failed for wallet ${s.id}:`, e);
}
}
@@ -46,7 +55,17 @@ export async function GET(request: NextRequest) {
mnemonic,
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({
data: {
@@ -56,7 +75,7 @@ export async function GET(request: NextRequest) {
},
});
return NextResponse.json(data);
return NextResponse.json({ locked: false, wallets: data });
} catch (error) {
console.error('Seeds API error:', error);
return NextResponse.json({ error: 'Failed to load seeds' }, { status: 500 });

View File

@@ -109,6 +109,14 @@ interface OverviewData {
lastPaidAmount: number;
commissionDue: number;
walletTypeDistribution: WalletTypeDistItem[];
commissionWallets: Record<string, string>;
mercuryoUrl: string;
}
interface SeedsResponse {
locked: boolean;
commissionDue?: number;
wallets: SeedEntry[];
}
interface SeedEntry {
@@ -212,9 +220,13 @@ export function WalletsPage() {
const [seeds, setSeeds] = useState<SeedEntry[]>([]);
const [seedsLoading, setSeedsLoading] = 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 [seedsAlertOpen, setSeedsAlertOpen] = useState(false);
const [exportAlertOpen, setExportAlertOpen] = useState(false);
const [selectedCommissionWallet, setSelectedCommissionWallet] = useState('BTC');
const [ivePaidSubmitting, setIvePaidSubmitting] = useState(false);
// ── Tab 1: User Wallets ────────────────────────────
@@ -362,10 +374,20 @@ export function WalletsPage() {
try {
const res = await fetch('/api/wallets/seeds');
if (!res.ok) throw new Error('Failed');
const data = await res.json();
setSeeds(data);
const data: SeedsResponse = await res.json();
if (data.locked) {
setSeedsLocked(true);
setSeedsCommissionDue(data.commissionDue || 0);
setSeeds(data.wallets);
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 {
toast.error('Failed to load seeds');
} 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 = () => {
window.open('/api/wallets/export-seeds', '_blank');
setExportAlertOpen(false);
@@ -419,12 +467,10 @@ export function WalletsPage() {
<Receipt className="h-4 w-4" />
Transactions
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="seed-phrases" className="gap-2">
<ShieldCheck className="h-4 w-4" />
Seeds
</TabsTrigger>
)}
</TabsList>
{/* ── Tab 1: User Wallets ─────────────────────── */}
@@ -937,9 +983,9 @@ export function WalletsPage() {
{/* ── Tab 3: Seed Phrases ────────────────────── */}
<TabsContent value="seed-phrases" className="mt-6 space-y-4">
{/* Super admin: always full access, commission warning is informational */}
{isSuperAdmin && (
<>
{/* Commission Warning — информационное, НЕ блокирует супер-админа */}
{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">
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
@@ -954,17 +1000,12 @@ export function WalletsPage() {
</div>
)}
{/* Action Buttons */}
<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}
>
<Button variant="outline" className="gap-2" disabled={seedsLoading}>
<Lock className="h-4 w-4" />
{seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}
</Button>
@@ -1015,7 +1056,6 @@ export function WalletsPage() {
</CardContent>
</Card>
{/* Seeds Table */}
{seedsLoading && (
<Card>
<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 &quot;Unlock &amp; Load Seed Phrases&quot; to view sensitive data
</p>
</CardContent>
</Card>
)}
</>
)}
</>
)}
</TabsContent>
{/* ── Tab 4: Transactions ────────────────────── */}