diff --git a/owner-portal/.env.example b/owner-portal/.env.example new file mode 100644 index 0000000..04c8062 --- /dev/null +++ b/owner-portal/.env.example @@ -0,0 +1,6 @@ +# Privy Authentication +# Get your App ID from https://console.privy.io +VITE_PRIVY_APP_ID=your-privy-app-id-here + +# Backend URL +VITE_BACKEND_URL=http://localhost:5000 \ No newline at end of file diff --git a/owner-portal/README.md b/owner-portal/README.md new file mode 100644 index 0000000..41c21ca --- /dev/null +++ b/owner-portal/README.md @@ -0,0 +1,38 @@ +# LensMint Owner Portal + +Web dashboard for LensMint camera owners to manage +their device, NFTs, and wallet. + +## Tech Stack +- React + Vite +- Privy (wallet authentication) +- Wagmi + Viem (blockchain interaction) +- TanStack Query + +## Prerequisites +- Node.js v18+ +- A Privy account → https://console.privy.io + +## Setup + +### 1. Install dependencies +\```bash +npm install --legacy-peer-deps +\``` + +### 2. Configure environment +\```bash +cp .env.example .env +\``` +Edit `.env` and add your Privy App ID from https://console.privy.io + +### 3. Start development server +\```bash +npm run dev +\``` +Portal runs at http://localhost:3000 + +## Known Issues +- Use `--legacy-peer-deps` flag during install due to peer + dependency conflicts between @privy-io packages +- Disable Solana in Privy console if you see Solana connector warnings \ No newline at end of file diff --git a/owner-portal/src/App.jsx b/owner-portal/src/App.jsx index c5089d9..191852d 100644 --- a/owner-portal/src/App.jsx +++ b/owner-portal/src/App.jsx @@ -1,39 +1,43 @@ import { PrivyProvider } from '@privy-io/react-auth' -import { createPrivyWagmiConfig } from '@privy-io/wagmi' -import { WagmiProvider } from 'wagmi' +import { createConfig, WagmiProvider } from '@privy-io/wagmi' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { sepolia } from 'wagmi/chains' +import { http } from 'viem' +import { sepolia } from 'viem/chains' import OwnerDashboard from './components/OwnerDashboard' const PRIVY_APP_ID = import.meta.env.VITE_PRIVY_APP_ID || 'your-privy-app-id' + const queryClient = new QueryClient() -const wagmiConfig = createPrivyWagmiConfig({ + +const wagmiConfig = createConfig({ chains: [sepolia], + transports: { + [sepolia.id]: http(), + }, }) function App() { return ( - - - + + + - - - + + + ) } -export default App - +export default App \ No newline at end of file diff --git a/owner-portal/src/components/OwnerDashboard.jsx b/owner-portal/src/components/OwnerDashboard.jsx index ee17076..be9329a 100644 --- a/owner-portal/src/components/OwnerDashboard.jsx +++ b/owner-portal/src/components/OwnerDashboard.jsx @@ -1,34 +1,69 @@ import { usePrivy, useWallets } from '@privy-io/react-auth' import { useAccount } from 'wagmi' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import axios from 'axios' import './OwnerDashboard.css' const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5000' const PRIVY_APP_ID = import.meta.env.VITE_PRIVY_APP_ID || 'your-privy-app-id' +// Validate IPFS CID format (v0 and v1) +const isValidIPFSHash = (hash) => hash?.startsWith('Qm') || hash?.startsWith('bafy') + +// Validate 32-byte hex string (image hash) +const isValidHash = (hash) => /^0x[a-fA-F0-9]{64}$/.test(hash) + +// Validate 65-byte ECDSA signature +const isValidSignature = (sig) => /^0x[a-fA-F0-9]{130}$/.test(sig) + function OwnerDashboard() { const { ready, authenticated, login, logout, user } = usePrivy() const { wallets } = useWallets() const { address, isConnected } = useAccount() + + // Session signer state — used for gas-sponsored transactions const [sessionSigner, setSessionSigner] = useState(null) const [signerAddress, setSignerAddress] = useState(null) + + // UI feedback states const [status, setStatus] = useState('') const [mintStatus, setMintStatus] = useState('') + const [isMinting, setIsMinting] = useState(false) + const [isSettingUp, setIsSettingUp] = useState(false) + const [txHash, setTxHash] = useState(null) + + // Mint form inputs const [ipfsHash, setIpfsHash] = useState('') const [imageHash, setImageHash] = useState('') const [signature, setSignature] = useState('') const [maxEditions, setMaxEditions] = useState(10) + // Auto-clear status messages to keep UI clean + useEffect(() => { + if (!status) return + const timer = setTimeout(() => setStatus(''), 5000) + return () => clearTimeout(timer) + }, [status]) + + useEffect(() => { + if (!mintStatus) return + const timer = setTimeout(() => setMintStatus(''), 8000) + return () => clearTimeout(timer) + }, [mintStatus]) + + // Auto-initialize session signer once wallet is available useEffect(() => { if (authenticated && wallets.length > 0 && !sessionSigner) { + setupSessionSigner() } - }, [authenticated, wallets, sessionSigner]) + }, [authenticated, wallets]) - const setupSessionSigner = async () => { + // Creates a Privy session signer for gas-sponsored minting + const setupSessionSigner = useCallback(async () => { try { + setIsSettingUp(true) setStatus('Setting up session signer...') - + const wallet = wallets.find(w => w.walletClientType === 'privy') || wallets[0] if (!wallet) { setStatus('No wallet found. Please connect a wallet.') @@ -37,81 +72,118 @@ function OwnerDashboard() { const walletAddress = wallet.address || address if (!walletAddress) { - setStatus('❌ No wallet address available') + setStatus('Wallet address unavailable. Please reconnect.') return } - const response = await axios.post(`${BACKEND_URL}/api/privy/create-session-signer`, { - walletAddress: walletAddress, - userId: user?.id || 'unknown' - }) + const response = await axios.post( + `${BACKEND_URL}/api/privy/create-session-signer`, + { walletAddress, userId: user?.id || 'unknown' } + ) if (response.data.success) { setSessionSigner(response.data.sessionSigner) setSignerAddress(response.data.signerAddress) - setStatus('✅ Session signer created successfully') + setStatus('Session signer created successfully.') } } catch (error) { console.error('Error setting up session signer:', error) - setStatus(`❌ Error: ${error.response?.data?.error || error.message}`) + setStatus(`Failed to create session signer: ${error.response?.data?.error || error.message}`) + } finally { + setIsSettingUp(false) } - } + }, [wallets, address, user]) - const handleMint = async (ipfsHash, imageHash, signature, maxEditions) => { - try { - if (!sessionSigner?.id) { - setMintStatus('❌ Please setup session signer first') - return - } + // Validates inputs and submits mint transaction via backend + const handleMint = async () => { + if (!ipfsHash || !imageHash || !signature) { + setMintStatus('All fields are required.') + return + } + if (!isValidIPFSHash(ipfsHash)) { + setMintStatus('Invalid IPFS hash. Must start with Qm or bafy.') + return + } + if (!isValidHash(imageHash)) { + setMintStatus('Invalid image hash. Must be a 0x-prefixed 32-byte hex string.') + return + } + if (!isValidSignature(signature)) { + setMintStatus('Invalid signature. Must be a 0x-prefixed 65-byte hex string.') + return + } + if (!sessionSigner?.id) { + setMintStatus('Session signer not initialized. Please wait or refresh.') + return + } + if (!address) { + setMintStatus('No wallet address detected. Please reconnect your wallet.') + return + } - if (!address) { - setMintStatus('❌ No wallet address available') - return - } + try { + setIsMinting(true) + setTxHash(null) + setMintStatus('Submitting mint transaction...') - setMintStatus('Minting NFT...') - - const response = await axios.post(`${BACKEND_URL}/api/privy/mint-with-signer`, { - recipient: address, - ipfsHash, - imageHash, - signature, - maxEditions, - sessionSignerId: sessionSigner.id - }) + const response = await axios.post( + `${BACKEND_URL}/api/privy/mint-with-signer`, + { + recipient: address, + ipfsHash, + imageHash, + signature, + maxEditions, + sessionSignerId: sessionSigner.id + } + ) if (response.data.success) { - setMintStatus(`✅ Minted successfully! TX: ${response.data.txHash}`) + setTxHash(response.data.txHash) + setMintStatus('NFT minted successfully.') + // Reset form after successful mint + setIpfsHash('') + setImageHash('') + setSignature('') + setMaxEditions(10) } } catch (error) { console.error('Error minting:', error) - setMintStatus(`❌ Error: ${error.response?.data?.error || error.message}`) + setMintStatus(`Minting failed: ${error.response?.data?.error || error.message}`) + } finally { + setIsMinting(false) } } + // Show initializing state while Privy loads if (!ready) { return (
-

