276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { listUsers, createUser, deleteUser } from '@/lib/api';
|
||
|
|
import PortalLayout from '@/app/layout-portal';
|
||
|
|
import { paymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
|
||
|
|
import { formatWeiToETH, formatBytes } from '@/lib/wallet';
|
||
|
|
import {
|
||
|
|
Users, Plus, Trash2, UserPlus, Search, Wallet,
|
||
|
|
HardDrive, Upload, Clock, ExternalLink, Copy,
|
||
|
|
CheckCircle, Loader2, XCircle,
|
||
|
|
} from 'lucide-react';
|
||
|
|
|
||
|
|
export default function UsersPage() {
|
||
|
|
// ── htpasswd users ──
|
||
|
|
const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [showAdd, setShowAdd] = useState(false);
|
||
|
|
const [newUser, setNewUser] = useState('');
|
||
|
|
const [newPass, setNewPass] = useState('');
|
||
|
|
|
||
|
|
// ── Wallet lookup ──
|
||
|
|
const [walletAddr, setWalletAddr] = useState('');
|
||
|
|
const [walletStats, setWalletStats] = useState<UserFullStats | null>(null);
|
||
|
|
const [walletLoading, setWalletLoading] = useState(false);
|
||
|
|
const [walletError, setWalletError] = useState<string | null>(null);
|
||
|
|
const [copiedCid, setCopiedCid] = useState<string | null>(null);
|
||
|
|
|
||
|
|
async function load() {
|
||
|
|
try {
|
||
|
|
const items = await listUsers();
|
||
|
|
setUsers(items);
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
finally { setLoading(false); }
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => { load(); }, []);
|
||
|
|
|
||
|
|
async function handleCreate() {
|
||
|
|
if (!newUser || !newPass) return;
|
||
|
|
try {
|
||
|
|
await createUser(newUser, newPass);
|
||
|
|
setNewUser('');
|
||
|
|
setNewPass('');
|
||
|
|
setShowAdd(false);
|
||
|
|
await load();
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleDelete(username: string) {
|
||
|
|
try {
|
||
|
|
await deleteUser(username);
|
||
|
|
setUsers((u) => u.filter((x) => x.username !== username));
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Wallet lookup ──
|
||
|
|
async function lookupWallet() {
|
||
|
|
const addr = walletAddr.trim();
|
||
|
|
if (!addr || !addr.startsWith('0x') || addr.length !== 42) {
|
||
|
|
setWalletError('Invalid address (must be 0x... 42 chars)');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setWalletLoading(true);
|
||
|
|
setWalletError(null);
|
||
|
|
setWalletStats(null);
|
||
|
|
try {
|
||
|
|
const stats = await paymentService.getUserUploads(addr as `0x${string}`);
|
||
|
|
setWalletStats(stats);
|
||
|
|
} catch (e: any) {
|
||
|
|
setWalletError(e.message || 'Failed to fetch wallet stats');
|
||
|
|
} finally {
|
||
|
|
setWalletLoading(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function copyCid(cid: string) {
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(cid);
|
||
|
|
setCopiedCid(cid);
|
||
|
|
setTimeout(() => setCopiedCid(null), 2000);
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTimestamp(ts: bigint): string {
|
||
|
|
const d = new Date(Number(ts) * 1000);
|
||
|
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<PortalLayout>
|
||
|
|
{/* ── Header with count badge ── */}
|
||
|
|
<div className="flex items-center justify-between mb-6">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-bold text-white">Users</h1>
|
||
|
|
<p className="text-sm text-surface-400 mt-1">{users.length} IPFS users</p>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
onClick={() => setShowAdd(!showAdd)}
|
||
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors"
|
||
|
|
>
|
||
|
|
<UserPlus className="w-4 h-4" />
|
||
|
|
Add User
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ── Add user form ── */}
|
||
|
|
{showAdd && (
|
||
|
|
<div className="glass rounded-xl p-4 mb-6 animate-fade-in">
|
||
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
||
|
|
<input
|
||
|
|
value={newUser} onChange={(e) => setNewUser(e.target.value)}
|
||
|
|
placeholder="Username"
|
||
|
|
className="flex-1 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
value={newPass} onChange={(e) => setNewPass(e.target.value)}
|
||
|
|
type="password" placeholder="Password"
|
||
|
|
className="flex-1 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<button onClick={handleCreate} disabled={!newUser || !newPass}
|
||
|
|
className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors disabled:opacity-50">
|
||
|
|
Create
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── Two-column layout ── */}
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||
|
|
{/* Left: htpasswd user list */}
|
||
|
|
<div className="lg:col-span-3">
|
||
|
|
<div className="glass rounded-xl overflow-hidden">
|
||
|
|
{loading ? (
|
||
|
|
<div className="p-8 text-center text-sm text-surface-500">Loading…</div>
|
||
|
|
) : users.length === 0 ? (
|
||
|
|
<div className="p-8 text-center text-sm text-surface-500">No users configured</div>
|
||
|
|
) : (
|
||
|
|
<div className="divide-y divide-surface-800">
|
||
|
|
{users.map((u) => (
|
||
|
|
<div key={u.username} className="flex items-center justify-between px-5 py-3 hover:bg-surface-800/30 transition-colors">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div className="p-1.5 rounded-lg bg-accent-purple/10">
|
||
|
|
<Users className="w-3.5 h-3.5 text-accent-purple" />
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<div className="text-sm text-surface-200">{u.username}</div>
|
||
|
|
<div className="text-xs text-surface-500 mt-0.5">Created {u.created}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
{u.active && <span className="status-dot status-dot--online" />}
|
||
|
|
<button onClick={() => handleDelete(u.username)}
|
||
|
|
className="p-1.5 rounded-lg hover:bg-accent-rose/10 transition-colors">
|
||
|
|
<Trash2 className="w-3.5 h-3.5 text-surface-500 hover:text-accent-rose" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Right: Wallet lookup */}
|
||
|
|
<div className="lg:col-span-2 space-y-4">
|
||
|
|
<div className="glass rounded-xl p-4 space-y-3">
|
||
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
|
|
<Wallet className="w-4 h-4 text-brand-400" /> Wallet Lookup
|
||
|
|
</h3>
|
||
|
|
<p className="text-[10px] text-surface-500">Enter a wallet address to see on-chain upload stats</p>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<input value={walletAddr} onChange={e => setWalletAddr(e.target.value)}
|
||
|
|
onKeyDown={e => e.key === 'Enter' && lookupWallet()}
|
||
|
|
placeholder="0x..." className="flex-1 px-3 py-1.5 rounded-lg bg-surface-900 border border-surface-700 text-xs font-mono text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<button onClick={lookupWallet} disabled={walletLoading}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-500 disabled:opacity-50 text-white text-xs transition-colors">
|
||
|
|
{walletLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{walletError && (
|
||
|
|
<div className="flex items-center gap-1.5 text-xs text-accent-rose">
|
||
|
|
<XCircle className="w-3 h-3 shrink-0" /> {walletError}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ── Wallet stats ── */}
|
||
|
|
{walletStats && (
|
||
|
|
<>
|
||
|
|
{/* Stats cards */}
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div className="glass rounded-xl p-3">
|
||
|
|
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
|
||
|
|
<HardDrive className="w-3 h-3" /> Total Bytes
|
||
|
|
</div>
|
||
|
|
<div className="text-lg font-bold text-white">{formatBytes(walletStats.totalBytes)}</div>
|
||
|
|
</div>
|
||
|
|
<div className="glass rounded-xl p-3">
|
||
|
|
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
|
||
|
|
<Upload className="w-3 h-3" /> Uploads
|
||
|
|
</div>
|
||
|
|
<div className="text-lg font-bold text-white">{walletStats.uploadCount}</div>
|
||
|
|
</div>
|
||
|
|
<div className="glass rounded-xl p-3">
|
||
|
|
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
|
||
|
|
<Clock className="w-3 h-3" /> Free Remaining
|
||
|
|
</div>
|
||
|
|
<div className="text-lg font-bold text-accent-cyan">{formatBytes(walletStats.remainingFree)}</div>
|
||
|
|
</div>
|
||
|
|
<div className="glass rounded-xl p-3">
|
||
|
|
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
|
||
|
|
<Wallet className="w-3 h-3" /> Address
|
||
|
|
</div>
|
||
|
|
<div className="text-xs font-mono text-surface-300 break-all">
|
||
|
|
{walletAddr.slice(0, 6)}...{walletAddr.slice(-4)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ── Upload history ── */}
|
||
|
|
{walletStats.uploads.length > 0 && (
|
||
|
|
<div className="glass rounded-xl overflow-hidden">
|
||
|
|
<div className="px-4 py-3 border-b border-surface-800">
|
||
|
|
<h4 className="text-xs font-semibold text-surface-300">Upload History ({walletStats.uploads.length})</h4>
|
||
|
|
</div>
|
||
|
|
<div className="divide-y divide-surface-800 max-h-80 overflow-y-auto">
|
||
|
|
{[...walletStats.uploads].reverse().map((rec, i) => (
|
||
|
|
<div key={i} className="px-4 py-2.5 space-y-1 hover:bg-surface-800/30 transition-colors">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-1.5 text-xs">
|
||
|
|
<span className={`px-1 py-0.5 rounded text-[10px] font-medium ${
|
||
|
|
rec.tokenSymbol === 'FREE' ? 'bg-accent-green/10 text-accent-green' :
|
||
|
|
rec.tokenSymbol === 'ETH' ? 'bg-brand-500/10 text-brand-400' :
|
||
|
|
'bg-accent-cyan/10 text-accent-cyan'
|
||
|
|
}`}>{rec.tokenSymbol}</span>
|
||
|
|
<span className="text-surface-400">{formatBytes(rec.bytesSize)}</span>
|
||
|
|
</div>
|
||
|
|
<div className="text-[10px] text-surface-500">{formatTimestamp(rec.timestamp)}</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<span className="text-[10px] font-mono text-surface-500 truncate max-w-[120px]">{rec.cid}</span>
|
||
|
|
<button onClick={() => copyCid(rec.cid)}
|
||
|
|
className="p-0.5 rounded hover:bg-surface-700 transition-colors">
|
||
|
|
{copiedCid === rec.cid
|
||
|
|
? <CheckCircle className="w-3 h-3 text-accent-green" />
|
||
|
|
: <Copy className="w-3 h-3 text-surface-500" />}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{rec.amountPaid > BigInt(0) && (
|
||
|
|
<span className="text-[10px] text-surface-400">
|
||
|
|
{rec.tokenSymbol === 'ETH' ? formatWeiToETH(rec.amountPaid, 6) : rec.amountPaid.toString()} {rec.tokenSymbol}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{walletStats.uploads.length === 0 && (
|
||
|
|
<div className="glass rounded-xl p-4 text-center">
|
||
|
|
<p className="text-xs text-surface-500">No uploads found for this address</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</PortalLayout>
|
||
|
|
);
|
||
|
|
}
|