Shreyaschorge bed12bd303
Clean up
2025-07-07 20:43:46 +05:30

423 lines
12 KiB
TypeScript

'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<string | null>(null);
const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(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<string | null>(
null
);
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(
null
);
// Helper function to create a signer
const createSigner = useCallback(async () => {
try {
const response = await fetch('/api/auth/signer', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to create signer');
}
const signerData = await response.json();
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 {
// 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();
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 {
setSignersLoading(true);
const response = await fetch(
`/api/auth/signers?message=${encodeURIComponent(
message
)}&signature=${signature}`
);
const signerData = await response.json();
if (response.ok) {
// Store signers in localStorage, preserving existing auth data
const existingAuth = getItem<StoredAuthState>(STORAGE_KEY);
const updatedState: StoredAuthState = {
...existingAuth,
isAuthenticated: true,
signers: signerData.signers || [],
lastSignInTime: existingAuth?.lastSignInTime || Date.now(),
};
setItem<StoredAuthState>(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) => {
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();
if (signerData.status === '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<StoredAuthState>(STORAGE_KEY);
if (stored && stored.isAuthenticated) {
setStoredAuth(stored);
}
}, []);
// Success callback - this is critical!
const onSuccessCallback = useCallback((res: unknown) => {
const existingAuth = getItem<StoredAuthState>(STORAGE_KEY);
const authState: StoredAuthState = {
isAuthenticated: true,
userData: res as StoredAuthState['userData'],
lastSignInTime: Date.now(),
signers: existingAuth?.signers || [], // Preserve existing signers
};
setItem<StoredAuthState>(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) {
connect();
}
}, [nonce, channelToken, connect]);
// Handle fetching signers after successful authentication
useEffect(() => {
if (data?.message && 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) {
// 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
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(() => {
if (isError) {
reconnect();
}
setDialogStep('signin');
setShowDialog(true);
signIn();
// Open mobile app if on mobile and URL is available
if (url && isMobile()) {
window.open(url, '_blank');
}
}, [isError, reconnect, signIn, url]);
const handleSignOut = useCallback(() => {
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;
// Show loading state while nonce is being fetched or signers are loading
if (!nonce || signersLoading) {
return (
<div className='flex items-center justify-center'>
<div className='flex items-center gap-3 px-4 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg'>
<div className='spinner w-4 h-4' />
<span className='text-sm text-gray-600 dark:text-gray-400'>
Loading...
</span>
</div>
</div>
);
}
return (
<>
{authenticated ? (
<ProfileButton userData={userData} onSignOut={handleSignOut} />
) : (
<Button
onClick={handleSignIn}
disabled={!url}
className={cn(
'btn btn-primary flex items-center gap-3',
'disabled:opacity-50 disabled:cursor-not-allowed',
'transform transition-all duration-200 active:scale-[0.98]',
!url && 'cursor-not-allowed'
)}
>
{!url ? (
<>
<div className='spinner-primary w-5 h-5' />
<span>Initializing...</span>
</>
) : (
<>
<span>Sign in with Neynar</span>
</>
)}
</Button>
)}
{/* Unified Auth Dialog */}
{url && (
<AuthDialog
open={showDialog && !isMobile()}
onClose={() => {
setShowDialog(false);
setDialogStep('signin');
setSignerApprovalUrl(null);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
}}
url={url}
isError={isError}
error={error}
step={dialogStep}
isLoading={signersLoading}
signerApprovalUrl={signerApprovalUrl}
/>
)}
</>
);
}