Loading...

+

Initializing...

) } + // Show login screen if user is not authenticated if (!authenticated) { return (
-

🔐 LensMint Owner Portal

-

Login to manage your LensMint camera system

+

LensMint Owner Portal

+

Sign in to manage your LensMint camera system.

{PRIVY_APP_ID === 'your-privy-app-id' && (
- ⚠️ Please configure VITE_PRIVY_APP_ID in .env file + VITE_PRIVY_APP_ID is not configured. Please update your .env file.
)} -
@@ -121,117 +193,168 @@ function OwnerDashboard() { return (
+ + {/* Portal header with sign out */}
-

🎨 LensMint Owner Portal

+

LensMint Owner Portal

+ {/* Logged-in user and wallet details */}
-

Account Info

-
-
- User ID: {user?.id || 'N/A'} -
-
- Wallet Address: {address || wallets[0]?.address || 'No wallet'} -
-
- Connected: {isConnected ? '✅' : '❌'} -
-
- Wallets: {wallets.length} -
+

Account

+
+
+ User ID + {user?.id || 'N/A'} +
+
+ Wallet Address + {address || wallets[0]?.address || 'No wallet connected'}
+
+ Status + {isConnected ? 'Connected' : 'Disconnected'} +
+
+ Wallets + {wallets.length} +
+
+ {/* Active session signer info — shown after setup */} {sessionSigner && (

Session Signer

- Signer ID: {sessionSigner.id} + Signer ID + {sessionSigner.id}
- Signer Address: {signerAddress} -
-
- Status: {status} + Signer Address + {signerAddress}
)} + {/* Global status feedback — auto clears after 5s */} + {status && ( +
{status}
+ )} + + {/* Mint actions — setup signer first, then show mint form */}

Actions

-
- {!sessionSigner && ( - - )} - {sessionSigner && ( -
- + {isSettingUp ? 'Setting up...' : 'Setup Session Signer'} + + )} + + {/* NFT mint form — visible only when session signer is ready */} + {sessionSigner && ( +
+
- )} -
+ + + +
+ )} + + {/* Mint status feedback — auto clears after 8s */} {mintStatus && (
{mintStatus}
)} + + {/* Etherscan link shown after successful mint */} + {txHash && ( +
+ + View transaction on Etherscan: {txHash.slice(0, 10)}...{txHash.slice(-6)} + +
+ )}
+ {/* Gas sponsorship info — transactions are free for the owner */}

Gas Sponsorship

- ✅ Gas fees are automatically sponsored through Privy. + Gas fees are automatically sponsored through Privy. Transactions will be executed without requiring ETH balance.

+
) } -export default OwnerDashboard - +export default OwnerDashboard \ No newline at end of file