mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-11-16 08:08:56 -05:00
423 lines
12 KiB
TypeScript
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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|