From bbc8d81613add72a8e4143d3b95d5d243df931cb Mon Sep 17 00:00:00 2001 From: Shreyaschorge Date: Mon, 7 Jul 2025 20:19:22 +0530 Subject: [PATCH] more refactor --- src/components/ui/NeynarAuthButton.tsx | 891 ------------------ .../ui/NeynarAuthButton/AuthDialog.tsx | 222 +++++ .../ui/NeynarAuthButton/ProfileButton.tsx | 92 ++ src/components/ui/NeynarAuthButton/index.tsx | 511 ++++++++++ src/components/ui/tabs/ActionsTab.tsx | 2 +- src/hooks/useDetectClickOutside.ts | 18 + src/lib/devices.ts | 27 + src/lib/localStorage.ts | 25 + 8 files changed, 896 insertions(+), 892 deletions(-) delete mode 100644 src/components/ui/NeynarAuthButton.tsx create mode 100644 src/components/ui/NeynarAuthButton/AuthDialog.tsx create mode 100644 src/components/ui/NeynarAuthButton/ProfileButton.tsx create mode 100644 src/components/ui/NeynarAuthButton/index.tsx create mode 100644 src/hooks/useDetectClickOutside.ts create mode 100644 src/lib/devices.ts create mode 100644 src/lib/localStorage.ts diff --git a/src/components/ui/NeynarAuthButton.tsx b/src/components/ui/NeynarAuthButton.tsx deleted file mode 100644 index 417c6c1..0000000 --- a/src/components/ui/NeynarAuthButton.tsx +++ /dev/null @@ -1,891 +0,0 @@ -'use client'; - -import '@farcaster/auth-kit/styles.css'; -import { useSignIn } from '@farcaster/auth-kit'; -import { useCallback, useEffect, useState, useRef } from 'react'; -import { cn } from '../../lib/utils'; -import { Button } from './Button'; - -// Utility functions for device detection -function isAndroid(): boolean { - return ( - typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent) - ); -} - -function isSmallIOS(): boolean { - return ( - typeof navigator !== 'undefined' && /iPhone|iPod/.test(navigator.userAgent) - ); -} - -function isLargeIOS(): boolean { - return ( - typeof navigator !== 'undefined' && - (/iPad/.test(navigator.userAgent) || - (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) - ); -} - -function isIOS(): boolean { - return isSmallIOS() || isLargeIOS(); -} - -function isMobile(): boolean { - return isAndroid() || isIOS(); -} - -// Hook for detecting clicks outside an element -function useDetectClickOutside( - ref: React.RefObject, - callback: () => void -) { - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (ref.current && !ref.current.contains(event.target as Node)) { - callback(); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [ref, callback]); -} - -// Storage utilities for persistence -const STORAGE_KEY = 'farcaster_auth_state'; - -interface StoredAuthState { - isAuthenticated: boolean; - userData?: { - fid?: number; - pfpUrl?: string; - username?: string; - }; - lastSignInTime?: number; - signers?: { - object: 'signer'; - signer_uuid: string; - public_key: string; - status: 'approved'; - fid: number; - }[]; // Store the list of signers -} - -function saveAuthState(state: StoredAuthState) { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch (error) { - console.warn('Failed to save auth state:', error); - } -} - -function loadAuthState(): StoredAuthState | null { - try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : null; - } catch (error) { - console.warn('Failed to load auth state:', error); - return null; - } -} - -function clearAuthState() { - try { - localStorage.removeItem(STORAGE_KEY); - } catch (error) { - console.warn('Failed to clear auth state:', error); - } -} - -function updateSignersInAuthState( - signers: StoredAuthState['signers'] -): StoredAuthState | null { - try { - const stored = loadAuthState(); - if (stored) { - const updatedState = { ...stored, signers }; - saveAuthState(updatedState); - return updatedState; - } - } catch (error) { - console.warn('Failed to update signers in auth state:', error); - } - return null; -} - -export function getStoredSigners(): unknown[] { - try { - const stored = loadAuthState(); - return stored?.signers || []; - } catch (error) { - console.warn('Failed to get stored signers:', error); - return []; - } -} - -// Enhanced QR Code Dialog Component with multiple steps -function AuthDialog({ - open, - onClose, - url, - isError, - error, - step, - isLoading, - signerApprovalUrl, -}: { - open: boolean; - onClose: () => void; - url: string; - isError: boolean; - error?: Error | null; - step: 'signin' | 'access' | 'loading'; - isLoading?: boolean; - signerApprovalUrl?: string | null; -}) { - if (!open) return null; - - const getStepContent = () => { - switch (step) { - case 'signin': - return { - title: 'Signin', - description: - "To signin, scan the code below with your phone's camera.", - showQR: true, - qrUrl: url, - showOpenButton: true, - }; - - case 'loading': - return { - title: 'Setting up access...', - description: - 'Checking your account permissions and setting up secure access.', - showQR: false, - qrUrl: '', - showOpenButton: false, - }; - - case 'access': - return { - title: 'Grant Access', - description: ( -
-

