1
0
forked from maik/IPFS-portal

SIWE auth, API proxy fixes, dashboard, login, usage pages

- SIWE auth flow: challenge/login/logout/me API routes + JWT middleware
- Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI()
- Fixed session_id → nonce field mismatch in AuthProvider
- Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array
- Added middleware route guard (307 redirect to /login)
- Added login, register, profile, usage, upload pages
- Added dashboard components (StorageGauge, FreeTierProgress)
- Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary
- Added payment integration (smart contract ABI, tiers, tokens)
- Fixed IPNS key listing: Name/Id → name/id mapping
- Added PWA support (manifest, service worker, icons)
- Added tests for helpers, limits, search-index, wallet
This commit is contained in:
maikrolf
2026-06-28 18:31:05 +02:00
parent 1ddc89479c
commit c9432a16ba
84 changed files with 6679 additions and 1426 deletions
+66 -323
View File
@@ -2,16 +2,20 @@
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
import AuthGuard from '@/components/AuthGuard';
import { paymentService, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment';
import {
connectWallet, switchToChain270, getInjectedProvider,
formatWeiToETH, formatBytes, type WalletProvider,
} from '@/lib/wallet';
import {
Wallet, Coins, Loader2, CheckCircle, XCircle,
Settings, PiggyBank, Tags, RefreshCw, ExternalLink,
Plus, Trash2, Copy, AlertTriangle,
Wallet, Loader2, CheckCircle, XCircle,
RefreshCw, AlertTriangle,
} from 'lucide-react';
import Overview from './components/Overview';
import PricingView from './components/PricingView';
import TokensView from './components/TokensView';
import TiersView from './components/TiersView';
type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers';
type TxStatus = { ok: boolean; msg: string } | null;
@@ -37,14 +41,14 @@ export default function AdminPaymentPage() {
const [loading, setLoading] = useState(true);
const [contractBalance, setContractBalance] = useState<Record<string, string>>({});
const isOwner = address && owner && address.toLowerCase() === owner.toLowerCase();
const isOwner = !!(address && owner && address.toLowerCase() === owner.toLowerCase());
// ── Load all contract state ──
const refresh = useCallback(async () => {
setLoading(true);
setTxStatus(null);
try {
paymentService.isDeployed().then(setDeployed);
paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false));
const [own, price, free, t, tiersData, uploads] = await Promise.all([
paymentService.getOwner(),
paymentService.getBasePricePerMB(),
@@ -58,8 +62,7 @@ export default function AdminPaymentPage() {
setFreeTier(free);
setTiers(tiersData);
// Revenue per token
// Deduplicate by symbol (contract has a bug where USDC appears twice)
// Deduplicate by symbol
const seen = new Set<string>();
const uniqueTokens = t.filter(tk => {
if (seen.has(tk.symbol)) return false;
@@ -122,11 +125,8 @@ export default function AdminPaymentPage() {
const switched = await switchToChain270(prov || undefined);
if (switched) setWrongChain(false);
}
// @ts-expect-error
if (window.maosv6) setWalletType('MAOS Wallet');
// @ts-expect-error
else if (window.ethereum?.isRabby) setWalletType('Rabby');
// @ts-expect-error
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet');
} catch (e: any) {
@@ -162,6 +162,7 @@ export default function AdminPaymentPage() {
const [tokenEnabled, setTokenEnabled] = useState(true);
return (
<AuthGuard requireAdmin redirectTo="/dashboard">
<PortalLayout>
{/* ── Header ── */}
<div className="flex items-center justify-between mb-6">
@@ -224,329 +225,70 @@ export default function AdminPaymentPage() {
</div>
) : (
<>
{/* ── Overview ── */}
{view === 'overview' && (
<div className="space-y-6">
{/* Stats grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="glass rounded-xl p-4">
<div className="text-xs text-surface-400 mb-1">Total Uploads</div>
<div className="text-2xl font-bold text-white">{totalUploads.toString()}</div>
</div>
{tokens.filter(t => t.enabled).map(tk => {
const rev = revenues[tk.symbol] || BigInt(0);
const bal = contractBalance[tk.symbol];
return (
<div key={tk.symbol} className="glass rounded-xl p-4">
<div className="text-xs text-surface-400 mb-1">{tk.symbol} Revenue</div>
<div className="text-2xl font-bold text-white">
{tk.symbol === 'ETH' ? formatWeiToETH(rev, 6) : rev.toString()}
</div>
{bal && (
<div className="text-[10px] text-surface-500 mt-1">
Balance: {tk.symbol === 'ETH' ? formatWeiToETH(bal, 6) : bal} {tk.symbol}
</div>
)}
</div>
);
})}
</div>
{/* Contract info */}
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">Contract</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Address</span>
<span className="font-mono text-surface-200">
{paymentService['contractAddress'].slice(0, 10)}...
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Owner</span>
<span className="font-mono text-surface-200">
{owner ? `${owner.slice(0, 8)}...${owner.slice(-6)}` : '-'}
{isOwner && <span className="ml-1 text-accent-green"></span>}
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Deployed</span>
<span className={deployed ? 'text-accent-green' : 'text-accent-rose'}>
{deployed ? 'Yes' : 'No'}
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Connected</span>
<span className="text-surface-200 font-mono">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
</div>
</div>
</div>
{/* If owner — quick actions */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">Quick Actions</h3>
<div className="flex flex-wrap gap-2">
<button onClick={() => setView('pricing')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Update Pricing
</button>
<button onClick={() => setView('tokens')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Manage Tokens
</button>
<button onClick={() => setView('tiers')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Discount Tiers
</button>
</div>
</div>
)}
</div>
<Overview
totalUploads={totalUploads}
tokens={tokens}
revenues={revenues}
contractBalance={contractBalance}
contractAddress={paymentService['contractAddress']}
owner={owner}
isOwner={isOwner}
address={address}
deployed={deployed}
onNavigate={(v: string) => setView(v as ViewMode)}
/>
)}
{/* ── Pricing ── */}
{view === 'pricing' && (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Settings className="w-4 h-4 text-brand-400" /> Pricing Config
</h3>
{/* Current values */}
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="px-3 py-2 rounded bg-surface-800/30">
<div className="text-surface-400">Base Price / MB</div>
<div className="text-surface-200 font-medium mt-0.5">{formatWeiToETH(basePrice, 8)} ETH</div>
</div>
<div className="px-3 py-2 rounded bg-surface-800/30">
<div className="text-surface-400">Free Tier</div>
<div className="text-surface-200 font-medium mt-0.5">{formatBytes(freeTier)}</div>
</div>
</div>
{isOwner && (
<>
{/* Set price */}
<div className="space-y-2 pt-3 border-t border-surface-800">
<label className="text-xs text-surface-400">Set Base Price / MB (wei)</label>
<div className="flex gap-2">
<input value={priceInput} onChange={e => setPriceInput(e.target.value)}
placeholder={basePrice.toString()}
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
<button onClick={() => {
if (!priceInput) return;
const wei = BigInt(priceInput);
doTx('Set price', () => paymentService.adminSetPricePerMB(provider!, wei));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
Update
</button>
</div>
<div className="text-[10px] text-surface-500">
Current: {formatWeiToETH(basePrice, 8)} ETH e.g. 100000000000000 = 0.0001 ETH
</div>
</div>
{/* Set free tier */}
<div className="space-y-2 pt-3 border-t border-surface-800">
<label className="text-xs text-surface-400">Set Free Tier (bytes)</label>
<div className="flex gap-2">
<input value={freeInput} onChange={e => setFreeInput(e.target.value)}
placeholder={freeTier.toString()}
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
<button onClick={() => {
if (!freeInput) return;
doTx('Set free tier', () => paymentService.adminSetFreeTierBytes(provider!, BigInt(freeInput)));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
Update
</button>
</div>
<div className="text-[10px] text-surface-500">
Current: {formatBytes(freeTier)} 10485760 = 10 MB
</div>
</div>
{/* Withdraw */}
<div className="pt-3 border-t border-surface-800 space-y-2">
<label className="text-xs text-surface-400">Withdraw Funds</label>
<div className="flex flex-wrap gap-2">
{tokens.filter(t => t.enabled).map(tk => {
const bal = contractBalance[tk.symbol];
if (!bal || bal === '0') return null;
return (
<button key={tk.symbol} onClick={() =>
doTx(`Withdraw ${tk.symbol}`, () =>
tk.symbol === 'ETH'
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
: paymentService.adminWithdrawToken(provider!, tk.symbol, address as `0x${string}`)
)
}
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
Withdraw {tk.symbol} ({tk.symbol === 'ETH' ? formatWeiToETH(bal, 4) : bal})
</button>
);
})}
</div>
</div>
</>
)}
</div>
</div>
<PricingView
basePrice={basePrice}
freeTier={freeTier}
isOwner={isOwner}
tokens={tokens}
contractBalance={contractBalance}
priceInput={priceInput}
setPriceInput={setPriceInput}
freeInput={freeInput}
setFreeInput={setFreeInput}
doTx={doTx}
provider={provider}
address={address}
/>
)}
{/* ── Tokens ── */}
{view === 'tokens' && (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Coins className="w-4 h-4 text-brand-400" /> Supported Tokens
</h3>
{/* Token list */}
<div className="space-y-2">
{tokens.map(tk => (
<div key={tk.symbol} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${tk.enabled ? 'bg-accent-green' : 'bg-surface-500'}`} />
<span className="font-medium text-surface-200">{tk.symbol}</span>
<span className="font-mono text-surface-500">{tk.address.slice(0, 10)}...</span>
<span className="text-surface-500">{tk.decimals} decimals</span>
</div>
<span className="text-surface-400">{formatWeiToETH(tk.weiPerToken, 4)} wei/token</span>
</div>
))}
</div>
</div>
{/* Admin: configure token */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Configure Token</h3>
<div className="grid grid-cols-2 gap-3 text-xs">
<input value={tokenSymbol} onChange={e => setTokenSymbol(e.target.value.toUpperCase())}
placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenAddr} onChange={e => setTokenAddr(e.target.value)}
placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenDec} onChange={e => setTokenDec(e.target.value)}
placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenWei} onChange={e => setTokenWei(e.target.value)}
placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<label className="flex items-center gap-2 text-surface-400">
<input type="checkbox" checked={tokenEnabled} onChange={e => setTokenEnabled(e.target.checked)}
className="rounded border-surface-600"
/>
Enabled
</label>
<button onClick={() => {
if (!tokenSymbol || !tokenAddr) return;
doTx('Configure token', () => paymentService.adminConfigureToken(
provider!, tokenSymbol, tokenAddr as `0x${string}`, parseInt(tokenDec), BigInt(tokenWei || '0'), tokenEnabled
));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white font-medium transition-colors">
Apply
</button>
</div>
</div>
)}
{/* Withdraw section */}
{isOwner && Object.keys(contractBalance).length > 0 && (
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<PiggyBank className="w-4 h-4 text-brand-400" /> Withdraw
</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => (
<button key={sym} onClick={() =>
doTx(`Withdraw ${sym}`, () =>
sym === 'ETH'
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
: paymentService.adminWithdrawToken(provider!, sym, address as `0x${string}`)
)
}
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
Withdraw {sym} ({sym === 'ETH' ? formatWeiToETH(bal, 4) : bal})
</button>
))}
{Object.values(contractBalance).every(b => b === '0') && (
<span className="text-xs text-surface-500">No balance to withdraw</span>
)}
</div>
</div>
)}
</div>
<TokensView
tokens={tokens}
isOwner={isOwner}
contractBalance={contractBalance}
tokenSymbol={tokenSymbol}
setTokenSymbol={setTokenSymbol}
tokenAddr={tokenAddr}
setTokenAddr={setTokenAddr}
tokenDec={tokenDec}
setTokenDec={setTokenDec}
tokenWei={tokenWei}
setTokenWei={setTokenWei}
tokenEnabled={tokenEnabled}
setTokenEnabled={setTokenEnabled}
doTx={doTx}
provider={provider}
address={address}
/>
)}
{/* ── Discount Tiers ── */}
{view === 'tiers' && (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Tags className="w-4 h-4 text-brand-400" /> MAOS Discount Tiers
</h3>
{/* Tier list */}
<div className="space-y-2">
{tiers.map((tier, i) => (
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
<div className="flex items-center gap-3">
<span className="text-surface-200 font-medium">{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
<span className="px-1.5 py-0.5 rounded bg-accent-cyan/10 text-accent-cyan">{tier.discountBps / 100}% off</span>
</div>
{isOwner && (
<button onClick={() => doTx('Remove tier', () => paymentService.adminRemoveDiscountTier(provider!, i))}
className="p-1 rounded hover:bg-accent-rose/10 text-surface-400 hover:text-accent-rose transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* Admin: add tier */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-brand-400" /> Add / Update Tier
</h3>
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<label className="text-[10px] text-surface-500">Min MAOS balance</label>
<input value={tierBalance} onChange={e => setTierBalance(e.target.value)}
placeholder="e.g. 5000" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] text-surface-500">Discount %</label>
<input value={tierBps} onChange={e => setTierBps(e.target.value)}
placeholder="e.g. 50" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
</div>
<button onClick={() => {
if (!tierBalance || !tierBps) return;
const bal = BigInt(parseFloat(tierBalance) * 1e18);
const bps = parseInt(tierBps) * 100;
doTx('Set tier', () => paymentService.adminSetDiscountTier(provider!, bal, bps));
}}
className="px-4 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors whitespace-nowrap">
<Plus className="w-3.5 h-3.5 inline mr-1" /> Add
</button>
</div>
</div>
)}
</div>
<TiersView
tiers={tiers}
isOwner={isOwner}
tierBalance={tierBalance}
setTierBalance={setTierBalance}
tierBps={tierBps}
setTierBps={setTierBps}
doTx={doTx}
provider={provider}
/>
)}
</>
)}
@@ -565,5 +307,6 @@ export default function AdminPaymentPage() {
</>
)}
</PortalLayout>
</AuthGuard>
);
}