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.
)}
-
- Login with Privy
+
+ Sign In
@@ -121,117 +193,168 @@ function OwnerDashboard() {
return (
+
+ {/* Portal header with sign out */}
-
🎨 LensMint Owner Portal
+ LensMint Owner Portal
- Logout
+ Sign Out
+ {/* 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 && (
-
- Setup Session Signer
-
- )}
- {sessionSigner && (
-
+
+
+
+ {isMinting ? 'Minting...' : 'Mint NFT'}
+
+
+ )}
+
+ {/* Mint status feedback — auto clears after 8s */}
{mintStatus && (
{mintStatus}
)}
+
+ {/* Etherscan link shown after successful mint */}
+ {txHash && (
+
+ )}
+ {/* 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