- Allow this app to access your Farcaster account: -

-
-
-
- - - -
-
-
- Read Access -
-
- View your profile and public information -
-
-
-
-
- - - -
-
-
- Write Access -
-
- Post casts, likes, and update your profile -
-
-
-
-
- ), - // Show QR code if we have signer approval URL, otherwise show loading - showQR: !!signerApprovalUrl, - qrUrl: signerApprovalUrl || '', - showOpenButton: !!signerApprovalUrl, - }; - - default: - return { - title: 'Sign in', - description: - "To signin, scan the code below with your phone's camera.", - showQR: true, - qrUrl: url, - showOpenButton: true, - }; - } - }; - - const content = getStepContent(); - - return ( -
-
-
-

- {isError ? 'Error' : content.title} -

- -
- - {isError ? ( -
-
- {error?.message || 'Unknown error, please try again.'} -
- -
- ) : ( -
-
- {typeof content.description === 'string' ? ( -

- {content.description} -

- ) : ( - content.description - )} -
- -
- {content.showQR && content.qrUrl ? ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - QR Code -
- ) : step === 'loading' || isLoading ? ( -
-
-
- - {step === 'loading' - ? 'Setting up access...' - : 'Loading...'} - -
-
- ) : null} -
- - {content.showOpenButton && content.qrUrl && ( - - )} -
- )} -
-
- ); -} - -// Profile Button Component -function ProfileButton({ - userData, - onSignOut, -}: { - userData?: { fid?: number; pfpUrl?: string; username?: string }; - onSignOut: () => void; -}) { - const [showDropdown, setShowDropdown] = useState(false); - const ref = useRef(null); - - useDetectClickOutside(ref, () => setShowDropdown(false)); - - const name = userData?.username ?? `!${userData?.fid}`; - const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; - - return ( -
- - - {showDropdown && ( -
- -
- )} -
- ); -} - -// Main Custom SignInButton Component -export function NeynarAuthButton() { - const [nonce, setNonce] = useState(null); - const [storedAuth, setStoredAuth] = useState(null); - const [signersLoading, setSignersLoading] = useState(false); - - // New state for unified dialog flow - const [showDialog, setShowDialog] = useState(false); - const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( - 'loading' - ); - const [signerApprovalUrl, setSignerApprovalUrl] = useState( - null - ); - const [pollingInterval, setPollingInterval] = useState( - null - ); - - // Helper function to create a signer - const createSigner = useCallback(async () => { - try { - // console.log('🔧 Creating new signer...'); - - const response = await fetch('/api/auth/signer', { - method: 'POST', - }); - - if (!response.ok) { - throw new Error('Failed to create signer'); - } - - const signerData = await response.json(); - // console.log('✅ Signer created:', signerData); - - return signerData; - } catch (error) { - // console.error('❌ Error creating signer:', error); - throw error; - } - }, []); - - // Helper function to generate signed key request - const generateSignedKeyRequest = useCallback( - async (signerUuid: string, publicKey: string) => { - try { - // console.log('🔑 Generating signed key request...'); - - // Prepare request body - const requestBody: { - signerUuid: string; - publicKey: string; - sponsor?: { sponsored_by_neynar: boolean }; - } = { - signerUuid, - publicKey, - }; - - const response = await fetch('/api/auth/signer/signed_key', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - `Failed to generate signed key request: ${errorData.error}` - ); - } - - const data = await response.json(); - // console.log('✅ Signed key request generated:', data); - - return data; - } catch (error) { - console.error('❌ Error generating signed key request:', error); - throw error; - } - }, - [] - ); - - // Helper function to fetch all signers - const fetchAllSigners = useCallback( - async (message: string, signature: string) => { - try { - // console.log('� Fetching all signers...'); - setSignersLoading(true); - - const response = await fetch( - `/api/auth/signers?message=${encodeURIComponent( - message - )}&signature=${signature}` - ); - - const signerData = await response.json(); - // console.log('� Signer response:', signerData); - - if (response.ok) { - // console.log('✅ Signers fetched successfully:', signerData.signers); - - // Store signers in localStorage - const updatedState = updateSignersInAuthState( - signerData.signers || [] - ); - if (updatedState) { - setStoredAuth(updatedState); - } - - return signerData.signers; - } else { - console.error('❌ Failed to fetch signers'); - throw new Error('Failed to fetch signers'); - } - } catch (error) { - console.error('❌ Error fetching signers:', error); - throw error; - } finally { - setSignersLoading(false); - } - }, - [] - ); - - // Helper function to poll signer status - const startPolling = useCallback( - (signerUuid: string, message: string, signature: string) => { - // console.log('� Starting polling for signer:', signerUuid); - - const interval = setInterval(async () => { - try { - const response = await fetch( - `/api/auth/signer?signerUuid=${signerUuid}` - ); - - if (!response.ok) { - throw new Error('Failed to poll signer status'); - } - - const signerData = await response.json(); - // console.log('� Signer status:', signerData.status); - - if (signerData.status === 'approved') { - // console.log('🎉 Signer approved!'); - clearInterval(interval); - setPollingInterval(null); - setShowDialog(false); - setDialogStep('signin'); - setSignerApprovalUrl(null); - - // Refetch all signers - await fetchAllSigners(message, signature); - } - } catch (error) { - console.error('❌ Error polling signer:', error); - } - }, 1000); // Poll every 1 second - - setPollingInterval(interval); - }, - [fetchAllSigners] - ); - - // Cleanup polling on unmount - useEffect(() => { - return () => { - if (pollingInterval) { - clearInterval(pollingInterval); - } - }; - }, [pollingInterval]); - - // Generate nonce - useEffect(() => { - const generateNonce = async () => { - try { - const response = await fetch('/api/auth/nonce'); - if (response.ok) { - const data = await response.json(); - setNonce(data.nonce); - } else { - console.error('Failed to fetch nonce'); - } - } catch (error) { - console.error('Error generating nonce:', error); - } - }; - - generateNonce(); - }, []); - - // Load stored auth state on mount - useEffect(() => { - const stored = loadAuthState(); - if (stored && stored.isAuthenticated) { - setStoredAuth(stored); - if (stored.signers && stored.signers.length > 0) { - // console.log('📂 Loaded stored signers:', stored.signers); - } - } - }, []); - - // Success callback - this is critical! - const onSuccessCallback = useCallback((res: unknown) => { - // console.log('🎉 Sign in successful!', res); - const authState: StoredAuthState = { - isAuthenticated: true, - userData: res as StoredAuthState['userData'], - lastSignInTime: Date.now(), - }; - saveAuthState(authState); - setStoredAuth(authState); - // setShowDialog(false); - }, []); - - // Error callback - const onErrorCallback = useCallback((error?: Error | null) => { - console.error('❌ Sign in error:', error); - }, []); - - const signInState = useSignIn({ - nonce: nonce || undefined, - onSuccess: onSuccessCallback, - onError: onErrorCallback, - }); - - const { - signIn, - signOut, - connect, - reconnect, - isSuccess, - isError, - error, - channelToken, - url, - data, - validSignature, - isPolling, - } = signInState; - - // Connect when component mounts and we have a nonce - useEffect(() => { - if (nonce && !channelToken) { - // console.log('🔌 Connecting with nonce:', nonce); - connect(); - } - }, [nonce, channelToken, connect]); - - // Debug logging - // useEffect(() => { - // console.log('🔍 Auth state:', { - // isSuccess, - // validSignature, - // hasData: !!data, - // isPolling, - // isError, - // storedAuth: !!storedAuth?.isAuthenticated, - // }); - // }, [isSuccess, validSignature, data, isPolling, isError, storedAuth]); - - // Handle fetching signers after successful authentication - useEffect(() => { - if (data?.message && data?.signature) { - // console.log('📝 Got message and signature:', { - // message: data.message, - // signature: data.signature, - // }); - const handleSignerFlow = async () => { - try { - // Ensure we have message and signature - if (!data.message || !data.signature) { - console.error('❌ Missing message or signature'); - return; - } - - // Step 1: Change to loading state - setDialogStep('loading'); - setSignersLoading(true); - - // First, fetch existing signers - const signers = await fetchAllSigners(data.message, data.signature); - - // Check if no signers exist - if (!signers || signers.length === 0) { - // console.log('� No signers found, creating new signer...'); - - // Step 1: Create a signer - const newSigner = await createSigner(); - - // Step 2: Generate signed key request - const signedKeyData = await generateSignedKeyRequest( - newSigner.signer_uuid, - newSigner.public_key - ); - - // Step 3: Show QR code in access dialog for signer approval - if (signedKeyData.signer_approval_url) { - setSignerApprovalUrl(signedKeyData.signer_approval_url); - setSignersLoading(false); // Stop loading, show QR code - setDialogStep('access'); // Switch to access step to show QR - - // Step 4: Start polling for signer approval - startPolling(newSigner.signer_uuid, data.message, data.signature); - } - } else { - // If signers exist, close the dialog - // console.log('✅ Signers already exist, closing dialog'); - setSignersLoading(false); - setShowDialog(false); - setDialogStep('signin'); - } - } catch (error) { - console.error('❌ Error in signer flow:', error); - // On error, reset to signin step - setDialogStep('signin'); - setSignersLoading(false); - } - }; - - handleSignerFlow(); - } - }, [ - data?.message, - data?.signature, - fetchAllSigners, - createSigner, - generateSignedKeyRequest, - startPolling, - ]); - - const handleSignIn = useCallback(() => { - // console.log('🚀 Starting sign in flow...'); - if (isError) { - // console.log('🔄 Reconnecting due to error...'); - reconnect(); - } - setDialogStep('signin'); - setShowDialog(true); - signIn(); - - // Open mobile app if on mobile and URL is available - if (url && isMobile()) { - // console.log('📱 Opening mobile app:', url); - window.open(url, '_blank'); - } - }, [isError, reconnect, signIn, url]); - - const handleSignOut = useCallback(() => { - // console.log('👋 Signing out...'); - setShowDialog(false); - signOut(); - clearAuthState(); - setStoredAuth(null); - }, [signOut]); - - // The key fix: match the original library's authentication logic exactly - const authenticated = - ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && - !!(storedAuth?.signers && storedAuth.signers.length > 0); - const userData = data || storedAuth?.userData; - - // Show loading state while nonce is being fetched or signers are loading - if (!nonce || signersLoading) { - return ( -
-
-
- - Loading... - -
-
- ); - } - - return ( - <> - {authenticated ? ( - - ) : ( - - )} - - {/* Unified Auth Dialog */} - {url && ( - { - setShowDialog(false); - setDialogStep('signin'); - setSignerApprovalUrl(null); - if (pollingInterval) { - clearInterval(pollingInterval); - setPollingInterval(null); - } - }} - url={url} - isError={isError} - error={error} - step={dialogStep} - isLoading={signersLoading} - signerApprovalUrl={signerApprovalUrl} - /> - )} - - {/* Debug panel (optional - can be removed in production) */} - {/* {process.env.NODE_ENV === "development" && ( -
-
Debug Info:
-
-            {JSON.stringify(
-              {
-                authenticated,
-                isSuccess,
-                validSignature,
-                hasData: !!data,
-                isPolling,
-                isError,
-                hasStoredAuth: !!storedAuth?.isAuthenticated,
-                hasUrl: !!url,
-                hasChannelToken: !!channelToken,
-              },
-              null,
-              2
-            )}
-          
-
- )} */} - - ); -} diff --git a/src/components/ui/NeynarAuthButton/AuthDialog.tsx b/src/components/ui/NeynarAuthButton/AuthDialog.tsx new file mode 100644 index 0000000..8c8f984 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/AuthDialog.tsx @@ -0,0 +1,222 @@ +'use client'; + +export function AuthDialog({ + open, + onClose, + url, + isError, + error, + step, + isLoading, + signerApprovalUrl, +}: { + open: boolean; + onClose: () => void; + url: string; + isError: boolean; + error?: Error | null; + step: 'signin' | 'access' | 'loading'; + isLoading?: boolean; + signerApprovalUrl?: string | null; +}) { + if (!open) return null; + + const getStepContent = () => { + switch (step) { + case 'signin': + return { + title: 'Signin', + description: + "To signin, scan the code below with your phone's camera.", + showQR: true, + qrUrl: url, + showOpenButton: true, + }; + + case 'loading': + return { + title: 'Setting up access...', + description: + 'Checking your account permissions and setting up secure access.', + showQR: false, + qrUrl: '', + showOpenButton: false, + }; + + case 'access': + return { + title: 'Grant Access', + description: ( +
+

+ Allow this app to access your Farcaster account: +

+
+
+
+ + + +
+
+
+ Read Access +
+
+ View your profile and public information +
+
+
+
+
+ + + +
+
+
+ Write Access +
+
+ Post casts, likes, and update your profile +
+
+
+
+
+ ), + // Show QR code if we have signer approval URL, otherwise show loading + showQR: !!signerApprovalUrl, + qrUrl: signerApprovalUrl || '', + showOpenButton: !!signerApprovalUrl, + }; + + default: + return { + title: 'Sign in', + description: + "To signin, scan the code below with your phone's camera.", + showQR: true, + qrUrl: url, + showOpenButton: true, + }; + } + }; + + const content = getStepContent(); + + return ( +
+
+
+

+ {isError ? 'Error' : content.title} +

+ +
+ + {isError ? ( +
+
+ {error?.message || 'Unknown error, please try again.'} +
+ +
+ ) : ( +
+
+ {typeof content.description === 'string' ? ( +

+ {content.description} +

+ ) : ( + content.description + )} +
+ +
+ {content.showQR && content.qrUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code +
+ ) : step === 'loading' || isLoading ? ( +
+
+
+ + {step === 'loading' + ? 'Setting up access...' + : 'Loading...'} + +
+
+ ) : null} +
+ + {content.showOpenButton && content.qrUrl && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/ProfileButton.tsx b/src/components/ui/NeynarAuthButton/ProfileButton.tsx new file mode 100644 index 0000000..daec476 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/ProfileButton.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useDetectClickOutside } from '~/hooks/useDetectClickOutside'; +import { cn } from '~/lib/utils'; + +export function ProfileButton({ + userData, + onSignOut, +}: { + userData?: { fid?: number; pfpUrl?: string; username?: string }; + onSignOut: () => void; +}) { + const [showDropdown, setShowDropdown] = useState(false); + const ref = useRef(null); + + useDetectClickOutside(ref, () => setShowDropdown(false)); + + const name = userData?.username ?? `!${userData?.fid}`; + const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; + + return ( +
+ + + {showDropdown && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx new file mode 100644 index 0000000..0a2fea0 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -0,0 +1,511 @@ +'use client'; + +import '@farcaster/auth-kit/styles.css'; +import { useSignIn } from '@farcaster/auth-kit'; +import { useCallback, useEffect, useState } from 'react'; +import { cn } from '~/lib/utils'; +import { Button } from '~/components/ui/Button'; +import { isMobile } from '~/lib/devices'; +import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; +import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; +import { getItem, removeItem, setItem } from '~/lib/localStorage'; + +const STORAGE_KEY = 'neynar_authenticated_user'; + +interface StoredAuthState { + isAuthenticated: boolean; + userData?: { + fid?: number; + pfpUrl?: string; + username?: string; + }; + lastSignInTime?: number; + signers?: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; // Store the list of signers +} + +// Main Custom SignInButton Component +export function NeynarAuthButton() { + const [nonce, setNonce] = useState(null); + const [storedAuth, setStoredAuth] = useState(null); + const [signersLoading, setSignersLoading] = useState(false); + + // New state for unified dialog flow + const [showDialog, setShowDialog] = useState(false); + const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( + 'loading' + ); + const [signerApprovalUrl, setSignerApprovalUrl] = useState( + null + ); + const [pollingInterval, setPollingInterval] = useState( + null + ); + + // Helper function to create a signer + const createSigner = useCallback(async () => { + try { + // console.log('🔧 Creating new signer...'); + + const response = await fetch('/api/auth/signer', { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Failed to create signer'); + } + + const signerData = await response.json(); + // console.log('✅ Signer created:', signerData); + + return signerData; + } catch (error) { + // console.error('❌ Error creating signer:', error); + throw error; + } + }, []); + + // Helper function to generate signed key request + const generateSignedKeyRequest = useCallback( + async (signerUuid: string, publicKey: string) => { + try { + // console.log('🔑 Generating signed key request...'); + + // Prepare request body + const requestBody: { + signerUuid: string; + publicKey: string; + sponsor?: { sponsored_by_neynar: boolean }; + } = { + signerUuid, + publicKey, + }; + + const response = await fetch('/api/auth/signer/signed_key', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Failed to generate signed key request: ${errorData.error}` + ); + } + + const data = await response.json(); + // console.log('✅ Signed key request generated:', data); + + return data; + } catch (error) { + console.error('❌ Error generating signed key request:', error); + throw error; + } + }, + [] + ); + + // Helper function to fetch all signers + const fetchAllSigners = useCallback( + async (message: string, signature: string) => { + try { + // console.log('� Fetching all signers...'); + setSignersLoading(true); + + const response = await fetch( + `/api/auth/signers?message=${encodeURIComponent( + message + )}&signature=${signature}` + ); + + const signerData = await response.json(); + // console.log('� Signer response:', signerData); + + if (response.ok) { + // console.log('✅ Signers fetched successfully:', signerData.signers); + + // Store signers in localStorage, preserving existing auth data + const existingAuth = getItem(STORAGE_KEY); + const updatedState: StoredAuthState = { + ...existingAuth, + isAuthenticated: true, + signers: signerData.signers || [], + lastSignInTime: existingAuth?.lastSignInTime || Date.now(), + }; + setItem(STORAGE_KEY, updatedState); + setStoredAuth(updatedState); + + return signerData.signers; + } else { + console.error('❌ Failed to fetch signers'); + throw new Error('Failed to fetch signers'); + } + } catch (error) { + console.error('❌ Error fetching signers:', error); + throw error; + } finally { + setSignersLoading(false); + } + }, + [] + ); + + // Helper function to poll signer status + const startPolling = useCallback( + (signerUuid: string, message: string, signature: string) => { + // console.log('� Starting polling for signer:', signerUuid); + + const interval = setInterval(async () => { + try { + const response = await fetch( + `/api/auth/signer?signerUuid=${signerUuid}` + ); + + if (!response.ok) { + throw new Error('Failed to poll signer status'); + } + + const signerData = await response.json(); + // console.log('� Signer status:', signerData.status); + + if (signerData.status === 'approved') { + // console.log('🎉 Signer approved!'); + clearInterval(interval); + setPollingInterval(null); + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + + // Refetch all signers + await fetchAllSigners(message, signature); + } + } catch (error) { + console.error('❌ Error polling signer:', error); + } + }, 1000); // Poll every 1 second + + setPollingInterval(interval); + }, + [fetchAllSigners] + ); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } + }; + }, [pollingInterval]); + + // Generate nonce + useEffect(() => { + const generateNonce = async () => { + try { + const response = await fetch('/api/auth/nonce'); + if (response.ok) { + const data = await response.json(); + setNonce(data.nonce); + } else { + console.error('Failed to fetch nonce'); + } + } catch (error) { + console.error('Error generating nonce:', error); + } + }; + + generateNonce(); + }, []); + + // Load stored auth state on mount + useEffect(() => { + const stored = getItem(STORAGE_KEY); + if (stored && stored.isAuthenticated) { + setStoredAuth(stored); + if (stored.signers && stored.signers.length > 0) { + // console.log('📂 Loaded stored signers:', stored.signers); + } + } + }, []); + + // Success callback - this is critical! + const onSuccessCallback = useCallback((res: unknown) => { + // console.log('🎉 Sign in successful!', res); + const existingAuth = getItem(STORAGE_KEY); + const authState: StoredAuthState = { + isAuthenticated: true, + userData: res as StoredAuthState['userData'], + lastSignInTime: Date.now(), + signers: existingAuth?.signers || [], // Preserve existing signers + }; + setItem(STORAGE_KEY, authState); + setStoredAuth(authState); + // setShowDialog(false); + }, []); + + // Error callback + const onErrorCallback = useCallback((error?: Error | null) => { + console.error('❌ Sign in error:', error); + }, []); + + const signInState = useSignIn({ + nonce: nonce || undefined, + onSuccess: onSuccessCallback, + onError: onErrorCallback, + }); + + const { + signIn, + signOut, + connect, + reconnect, + isSuccess, + isError, + error, + channelToken, + url, + data, + validSignature, + } = signInState; + + // Connect when component mounts and we have a nonce + useEffect(() => { + if (nonce && !channelToken) { + // console.log('🔌 Connecting with nonce:', nonce); + connect(); + } + }, [nonce, channelToken, connect]); + + // Debug logging + // useEffect(() => { + // console.log('🔍 Auth state:', { + // isSuccess, + // validSignature, + // hasData: !!data, + // isPolling, + // isError, + // storedAuth: !!storedAuth?.isAuthenticated, + // }); + // }, [isSuccess, validSignature, data, isPolling, isError, storedAuth]); + + // Handle fetching signers after successful authentication + useEffect(() => { + if (data?.message && data?.signature) { + // console.log('📝 Got message and signature:', { + // message: data.message, + // signature: data.signature, + // }); + const handleSignerFlow = async () => { + try { + // Ensure we have message and signature + if (!data.message || !data.signature) { + console.error('❌ Missing message or signature'); + return; + } + + // Step 1: Change to loading state + setDialogStep('loading'); + setSignersLoading(true); + + // First, fetch existing signers + const signers = await fetchAllSigners(data.message, data.signature); + + // Check if no signers exist or if we have empty signers + if (!signers || signers.length === 0) { + // console.log('🔧 No signers found, creating new signer...'); + + // Step 1: Create a signer + const newSigner = await createSigner(); + + // Step 2: Generate signed key request + const signedKeyData = await generateSignedKeyRequest( + newSigner.signer_uuid, + newSigner.public_key + ); + + // Step 3: Show QR code in access dialog for signer approval + if (signedKeyData.signer_approval_url) { + setSignerApprovalUrl(signedKeyData.signer_approval_url); + setSignersLoading(false); // Stop loading, show QR code + setDialogStep('access'); // Switch to access step to show QR + + // Step 4: Start polling for signer approval + startPolling(newSigner.signer_uuid, data.message, data.signature); + } + } else { + // If signers exist, close the dialog + // console.log('✅ Signers already exist, closing dialog'); + setSignersLoading(false); + setShowDialog(false); + setDialogStep('signin'); + } + } catch (error) { + console.error('❌ Error in signer flow:', error); + // On error, reset to signin step + setDialogStep('signin'); + setSignersLoading(false); + } + }; + + handleSignerFlow(); + } + }, [ + data?.message, + data?.signature, + fetchAllSigners, + createSigner, + generateSignedKeyRequest, + startPolling, + ]); + + const handleSignIn = useCallback(() => { + // console.log('🚀 Starting sign in flow...'); + if (isError) { + // console.log('🔄 Reconnecting due to error...'); + reconnect(); + } + setDialogStep('signin'); + setShowDialog(true); + signIn(); + + // Open mobile app if on mobile and URL is available + if (url && isMobile()) { + // console.log('📱 Opening mobile app:', url); + window.open(url, '_blank'); + } + }, [isError, reconnect, signIn, url]); + + const handleSignOut = useCallback(() => { + // console.log('👋 Signing out...'); + setShowDialog(false); + signOut(); + removeItem(STORAGE_KEY); + setStoredAuth(null); + }, [signOut]); + + // The key fix: match the original library's authentication logic exactly + const authenticated = + ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && + !!(storedAuth?.signers && storedAuth.signers.length > 0); + const userData = data || storedAuth?.userData; + + // Debug logging + // useEffect(() => { + // console.log('🔍 Auth state:', { + // authenticated, + // isSuccess, + // validSignature, + // hasData: !!data, + // isError, + // storedAuth: !!storedAuth?.isAuthenticated, + // storedSigners: storedAuth?.signers?.length || 0, + // hasUrl: !!url, + // }); + // }, [ + // authenticated, + // isSuccess, + // validSignature, + // data, + // isError, + // storedAuth, + // url, + // ]); + + // Show loading state while nonce is being fetched or signers are loading + if (!nonce || signersLoading) { + return ( +
+
+
+ + Loading... + +
+
+ ); + } + + return ( + <> + {authenticated ? ( + + ) : ( + + )} + + {/* Unified Auth Dialog */} + {url && ( + { + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + }} + url={url} + isError={isError} + error={error} + step={dialogStep} + isLoading={signersLoading} + signerApprovalUrl={signerApprovalUrl} + /> + )} + + {/* Debug panel (optional - can be removed in production) */} + {/* {process.env.NODE_ENV === "development" && ( +
+
Debug Info:
+
+            {JSON.stringify(
+              {
+                authenticated,
+                isSuccess,
+                validSignature,
+                hasData: !!data,
+                isPolling,
+                isError,
+                hasStoredAuth: !!storedAuth?.isAuthenticated,
+                hasUrl: !!url,
+                hasChannelToken: !!channelToken,
+              },
+              null,
+              2
+            )}
+          
+
+ )} */} + + ); +} diff --git a/src/components/ui/tabs/ActionsTab.tsx b/src/components/ui/tabs/ActionsTab.tsx index e9c0fb7..1b624c4 100644 --- a/src/components/ui/tabs/ActionsTab.tsx +++ b/src/components/ui/tabs/ActionsTab.tsx @@ -6,7 +6,7 @@ import { ShareButton } from '../Share'; import { Button } from '../Button'; import { SignIn } from '../wallet/SignIn'; import { type Haptics } from '@farcaster/frame-sdk'; -import { NeynarAuthButton } from '../NeynarAuthButton'; +import { NeynarAuthButton } from '../NeynarAuthButton/index'; /** * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. diff --git a/src/hooks/useDetectClickOutside.ts b/src/hooks/useDetectClickOutside.ts new file mode 100644 index 0000000..e6b1533 --- /dev/null +++ b/src/hooks/useDetectClickOutside.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +export function useDetectClickOutside( + ref: React.RefObject, + callback: () => void +) { + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, callback]); +} diff --git a/src/lib/devices.ts b/src/lib/devices.ts new file mode 100644 index 0000000..f6757ec --- /dev/null +++ b/src/lib/devices.ts @@ -0,0 +1,27 @@ +function isAndroid(): boolean { + return ( + typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent) + ); +} + +function isSmallIOS(): boolean { + return ( + typeof navigator !== 'undefined' && /iPhone|iPod/.test(navigator.userAgent) + ); +} + +function isLargeIOS(): boolean { + return ( + typeof navigator !== 'undefined' && + (/iPad/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + ); +} + +function isIOS(): boolean { + return isSmallIOS() || isLargeIOS(); +} + +export function isMobile(): boolean { + return isAndroid() || isIOS(); +} diff --git a/src/lib/localStorage.ts b/src/lib/localStorage.ts new file mode 100644 index 0000000..0d86b65 --- /dev/null +++ b/src/lib/localStorage.ts @@ -0,0 +1,25 @@ +export function setItem(key: string, value: T) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn('Failed to save item:', error); + } +} + +export function getItem(key: string): T | null { + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.warn('Failed to load item:', error); + return null; + } +} + +export function removeItem(key: string) { + try { + localStorage.removeItem(key); + } catch (error) { + console.warn('Failed to remove item:', error); + } +}