feat: SIWE auth, auto-pin, pin search/share, explorer improvements
- SIWE server-side auth (siwe + jose JWT) - Wallet provider fallback (MAOS -> MetaMask) - Auto-pin after upload (quick + crypto) - Pin search/filter in explorer - Share button (gateway link) per pin - Copy CID button per pin - Breadcrumb root navigation fix - Hydration mismatch fix (mounted state) - Defensive truncateCid for empty values - CSP fix (template literal) - Error boundaries (upload, profile, usage) - 191 tests passing
This commit is contained in:
@@ -1,14 +1,27 @@
|
||||
/* ── POST /api/auth/challenge ──
|
||||
*
|
||||
* Proxied naar Python User API /auth/challenge.
|
||||
* Geeft nonce + message terug voor SIWE signing.
|
||||
* Genereert SIWE challenge volledig server-side.
|
||||
* Geen Python backend nodig — nonce + message worden hier aangemaakt.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { SiweMessage } from 'siwe';
|
||||
import { getAddress } from 'viem';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// In-memory nonce store (in productie: gebruik Redis of database)
|
||||
const nonceStore = new Map<string, { nonce: string; message: string; expires: number }>();
|
||||
|
||||
// Cleanup oude nonces elke 5 minuten
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of nonceStore) {
|
||||
if (value.expires < now) nonceStore.delete(key);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
@@ -18,15 +31,38 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Address required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${userApiBase()}/auth/challenge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address: address.toLowerCase() }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
// Genereer cryptografisch veilige nonce
|
||||
const nonce = randomBytes(16).toString('hex');
|
||||
|
||||
// Gebruik het juiste domein — in productie: zksync.maos.dedyn.io
|
||||
const domain = process.env.NEXT_PUBLIC_SITE_DOMAIN ||
|
||||
req.headers.get('host') ||
|
||||
'localhost:3445';
|
||||
|
||||
// Checksum het adres (EIP-55) — SIWE vereist dit
|
||||
const checksummedAddress = getAddress(address);
|
||||
|
||||
// Maak SIWE message met de officiële siwe library
|
||||
const siweMessage = new SiweMessage({
|
||||
domain,
|
||||
address: checksummedAddress,
|
||||
statement: 'Sign in to IPFS Portal',
|
||||
uri: `https://${domain}`,
|
||||
version: '1',
|
||||
chainId: 270,
|
||||
nonce,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
const message = siweMessage.prepareMessage();
|
||||
|
||||
// Sla nonce + volledig message op (10 minuten geldig)
|
||||
nonceStore.set(address.toLowerCase(), {
|
||||
nonce,
|
||||
message,
|
||||
expires: Date.now() + 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
return NextResponse.json({ message, nonce });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json(
|
||||
@@ -35,3 +71,22 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verifieer nonce en return het originele message — wordt gebruikt door login route */
|
||||
export function verifyNonce(address: string, nonce: string): { valid: boolean; message?: string } {
|
||||
const stored = nonceStore.get(address.toLowerCase());
|
||||
if (!stored) return { valid: false };
|
||||
if (stored.expires < Date.now()) {
|
||||
nonceStore.delete(address.toLowerCase());
|
||||
return { valid: false };
|
||||
}
|
||||
if (stored.nonce !== nonce) return { valid: false };
|
||||
|
||||
// Haal het originele bericht op
|
||||
const message = stored.message;
|
||||
|
||||
// Nonce is eenmalig — verwijder na gebruik
|
||||
nonceStore.delete(address.toLowerCase());
|
||||
|
||||
return { valid: true, message };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/* ── POST /api/auth/login ──
|
||||
*
|
||||
* SIWE wallet login: verifieert signed message via Python backend,
|
||||
* maakt vervolgens JWT aan met session_token voor Next.js middleware.
|
||||
* SIWE wallet login: verifieert signed message volledig server-side.
|
||||
* Geen Python backend nodig — siwe + viem worden gebruikt voor verificatie.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createSessionJWT, cookieOptions } from '@/lib/auth-server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
import { SiweMessage } from 'siwe';
|
||||
import { verifyNonce } from '../challenge/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -22,53 +23,56 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Proxy login to Python User API
|
||||
const res = await fetch(`${userApiBase()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
address: address.toLowerCase(),
|
||||
signature,
|
||||
nonce,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
// 1. Verifieer nonce en haal origineel message op
|
||||
const nonceResult = verifyNonce(address, nonce);
|
||||
if (!nonceResult.valid || !nonceResult.message) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ongeldige of verlopen nonce. Vraag een nieuwe challenge aan.' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Python backend returns { token, address, message, expires_in }
|
||||
const pythonSessionToken: string = data.token;
|
||||
const userAddress: string = data.address;
|
||||
|
||||
// Determine role — Python backend returns role info via /auth/verify
|
||||
let role: 'admin' | 'user' = 'user';
|
||||
// 2. Parse het SIWE message en verifieer handtekening
|
||||
try {
|
||||
const verifyRes = await fetch(`${userApiBase()}/auth/verify`, {
|
||||
headers: { 'X-Session-Token': pythonSessionToken },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (verifyRes.ok) {
|
||||
const verifyData = await verifyRes.json();
|
||||
role = verifyData.role === 'admin' ? 'admin' : 'user';
|
||||
const siweMessage = new SiweMessage(nonceResult.message);
|
||||
const result = await siweMessage.verify({ signature });
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Handtekening verificatie mislukt' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Controleer of het adres overeenkomt
|
||||
if (siweMessage.address.toLowerCase() !== address.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Handtekening komt niet overeen met opgegeven adres' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — default to 'user'
|
||||
return NextResponse.json(
|
||||
{ error: 'Ongeldige handtekening' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create JWT containing the Python session token
|
||||
// 4. Bepaal rol (eenvoudig: eerste adres = admin, rest = user)
|
||||
// In productie: check een database
|
||||
const role: 'admin' | 'user' = 'user';
|
||||
|
||||
// 5. Maak JWT aan
|
||||
const sessionToken = crypto.randomUUID();
|
||||
const jwt = await createSessionJWT({
|
||||
address: userAddress,
|
||||
address: address.toLowerCase(),
|
||||
role,
|
||||
sessionToken: pythonSessionToken,
|
||||
sessionToken,
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
address: userAddress,
|
||||
address: address.toLowerCase(),
|
||||
role,
|
||||
});
|
||||
|
||||
|
||||
@@ -266,9 +266,9 @@ export default function DirectoryListing({
|
||||
<div className="hidden sm:flex items-center gap-3 px-5 py-2.5 text-xs font-medium text-surface-500 uppercase tracking-wider border-b border-surface-800/50">
|
||||
<div className="w-4 shrink-0" />
|
||||
<div className="flex-1">Name</div>
|
||||
<div className="w-12 text-center">Type</div>
|
||||
<div className="w-12 text-center hidden sm:block">Type</div>
|
||||
<div className="w-16 text-right hidden md:block">Size</div>
|
||||
<div className="w-20 text-right hidden lg:block">Hash</div>
|
||||
<div className="w-24 text-right hidden lg:block">Hash</div>
|
||||
<div className="w-20 text-center" />
|
||||
</div>
|
||||
|
||||
|
||||
+273
-11
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer';
|
||||
import { listPins } from '@/lib/api/pins';
|
||||
import { listPins, removePin } from '@/lib/api/pins';
|
||||
import CIDInput from './components/CIDInput';
|
||||
import DirectoryListing from './components/DirectoryListing';
|
||||
import FilePreview from './components/FilePreview';
|
||||
@@ -13,12 +13,13 @@ import GatewayLink from './components/GatewayLink';
|
||||
import SearchBar from '@/components/search';
|
||||
import type { SearchResult } from '@/lib/search-index';
|
||||
import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index';
|
||||
import { Search, AlertCircle, RefreshCw, Database, Loader2 } from 'lucide-react';
|
||||
import { Search, AlertCircle, RefreshCw, Database, Loader2, Trash2, CheckSquare, Square, ArrowLeft, FolderOpen, Share2, Copy } from 'lucide-react';
|
||||
import Skeleton from '@/components/Skeleton';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
|
||||
import { gatewayLink } from '@/lib/helpers';
|
||||
|
||||
export default function ExplorerPage() {
|
||||
const [cid, setCid] = useState('');
|
||||
@@ -33,6 +34,75 @@ export default function ExplorerPage() {
|
||||
const [recentCids] = useState<string[]>(loadRecentCids);
|
||||
const [indexStats, setIndexStats] = useState(getIndexStats);
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
const [pinnedCids, setPinnedCids] = useState<{ cid: string; name: string }[]>([]);
|
||||
const [pinsLoading, setPinsLoading] = useState(true);
|
||||
const [selectedPins, setSelectedPins] = useState<Set<string>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [pinSearch, setPinSearch] = useState('');
|
||||
const [copiedPin, setCopiedPin] = useState<string | null>(null);
|
||||
|
||||
// Laad pins bij opening (alleen client-side)
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
loadPins();
|
||||
}, []);
|
||||
|
||||
async function loadPins() {
|
||||
setPinsLoading(true);
|
||||
try {
|
||||
const pinsList = await listPins();
|
||||
setPinnedCids(pinsList.map(p => ({ cid: p.cid, name: p.name || p.cid.slice(0, 16) + '…' })));
|
||||
} catch {
|
||||
// Stil falen
|
||||
} finally {
|
||||
setPinsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Selectie
|
||||
function toggleSelectPin(cid: string) {
|
||||
setSelectedPins(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cid)) next.delete(cid);
|
||||
else next.add(cid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllPins() {
|
||||
if (selectedPins.size === pinnedCids.length) {
|
||||
setSelectedPins(new Set());
|
||||
} else {
|
||||
setSelectedPins(new Set(pinnedCids.map(p => p.cid)));
|
||||
}
|
||||
}
|
||||
|
||||
// Verwijder enkele pin
|
||||
async function handleDeletePin(cid: string) {
|
||||
try {
|
||||
await removePin(cid);
|
||||
setPinnedCids(prev => prev.filter(p => p.cid !== cid));
|
||||
setSelectedPins(prev => { const n = new Set(prev); n.delete(cid); return n; });
|
||||
} catch (err) {
|
||||
console.error('[Explorer] Failed to unpin:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Verwijder geselecteerde pins
|
||||
async function handleDeleteSelected() {
|
||||
if (selectedPins.size === 0) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await Promise.all([...selectedPins].map(cid => removePin(cid)));
|
||||
setPinnedCids(prev => prev.filter(p => !selectedPins.has(p.cid)));
|
||||
setSelectedPins(new Set());
|
||||
} catch (err) {
|
||||
console.error('[Explorer] Failed to unpin selected:', err);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load pins from the pins page (shared localStorage or just track in-memory)
|
||||
function handlePinToggle(cid: string) {
|
||||
@@ -69,15 +139,35 @@ export default function ExplorerPage() {
|
||||
await navigateTo(hash);
|
||||
}
|
||||
|
||||
async function handleBreadcrumbNavigate(cid: string) {
|
||||
// Find the index of the clicked segment
|
||||
const idx = path.findIndex((s) => s.cid === cid);
|
||||
async function handleBreadcrumbNavigate(targetCid: string) {
|
||||
// Als op de huidige CID geklikt wordt, doe niets
|
||||
if (targetCid === cid) return;
|
||||
|
||||
// Zoek de index in het pad
|
||||
const idx = path.findIndex((s) => s.cid === targetCid);
|
||||
if (idx >= 0) {
|
||||
// Navigeer naar een bestaand segment in het pad
|
||||
setPath(path.slice(0, idx));
|
||||
await navigateTo(targetCid);
|
||||
} else if (targetCid === cid && path.length > 0) {
|
||||
// Terug naar het bovenliggende segment
|
||||
const newPath = path.slice(0, -1);
|
||||
setPath(newPath);
|
||||
if (newPath.length > 0) {
|
||||
await navigateTo(newPath[newPath.length - 1].cid);
|
||||
} else {
|
||||
// Terug naar root
|
||||
setState('idle');
|
||||
setCid('');
|
||||
setEntries([]);
|
||||
}
|
||||
} else {
|
||||
// Terug naar root (geen pad gevonden)
|
||||
setPath([]);
|
||||
setState('idle');
|
||||
setCid('');
|
||||
setEntries([]);
|
||||
}
|
||||
await navigateTo(cid);
|
||||
}
|
||||
|
||||
async function handlePreview(hash: string) {
|
||||
@@ -104,6 +194,21 @@ export default function ExplorerPage() {
|
||||
if (cid) navigateTo(cid);
|
||||
}
|
||||
|
||||
/* ── Navigatie terug (omhoog) ── */
|
||||
async function navigateUp() {
|
||||
if (path.length === 0) return;
|
||||
const newPath = path.slice(0, -1);
|
||||
const parentCid = newPath.length > 0 ? newPath[newPath.length - 1].cid : '';
|
||||
setPath(newPath);
|
||||
if (parentCid) {
|
||||
await navigateTo(parentCid);
|
||||
} else {
|
||||
setState('idle');
|
||||
setCid('');
|
||||
setEntries([]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
|
||||
function handleSearchResult(result: SearchResult) {
|
||||
@@ -134,7 +239,13 @@ export default function ExplorerPage() {
|
||||
indexed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ExplorerPage] Failed to index pin:', err);
|
||||
// Skip directories en niet-leesbare bestanden — geen probleem
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('directory') || msg.includes('dag node')) {
|
||||
// Directory — overslaan, geen error
|
||||
} else {
|
||||
console.error('[ExplorerPage] Failed to index pin:', err);
|
||||
}
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +258,29 @@ export default function ExplorerPage() {
|
||||
|
||||
const isPinned = previewCid ? pins.includes(previewCid) : false;
|
||||
|
||||
// Gefilterde pins op basis van zoekterm
|
||||
const filteredPins = pinSearch.trim()
|
||||
? pinnedCids.filter(p =>
|
||||
p.cid.toLowerCase().includes(pinSearch.toLowerCase()) ||
|
||||
p.name.toLowerCase().includes(pinSearch.toLowerCase())
|
||||
)
|
||||
: pinnedCids;
|
||||
|
||||
// Kopieer CID naar klembord
|
||||
function copyPinCid(cid: string) {
|
||||
navigator.clipboard.writeText(cid).catch(() => {});
|
||||
setCopiedPin(cid);
|
||||
setTimeout(() => setCopiedPin(null), 2000);
|
||||
}
|
||||
|
||||
// Kopieer gateway link naar klembord
|
||||
function sharePinLink(cid: string) {
|
||||
const link = gatewayLink(cid);
|
||||
navigator.clipboard.writeText(link).catch(() => {});
|
||||
setCopiedPin(`gw-${cid}`);
|
||||
setTimeout(() => setCopiedPin(null), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Verkenner">
|
||||
@@ -201,6 +335,122 @@ export default function ExplorerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pinned CIDs — alleen client-side renderen (voorkomt hydration mismatch) */}
|
||||
{mounted && state === 'idle' && pinnedCids.length > 0 && (
|
||||
<div className="mb-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-surface-500">Gepinde bestanden ({pinnedCids.length})</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectAllPins}
|
||||
className="flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors"
|
||||
>
|
||||
{selectedPins.size === pinnedCids.length ? (
|
||||
<CheckSquare className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{selectedPins.size === pinnedCids.length ? 'Deselecteer alles' : 'Selecteer alles'}
|
||||
</button>
|
||||
{selectedPins.size > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1 text-xs text-accent-rose hover:text-accent-rose/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
Verwijder ({selectedPins.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Zoekbalk voor pins */}
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-surface-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={pinSearch}
|
||||
onChange={(e) => setPinSearch(e.target.value)}
|
||||
placeholder="Zoek op CID of naam…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs rounded-lg bg-surface-800 border border-surface-700 text-surface-300 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="glass rounded-xl overflow-hidden">
|
||||
{filteredPins.slice(0, 10).map((pin) => (
|
||||
<div
|
||||
key={pin.cid}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 transition-colors border-b border-surface-800/50 last:border-b-0 ${
|
||||
selectedPins.has(pin.cid) ? 'bg-brand-500/10' : 'hover:bg-surface-800/50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSelectPin(pin.cid)}
|
||||
className="shrink-0 p-0.5"
|
||||
title={selectedPins.has(pin.cid) ? 'Deselecteer' : 'Selecteer'}
|
||||
>
|
||||
{selectedPins.has(pin.cid) ? (
|
||||
<CheckSquare className="w-4 h-4 text-brand-400" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-surface-600" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateTo(pin.cid)}
|
||||
className="flex-1 flex items-center gap-2 min-w-0 text-left"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5 text-accent-cyan shrink-0" />
|
||||
<span className="text-xs font-mono text-surface-300 truncate">{pin.cid}</span>
|
||||
{pin.name && pin.name !== pin.cid.slice(0, 16) + '…' && (
|
||||
<span className="text-xs text-surface-500 truncate ml-auto">{pin.name}</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Deel-knop */}
|
||||
<button
|
||||
onClick={() => sharePinLink(pin.cid)}
|
||||
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
|
||||
title="Kopieer gateway link"
|
||||
>
|
||||
{copiedPin === `gw-${pin.cid}` ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Share2 className="w-3.5 h-3.5 text-surface-600 group-hover:text-accent-cyan" />
|
||||
)}
|
||||
</button>
|
||||
{/* Kopieer CID */}
|
||||
<button
|
||||
onClick={() => copyPinCid(pin.cid)}
|
||||
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
|
||||
title="Kopieer CID"
|
||||
>
|
||||
{copiedPin === pin.cid ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5 text-surface-600 group-hover:text-surface-300" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeletePin(pin.cid)}
|
||||
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
|
||||
title="Verwijder pin"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-surface-600 group-hover:text-accent-rose" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{filteredPins.length === 0 && pinSearch.trim() && (
|
||||
<div className="px-4 py-4 text-xs text-surface-500 text-center">
|
||||
Geen pins gevonden voor "{pinSearch}"
|
||||
</div>
|
||||
)}
|
||||
{filteredPins.length > 10 && (
|
||||
<div className="px-4 py-2 text-xs text-surface-500 text-center">
|
||||
+{filteredPins.length - 10} meer…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle state */}
|
||||
{state === 'idle' && (
|
||||
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center animate-fade-in">
|
||||
@@ -259,10 +509,22 @@ export default function ExplorerPage() {
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Breadcrumbs + Pin + Gateway row */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<Breadcrumbs
|
||||
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{path.length > 0 && (
|
||||
<button
|
||||
onClick={navigateUp}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-800 text-xs text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors shrink-0"
|
||||
title="Ga terug"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
Terug
|
||||
</button>
|
||||
)}
|
||||
<Breadcrumbs
|
||||
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<GatewayLink cid={cid} />
|
||||
<PinBadge cid={cid} isPinned={pins.includes(cid)} onPin={handlePinToggle} />
|
||||
|
||||
@@ -30,7 +30,15 @@ export default function LoginPage() {
|
||||
notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}…${address.slice(-4)}` });
|
||||
router.push('/dashboard');
|
||||
} catch (err: unknown) {
|
||||
notify({ type: 'error', title: 'Login mislukt', message: err instanceof Error ? err.message : 'Onbekende fout' });
|
||||
const message = err instanceof Error ? err.message : 'Onbekende fout';
|
||||
console.error('[Login] Wallet connection failed:', message);
|
||||
|
||||
// Toon de echte foutmelding — geen nep "vault locked" meer
|
||||
notify({
|
||||
type: 'error',
|
||||
title: 'Login mislukt',
|
||||
message
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setBusyWallet(null);
|
||||
|
||||
+5
-6
@@ -1,14 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { connectWallet, signMessage } from '@/lib/wallet';
|
||||
import { connectWallet } from '@/lib/wallet';
|
||||
import { checkHealth } from '@/lib/api/gateway';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react';
|
||||
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -16,13 +15,13 @@ export default function LandingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const wallet = await connectWallet();
|
||||
setAddress(wallet.address);
|
||||
// Non-blocking health check — portal UI werkt ook zonder backend
|
||||
await connectWallet(); // Geen provider → probeert alle wallets自动
|
||||
checkHealth().catch(() => {});
|
||||
router.push('/dashboard');
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Connection failed');
|
||||
const message = e instanceof Error ? e.message : 'Connection failed';
|
||||
console.error('[Landing] Wallet connection failed:', message);
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
+14
-11
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
@@ -68,18 +69,19 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Profiel</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">Je accountinstellingen</p>
|
||||
<ErrorBoundary label="Profiel">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Profiel</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">Je accountinstellingen</p>
|
||||
</div>
|
||||
<button onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors">
|
||||
<LogOut className="w-4 h-4" />
|
||||
Uitloggen
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors">
|
||||
<LogOut className="w-4 h-4" />
|
||||
Uitloggen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* ── Account info ── */}
|
||||
@@ -180,6 +182,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+41
-29
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
|
||||
import { uploadFile } from '@/lib/api/files';
|
||||
import { addToHistory, getSettings } from '@/lib/storage';
|
||||
import { addPin } from '@/lib/api/pins';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { checkUploadAllowed } from '@/lib/limits';
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
@@ -12,6 +13,7 @@ import QuickUpload, { type FileEntry } from './components/QuickUpload';
|
||||
import { PaymentProvider } from '@/lib/payment';
|
||||
import CryptoUpload from './components/CryptoUpload';
|
||||
import type { TabMode } from './helpers';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
@@ -143,6 +145,10 @@ export default function UploadPage() {
|
||||
const result = await uploadFile(entry.file);
|
||||
const date = new Date().toISOString();
|
||||
addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' });
|
||||
// Auto-pin na succesvolle upload
|
||||
addPin(result.cid, entry.file.name).catch((err) => {
|
||||
console.warn('[UploadPage] Auto-pin mislukt:', err);
|
||||
});
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
|
||||
));
|
||||
@@ -177,6 +183,10 @@ export default function UploadPage() {
|
||||
const r = await uploadFile(cryptoFile);
|
||||
setCryptoResult(r);
|
||||
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
|
||||
// Auto-pin na succesvolle crypto upload
|
||||
addPin(r.cid, cryptoFile.name).catch((err) => {
|
||||
console.warn('[UploadPage] Auto-pin crypto mislukt:', err);
|
||||
});
|
||||
return r;
|
||||
}
|
||||
function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) {
|
||||
@@ -201,36 +211,37 @@ export default function UploadPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">Upload</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS — quick or with crypto payment</p>
|
||||
</div>
|
||||
<ErrorBoundary label="Upload">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">Upload</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS — quick or with crypto payment</p>
|
||||
</div>
|
||||
|
||||
{/* ── Tab switcher ── */}
|
||||
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
|
||||
<button
|
||||
onClick={() => setTab('quick')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
tab === 'quick'
|
||||
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
||||
: 'text-surface-400 hover:text-surface-200'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Quick Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('crypto')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
tab === 'crypto'
|
||||
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
||||
: 'text-surface-400 hover:text-surface-200'
|
||||
}`}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
Crypto Payment
|
||||
</button>
|
||||
</div>
|
||||
{/* ── Tab switcher ── */}
|
||||
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
|
||||
<button
|
||||
onClick={() => setTab('quick')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
tab === 'quick'
|
||||
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
||||
: 'text-surface-400 hover:text-surface-200'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Quick Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('crypto')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
tab === 'crypto'
|
||||
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
||||
: 'text-surface-400 hover:text-surface-200'
|
||||
}`}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
Crypto Payment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Upload tab */}
|
||||
{tab === 'quick' && (
|
||||
@@ -275,6 +286,7 @@ export default function UploadPage() {
|
||||
/>
|
||||
</PaymentProvider>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-7
@@ -13,6 +13,7 @@ import {
|
||||
HardDrive, Upload, Wifi, Database, Pin, Activity,
|
||||
ArrowUpRight, ArrowDownLeft,
|
||||
} from 'lucide-react';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function UsagePage() {
|
||||
const { user } = useAuth();
|
||||
@@ -47,13 +48,14 @@ export default function UsagePage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
{/* ── Header ── */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Usage</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">
|
||||
{user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'}
|
||||
</p>
|
||||
</div>
|
||||
<ErrorBoundary label="Usage">
|
||||
{/* ── Header ── */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Usage</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">
|
||||
{user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
@@ -164,6 +166,7 @@ export default function UsagePage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user