mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-11-16 08:08:56 -05:00
feat: replace next-auth with quick auth
This commit is contained in:
parent
c713d53054
commit
86029b2bd9
@ -460,6 +460,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
|||||||
'@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0',
|
'@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0',
|
||||||
'@farcaster/miniapp-wagmi-connector': '^1.0.0',
|
'@farcaster/miniapp-wagmi-connector': '^1.0.0',
|
||||||
'@farcaster/mini-app-solana': '>=0.0.17 <1.0.0',
|
'@farcaster/mini-app-solana': '>=0.0.17 <1.0.0',
|
||||||
|
'@farcaster/quick-auth': '>=0.0.7 <1.0.0',
|
||||||
'@neynar/react': '^1.2.5',
|
'@neynar/react': '^1.2.5',
|
||||||
'@radix-ui/react-label': '^2.1.1',
|
'@radix-ui/react-label': '^2.1.1',
|
||||||
'@solana/wallet-adapter-react': '^0.15.38',
|
'@solana/wallet-adapter-react': '^0.15.38',
|
||||||
@ -471,7 +472,6 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
|||||||
'lucide-react': '^0.469.0',
|
'lucide-react': '^0.469.0',
|
||||||
mipd: '^0.0.7',
|
mipd: '^0.0.7',
|
||||||
next: '^15',
|
next: '^15',
|
||||||
'next-auth': '^4.24.11',
|
|
||||||
react: '^19',
|
react: '^19',
|
||||||
'react-dom': '^19',
|
'react-dom': '^19',
|
||||||
'tailwind-merge': '^2.6.0',
|
'tailwind-merge': '^2.6.0',
|
||||||
@ -483,6 +483,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
|||||||
};
|
};
|
||||||
|
|
||||||
packageJson.devDependencies = {
|
packageJson.devDependencies = {
|
||||||
|
"@types/inquirer": "^9.0.8",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@ -494,8 +495,8 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
|||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5",
|
"ts-node": "^10.9.2",
|
||||||
"ts-node": "^10.9.2"
|
"typescript": "^5"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add Neynar SDK if selected
|
// Add Neynar SDK if selected
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neynar/create-farcaster-mini-app",
|
"name": "@neynar/create-farcaster-mini-app",
|
||||||
"version": "1.6.2",
|
"version": "1.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
"access": "public",
|
"access": "public",
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import os from 'os';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import crypto from 'crypto';
|
|
||||||
import { Vercel } from '@vercel/sdk';
|
import { Vercel } from '@vercel/sdk';
|
||||||
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants';
|
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants';
|
||||||
|
|
||||||
@ -130,7 +129,7 @@ async function checkRequiredEnvVars(): Promise<void> {
|
|||||||
|
|
||||||
process.env.SPONSOR_SIGNER = sponsorSigner.toString();
|
process.env.SPONSOR_SIGNER = sponsorSigner.toString();
|
||||||
|
|
||||||
if (storeSeedPhrase) {
|
if (process.env.SEED_PHRASE) {
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
'.env.local',
|
'.env.local',
|
||||||
`\nSPONSOR_SIGNER="${sponsorSigner}"`
|
`\nSPONSOR_SIGNER="${sponsorSigner}"`
|
||||||
@ -287,17 +286,26 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get existing environment variables
|
// Get existing environment variables
|
||||||
const existingVars = await vercelClient.projects.getEnvironmentVariables({
|
const existingVars = await vercelClient.projects.filterProjectEnvs({
|
||||||
idOrName: projectId,
|
idOrName: projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingVar = existingVars.envs?.find((env: any) =>
|
// Handle different response types
|
||||||
|
let envs: any[] = [];
|
||||||
|
if ('envs' in existingVars && Array.isArray(existingVars.envs)) {
|
||||||
|
envs = existingVars.envs;
|
||||||
|
} else if ('target' in existingVars && 'key' in existingVars) {
|
||||||
|
// Single environment variable response
|
||||||
|
envs = [existingVars];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingVar = envs.find((env: any) =>
|
||||||
env.key === key && env.target?.includes('production')
|
env.key === key && env.target?.includes('production')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingVar) {
|
if (existingVar && existingVar.id) {
|
||||||
// Update existing variable
|
// Update existing variable
|
||||||
await vercelClient.projects.editEnvironmentVariable({
|
await vercelClient.projects.editProjectEnv({
|
||||||
idOrName: projectId,
|
idOrName: projectId,
|
||||||
id: existingVar.id,
|
id: existingVar.id,
|
||||||
requestBody: {
|
requestBody: {
|
||||||
@ -308,7 +316,7 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
|
|||||||
console.log(`✅ Updated environment variable: ${key}`);
|
console.log(`✅ Updated environment variable: ${key}`);
|
||||||
} else {
|
} else {
|
||||||
// Create new variable
|
// Create new variable
|
||||||
await vercelClient.projects.createEnvironmentVariable({
|
await vercelClient.projects.createProjectEnv({
|
||||||
idOrName: projectId,
|
idOrName: projectId,
|
||||||
requestBody: {
|
requestBody: {
|
||||||
key: key,
|
key: key,
|
||||||
@ -426,7 +434,7 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string,
|
|||||||
|
|
||||||
while (Date.now() - startTime < maxWaitTime) {
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
try {
|
try {
|
||||||
const deployments = await vercelClient?.deployments.list({
|
const deployments = await vercelClient?.deployments.getDeployments({
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
@ -558,12 +566,15 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
|||||||
|
|
||||||
if (vercelClient) {
|
if (vercelClient) {
|
||||||
try {
|
try {
|
||||||
const project = await vercelClient.projects.get({
|
const projects = await vercelClient.projects.getProjects({});
|
||||||
idOrName: projectId,
|
const project = projects.projects.find(p => p.id === projectId || p.name === projectId);
|
||||||
});
|
if (project) {
|
||||||
projectName = project.name;
|
projectName = project.name;
|
||||||
domain = `${projectName}.vercel.app`;
|
domain = `${projectName}.vercel.app`;
|
||||||
console.log('🌐 Using project name for domain:', domain);
|
console.log('🌐 Using project name for domain:', domain);
|
||||||
|
} else {
|
||||||
|
throw new Error('Project not found');
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
console.warn('⚠️ Could not get project details via SDK, using CLI fallback');
|
console.warn('⚠️ Could not get project details via SDK, using CLI fallback');
|
||||||
@ -615,12 +626,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare environment variables
|
// Prepare environment variables
|
||||||
const nextAuthSecret =
|
|
||||||
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex');
|
|
||||||
const vercelEnv = {
|
const vercelEnv = {
|
||||||
NEXTAUTH_SECRET: nextAuthSecret,
|
|
||||||
AUTH_SECRET: nextAuthSecret,
|
|
||||||
NEXTAUTH_URL: `https://${domain}`,
|
|
||||||
NEXT_PUBLIC_URL: `https://${domain}`,
|
NEXT_PUBLIC_URL: `https://${domain}`,
|
||||||
|
|
||||||
...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }),
|
...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }),
|
||||||
@ -714,7 +720,6 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
|||||||
console.log('🔄 Updating environment variables with correct domain...');
|
console.log('🔄 Updating environment variables with correct domain...');
|
||||||
|
|
||||||
const updatedEnv: Record<string, string | object> = {
|
const updatedEnv: Record<string, string | object> = {
|
||||||
NEXTAUTH_URL: `https://${actualDomain}`,
|
|
||||||
NEXT_PUBLIC_URL: `https://${actualDomain}`,
|
NEXT_PUBLIC_URL: `https://${actualDomain}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -149,7 +149,7 @@ async function startDev() {
|
|||||||
|
|
||||||
nextDev = spawn(nextBin, ['dev', '-p', port.toString()], {
|
nextDev = spawn(nextBin, ['dev', '-p', port.toString()], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl, NEXTAUTH_URL: miniAppUrl },
|
env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl },
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
shell: process.platform === 'win32' // Add shell option for Windows
|
shell: process.platform === 'win32' // Add shell option for Windows
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
import NextAuth from "next-auth"
|
|
||||||
import { authOptions } from "~/auth"
|
|
||||||
|
|
||||||
const handler = NextAuth(authOptions)
|
|
||||||
|
|
||||||
export { handler as GET, handler as POST }
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
import { authOptions } from '~/auth';
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user?.fid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'No authenticated session found' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { signers, user } = body;
|
|
||||||
|
|
||||||
if (!signers || !user) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Signers and user are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For NextAuth to update the session, we need to trigger the JWT callback
|
|
||||||
// This is typically done by calling the session endpoint with updated data
|
|
||||||
// However, we can't directly modify the session token from here
|
|
||||||
|
|
||||||
// Instead, we'll store the data temporarily and let the client refresh the session
|
|
||||||
// The session will be updated when the JWT callback is triggered
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Session update prepared',
|
|
||||||
signers,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error preparing session update:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to prepare session update' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
src/app/api/auth/validate/route.ts
Normal file
52
src/app/api/auth/validate/route.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createClient, Errors } from '@farcaster/quick-auth';
|
||||||
|
|
||||||
|
const client = createClient();
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Token is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get domain from environment or request
|
||||||
|
const domain = process.env.NEXT_PUBLIC_URL
|
||||||
|
? new URL(process.env.NEXT_PUBLIC_URL).hostname
|
||||||
|
: request.headers.get('host') || 'localhost';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the official QuickAuth library to verify the JWT
|
||||||
|
const payload = await client.verifyJwt({
|
||||||
|
token,
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
fid: payload.sub,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Errors.InvalidTokenError) {
|
||||||
|
console.info('Invalid token:', e.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid token' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token validation error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/app/app/.well-known/farcaster.json/route.ts
Normal file
12
src/app/app/.well-known/farcaster.json/route.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getFarcasterDomainManifest } from '~/lib/utils';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const config = await getFarcasterDomainManifest();
|
||||||
|
return NextResponse.json(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating metadata:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/app/api/auth/nonce/route.ts
Normal file
16
src/app/app/api/auth/nonce/route.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getNeynarClient } from '~/lib/neynar';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const client = getNeynarClient();
|
||||||
|
const response = await client.fetchNonce();
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching nonce:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch nonce' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/app/api/auth/session-signers/route.ts
Normal file
43
src/app/app/api/auth/session-signers/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getNeynarClient } from '~/lib/neynar';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const message = searchParams.get('message');
|
||||||
|
const signature = searchParams.get('signature');
|
||||||
|
|
||||||
|
if (!message || !signature) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message and signature are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getNeynarClient();
|
||||||
|
const data = await client.fetchSigners({ message, signature });
|
||||||
|
const signers = data.signers;
|
||||||
|
|
||||||
|
// Fetch user data if signers exist
|
||||||
|
let user = null;
|
||||||
|
if (signers && signers.length > 0 && signers[0].fid) {
|
||||||
|
const {
|
||||||
|
users: [fetchedUser],
|
||||||
|
} = await client.fetchBulkUsers({
|
||||||
|
fids: [signers[0].fid],
|
||||||
|
});
|
||||||
|
user = fetchedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
signers,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in session-signers API:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch signers' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/app/api/auth/signer/route.ts
Normal file
42
src/app/app/api/auth/signer/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getNeynarClient } from '~/lib/neynar';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const neynarClient = getNeynarClient();
|
||||||
|
const signer = await neynarClient.createSigner();
|
||||||
|
return NextResponse.json(signer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching signer:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch signer' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const signerUuid = searchParams.get('signerUuid');
|
||||||
|
|
||||||
|
if (!signerUuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'signerUuid is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const neynarClient = getNeynarClient();
|
||||||
|
const signer = await neynarClient.lookupSigner({
|
||||||
|
signerUuid,
|
||||||
|
});
|
||||||
|
return NextResponse.json(signer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching signed key:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch signed key' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/app/app/api/auth/signer/signed_key/route.ts
Normal file
91
src/app/app/api/auth/signer/signed_key/route.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getNeynarClient } from '~/lib/neynar';
|
||||||
|
import { mnemonicToAccount } from 'viem/accounts';
|
||||||
|
import {
|
||||||
|
SIGNED_KEY_REQUEST_TYPE,
|
||||||
|
SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN,
|
||||||
|
} from '~/lib/constants';
|
||||||
|
|
||||||
|
const postRequiredFields = ['signerUuid', 'publicKey'];
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
for (const field of postRequiredFields) {
|
||||||
|
if (!body[field]) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `${field} is required` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { signerUuid, publicKey, redirectUrl } = body;
|
||||||
|
|
||||||
|
if (redirectUrl && typeof redirectUrl !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'redirectUrl must be a string' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the app's account from seed phrase
|
||||||
|
const seedPhrase = process.env.SEED_PHRASE;
|
||||||
|
const shouldSponsor = process.env.SPONSOR_SIGNER === 'true';
|
||||||
|
|
||||||
|
if (!seedPhrase) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'App configuration missing (SEED_PHRASE or FID)' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const neynarClient = getNeynarClient();
|
||||||
|
|
||||||
|
const account = mnemonicToAccount(seedPhrase);
|
||||||
|
|
||||||
|
const {
|
||||||
|
user: { fid },
|
||||||
|
} = await neynarClient.lookupUserByCustodyAddress({
|
||||||
|
custodyAddress: account.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appFid = fid;
|
||||||
|
|
||||||
|
// Generate deadline (24 hours from now)
|
||||||
|
const deadline = Math.floor(Date.now() / 1000) + 86400;
|
||||||
|
|
||||||
|
// Generate EIP-712 signature
|
||||||
|
const signature = await account.signTypedData({
|
||||||
|
domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN,
|
||||||
|
types: {
|
||||||
|
SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE,
|
||||||
|
},
|
||||||
|
primaryType: 'SignedKeyRequest',
|
||||||
|
message: {
|
||||||
|
requestFid: BigInt(appFid),
|
||||||
|
key: publicKey,
|
||||||
|
deadline: BigInt(deadline),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signer = await neynarClient.registerSignedKey({
|
||||||
|
appFid,
|
||||||
|
deadline,
|
||||||
|
signature,
|
||||||
|
signerUuid,
|
||||||
|
...(redirectUrl && { redirectUrl }),
|
||||||
|
...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(signer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering signed key:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to register signed key' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/app/api/auth/signers/route.ts
Normal file
38
src/app/app/api/auth/signers/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getNeynarClient } from '~/lib/neynar';
|
||||||
|
|
||||||
|
const requiredParams = ['message', 'signature'];
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const params: Record<string, string | null> = {};
|
||||||
|
for (const param of requiredParams) {
|
||||||
|
params[param] = searchParams.get(param);
|
||||||
|
if (!params[param]) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `${param} parameter is required`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = params.message as string;
|
||||||
|
const signature = params.signature as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getNeynarClient();
|
||||||
|
const data = await client.fetchSigners({ message, signature });
|
||||||
|
const signers = data.signers;
|
||||||
|
return NextResponse.json({
|
||||||
|
signers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching signers:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch signers' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/app/api/auth/validate/route.ts
Normal file
52
src/app/app/api/auth/validate/route.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createClient, Errors } from '@farcaster/quick-auth';
|
||||||
|
|
||||||
|
const client = createClient();
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Token is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get domain from environment or request
|
||||||
|
const domain = process.env.NEXT_PUBLIC_URL
|
||||||
|
? new URL(process.env.NEXT_PUBLIC_URL).hostname
|
||||||
|
: request.headers.get('host') || 'localhost';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the official QuickAuth library to verify the JWT
|
||||||
|
const payload = await client.verifyJwt({
|
||||||
|
token,
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
fid: payload.sub,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Errors.InvalidTokenError) {
|
||||||
|
console.info('Invalid token:', e.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid token' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token validation error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/app/api/best-friends/route.ts
Normal file
46
src/app/app/api/best-friends/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const apiKey = process.env.NEYNAR_API_KEY;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const fid = searchParams.get('fid');
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FID parameter is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Neynar API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { users } = await response.json() as { users: { user: { fid: number; username: string } }[] };
|
||||||
|
|
||||||
|
return NextResponse.json({ bestFriends: users });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch best friends:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch best friends. Please check your Neynar API key and try again.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/app/api/opengraph-image/route.tsx
Normal file
30
src/app/app/api/opengraph-image/route.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getNeynarUser } from "~/lib/neynar";
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const fid = searchParams.get('fid');
|
||||||
|
|
||||||
|
const user = fid ? await getNeynarUser(Number(fid)) : null;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div tw="flex h-full w-full flex-col justify-center items-center relative bg-primary">
|
||||||
|
{user?.pfp_url && (
|
||||||
|
<div tw="flex w-96 h-96 rounded-full overflow-hidden mb-8 border-8 border-white">
|
||||||
|
<img src={user.pfp_url} alt="Profile" tw="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 tw="text-8xl text-white">{user?.display_name ? `Hello from ${user.display_name ?? user.username}!` : 'Hello!'}</h1>
|
||||||
|
<p tw="text-5xl mt-4 text-white opacity-80">Powered by Neynar 🪐</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/app/api/send-notification/route.ts
Normal file
57
src/app/app/api/send-notification/route.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { notificationDetailsSchema } from "@farcaster/miniapp-sdk";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { setUserNotificationDetails } from "~/lib/kv";
|
||||||
|
import { sendMiniAppNotification } from "~/lib/notifs";
|
||||||
|
import { sendNeynarMiniAppNotification } from "~/lib/neynar";
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
fid: z.number(),
|
||||||
|
notificationDetails: notificationDetailsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// If Neynar is enabled, we don't need to store notification details
|
||||||
|
// as they will be managed by Neynar's system
|
||||||
|
const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
|
||||||
|
|
||||||
|
const requestJson = await request.json();
|
||||||
|
const requestBody = requestSchema.safeParse(requestJson);
|
||||||
|
|
||||||
|
if (requestBody.success === false) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, errors: requestBody.error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only store notification details if not using Neynar
|
||||||
|
if (!neynarEnabled) {
|
||||||
|
await setUserNotificationDetails(
|
||||||
|
Number(requestBody.data.fid),
|
||||||
|
requestBody.data.notificationDetails
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use appropriate notification function based on Neynar status
|
||||||
|
const sendNotification = neynarEnabled ? sendNeynarMiniAppNotification : sendMiniAppNotification;
|
||||||
|
const sendResult = await sendNotification({
|
||||||
|
fid: Number(requestBody.data.fid),
|
||||||
|
title: "Test notification",
|
||||||
|
body: "Sent at " + new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sendResult.state === "error") {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: sendResult.error },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
} else if (sendResult.state === "rate_limit") {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "Rate limited" },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
39
src/app/app/api/users/route.ts
Normal file
39
src/app/app/api/users/route.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const apiKey = process.env.NEYNAR_API_KEY;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const fids = searchParams.get('fids');
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fids) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FIDs parameter is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const neynar = new NeynarAPIClient({ apiKey });
|
||||||
|
const fidsArray = fids.split(',').map(fid => parseInt(fid.trim()));
|
||||||
|
|
||||||
|
const { users } = await neynar.fetchBulkUsers({
|
||||||
|
fids: fidsArray,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ users });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch users. Please check your Neynar API key and try again.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/app/app/api/webhook/route.ts
Normal file
91
src/app/app/api/webhook/route.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
ParseWebhookEvent,
|
||||||
|
parseWebhookEvent,
|
||||||
|
verifyAppKeyWithNeynar,
|
||||||
|
} from "@farcaster/miniapp-node";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { APP_NAME } from "~/lib/constants";
|
||||||
|
import {
|
||||||
|
deleteUserNotificationDetails,
|
||||||
|
setUserNotificationDetails,
|
||||||
|
} from "~/lib/kv";
|
||||||
|
import { sendMiniAppNotification } from "~/lib/notifs";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// If Neynar is enabled, we don't need to handle webhooks here
|
||||||
|
// as they will be handled by Neynar's webhook endpoint
|
||||||
|
const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
|
||||||
|
if (neynarEnabled) {
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestJson = await request.json();
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await parseWebhookEvent(requestJson, verifyAppKeyWithNeynar);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const error = e as ParseWebhookEvent.ErrorType;
|
||||||
|
|
||||||
|
switch (error.name) {
|
||||||
|
case "VerifyJsonFarcasterSignature.InvalidDataError":
|
||||||
|
case "VerifyJsonFarcasterSignature.InvalidEventDataError":
|
||||||
|
// The request data is invalid
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
case "VerifyJsonFarcasterSignature.InvalidAppKeyError":
|
||||||
|
// The app key is invalid
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: error.message },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
case "VerifyJsonFarcasterSignature.VerifyAppKeyError":
|
||||||
|
// Internal error verifying the app key (caller may want to try again)
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fid = data.fid;
|
||||||
|
const event = data.event;
|
||||||
|
|
||||||
|
// Only handle notifications if Neynar is not enabled
|
||||||
|
// When Neynar is enabled, notifications are handled through their webhook
|
||||||
|
switch (event.event) {
|
||||||
|
case "frame_added":
|
||||||
|
if (event.notificationDetails) {
|
||||||
|
await setUserNotificationDetails(fid, event.notificationDetails);
|
||||||
|
await sendMiniAppNotification({
|
||||||
|
fid,
|
||||||
|
title: `Welcome to ${APP_NAME}`,
|
||||||
|
body: "Mini app is now added to your client",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await deleteUserNotificationDetails(fid);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "frame_removed":
|
||||||
|
await deleteUserNotificationDetails(fid);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "notifications_enabled":
|
||||||
|
await setUserNotificationDetails(fid, event.notificationDetails);
|
||||||
|
await sendMiniAppNotification({
|
||||||
|
fid,
|
||||||
|
title: `Welcome to ${APP_NAME}`,
|
||||||
|
body: "Notifications are now enabled",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "notifications_disabled":
|
||||||
|
await deleteUserNotificationDetails(fid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
15
src/app/app/app.tsx
Normal file
15
src/app/app/app.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { APP_NAME } from "~/lib/constants";
|
||||||
|
|
||||||
|
// note: dynamic import is required for components that use the Frame SDK
|
||||||
|
const AppComponent = dynamic(() => import("~/components/App"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App(
|
||||||
|
{ title }: { title?: string } = { title: APP_NAME }
|
||||||
|
) {
|
||||||
|
return <AppComponent title={title} />;
|
||||||
|
}
|
||||||
BIN
src/app/app/favicon.ico
Normal file
BIN
src/app/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
118
src/app/app/globals.css
Normal file
118
src/app/app/globals.css
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* DESIGN SYSTEM - DO NOT EDIT UNLESS NECESSARY
|
||||||
|
*
|
||||||
|
* This file contains the centralized design system for the mini app.
|
||||||
|
* These component classes establish the visual consistency across all components.
|
||||||
|
*
|
||||||
|
* ⚠️ AI SHOULD NOT NORMALLY EDIT THIS FILE ⚠️
|
||||||
|
*
|
||||||
|
* Instead of modifying these classes, AI should:
|
||||||
|
* 1. Use existing component classes (e.g., .btn, .card, .input)
|
||||||
|
* 2. Use Tailwind utilities for one-off styling
|
||||||
|
* 3. Create new React components rather than new CSS classes
|
||||||
|
* 4. Only edit this file for specific bug fixes or accessibility improvements
|
||||||
|
*
|
||||||
|
* When AI needs to style something:
|
||||||
|
* ✅ Good: <button className="btn btn-primary">Click me</button>
|
||||||
|
* ✅ Good: <div className="bg-primary text-white px-4 py-2 rounded">Custom</div>
|
||||||
|
* ❌ Bad: Adding new CSS classes here for component-specific styling
|
||||||
|
*
|
||||||
|
* This design system is intentionally minimal to prevent bloat and maintain consistency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: 'Inter', Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Global container styles for consistent layout */
|
||||||
|
.container {
|
||||||
|
@apply mx-auto max-w-md px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-wide {
|
||||||
|
@apply mx-auto max-w-lg px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-narrow {
|
||||||
|
@apply mx-auto max-w-sm px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global card styles */
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-primary {
|
||||||
|
@apply bg-primary/10 border-primary/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global button styles */
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary text-white hover:bg-primary-dark focus:ring-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-secondary text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-secondary-dark dark:text-gray-100 dark:hover:bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
@apply border border-gray-300 bg-transparent hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:hover:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global input styles */
|
||||||
|
.input {
|
||||||
|
@apply block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 placeholder-gray-500 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global loading spinner */
|
||||||
|
.spinner {
|
||||||
|
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-primary {
|
||||||
|
@apply animate-spin rounded-full border-2 border-white border-t-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global focus styles */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/app/layout.tsx
Normal file
24
src/app/app/layout.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import "~/app/globals.css";
|
||||||
|
import { Providers } from "~/app/providers";
|
||||||
|
import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: APP_NAME,
|
||||||
|
description: APP_DESCRIPTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/app/page.tsx
Normal file
24
src/app/app/page.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import App from "./app";
|
||||||
|
import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from "~/lib/constants";
|
||||||
|
import { getMiniAppEmbedMetadata } from "~/lib/utils";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: APP_NAME,
|
||||||
|
openGraph: {
|
||||||
|
title: APP_NAME,
|
||||||
|
description: APP_DESCRIPTION,
|
||||||
|
images: [APP_OG_IMAGE_URL],
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
"fc:frame": JSON.stringify(getMiniAppEmbedMetadata()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (<App />);
|
||||||
|
}
|
||||||
34
src/app/app/providers.tsx
Normal file
34
src/app/app/providers.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { MiniAppProvider } from '@neynar/react';
|
||||||
|
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
|
||||||
|
import { ANALYTICS_ENABLED } from '~/lib/constants';
|
||||||
|
|
||||||
|
const WagmiProvider = dynamic(
|
||||||
|
() => import('~/components/providers/WagmiProvider'),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Providers({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const solanaEndpoint =
|
||||||
|
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
|
||||||
|
return (
|
||||||
|
<WagmiProvider>
|
||||||
|
<MiniAppProvider
|
||||||
|
analyticsEnabled={ANALYTICS_ENABLED}
|
||||||
|
backButtonEnabled={true}
|
||||||
|
>
|
||||||
|
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
|
||||||
|
{children}
|
||||||
|
</SafeFarcasterSolanaProvider>
|
||||||
|
</MiniAppProvider>
|
||||||
|
</WagmiProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/app/app/share/[fid]/page.tsx
Normal file
34
src/app/app/share/[fid]/page.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { APP_URL, APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
|
||||||
|
import { getMiniAppEmbedMetadata } from "~/lib/utils";
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
// This is an example of how to generate a dynamically generated share page based on fid:
|
||||||
|
// Sharing this route e.g. exmaple.com/share/123 will generate a share page for fid 123,
|
||||||
|
// with the image dynamically generated by the opengraph-image API route.
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ fid: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { fid } = await params;
|
||||||
|
const imageUrl = `${APP_URL}/api/opengraph-image?fid=${fid}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${APP_NAME} - Share`,
|
||||||
|
openGraph: {
|
||||||
|
title: APP_NAME,
|
||||||
|
description: APP_DESCRIPTION,
|
||||||
|
images: [imageUrl],
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
"fc:frame": JSON.stringify(getMiniAppEmbedMetadata(imageUrl)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharePage() {
|
||||||
|
// redirect to home page
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { getSession } from "~/auth"
|
|
||||||
import "~/app/globals.css";
|
import "~/app/globals.css";
|
||||||
import { Providers } from "~/app/providers";
|
import { Providers } from "~/app/providers";
|
||||||
import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
|
import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
|
||||||
@ -15,12 +14,10 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const session = await getSession()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<Providers session={session}>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import type { Session } from 'next-auth';
|
|
||||||
import { SessionProvider } from 'next-auth/react';
|
|
||||||
import { MiniAppProvider } from '@neynar/react';
|
import { MiniAppProvider } from '@neynar/react';
|
||||||
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
|
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
|
||||||
import { ANALYTICS_ENABLED } from '~/lib/constants';
|
import { ANALYTICS_ENABLED } from '~/lib/constants';
|
||||||
import { AuthKitProvider } from '@farcaster/auth-kit';
|
|
||||||
|
|
||||||
const WagmiProvider = dynamic(
|
const WagmiProvider = dynamic(
|
||||||
() => import('~/components/providers/WagmiProvider'),
|
() => import('~/components/providers/WagmiProvider'),
|
||||||
@ -16,26 +13,22 @@ const WagmiProvider = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function Providers({
|
export function Providers({
|
||||||
session,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
session: Session | null;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const solanaEndpoint =
|
const solanaEndpoint =
|
||||||
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
|
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
|
||||||
return (
|
return (
|
||||||
<SessionProvider session={session}>
|
<WagmiProvider>
|
||||||
<WagmiProvider>
|
<MiniAppProvider
|
||||||
<MiniAppProvider
|
analyticsEnabled={ANALYTICS_ENABLED}
|
||||||
analyticsEnabled={ANALYTICS_ENABLED}
|
backButtonEnabled={true}
|
||||||
backButtonEnabled={true}
|
>
|
||||||
>
|
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
|
||||||
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
|
{children}
|
||||||
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
|
</SafeFarcasterSolanaProvider>
|
||||||
</SafeFarcasterSolanaProvider>
|
</MiniAppProvider>
|
||||||
</MiniAppProvider>
|
</WagmiProvider>
|
||||||
</WagmiProvider>
|
|
||||||
</SessionProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
441
src/auth.ts
441
src/auth.ts
@ -1,439 +1,10 @@
|
|||||||
import { AuthOptions, getServerSession } from 'next-auth';
|
import { sdk } from '@farcaster/miniapp-sdk';
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
|
||||||
import { createAppClient, viemConnector } from '@farcaster/auth-client';
|
|
||||||
|
|
||||||
declare module 'next-auth' {
|
// Export QuickAuth from the SDK
|
||||||
interface Session {
|
export const quickAuth = sdk.quickAuth;
|
||||||
provider?: string;
|
|
||||||
user?: {
|
|
||||||
fid: number;
|
|
||||||
object?: 'user';
|
|
||||||
username?: string;
|
|
||||||
display_name?: string;
|
|
||||||
pfp_url?: string;
|
|
||||||
custody_address?: string;
|
|
||||||
profile?: {
|
|
||||||
bio: {
|
|
||||||
text: string;
|
|
||||||
mentioned_profiles?: Array<{
|
|
||||||
object: 'user_dehydrated';
|
|
||||||
fid: number;
|
|
||||||
username: string;
|
|
||||||
display_name: string;
|
|
||||||
pfp_url: string;
|
|
||||||
custody_address: string;
|
|
||||||
}>;
|
|
||||||
mentioned_profiles_ranges?: Array<{
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
location?: {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
address: {
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
country: string;
|
|
||||||
country_code: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
follower_count?: number;
|
|
||||||
following_count?: number;
|
|
||||||
verifications?: string[];
|
|
||||||
verified_addresses?: {
|
|
||||||
eth_addresses: string[];
|
|
||||||
sol_addresses: string[];
|
|
||||||
primary: {
|
|
||||||
eth_address: string;
|
|
||||||
sol_address: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
verified_accounts?: Array<Record<string, unknown>>;
|
|
||||||
power_badge?: boolean;
|
|
||||||
url?: string;
|
|
||||||
experimental?: {
|
|
||||||
neynar_user_score: number;
|
|
||||||
deprecation_notice: string;
|
|
||||||
};
|
|
||||||
score?: number;
|
|
||||||
};
|
|
||||||
signers?: {
|
|
||||||
object: 'signer';
|
|
||||||
signer_uuid: string;
|
|
||||||
public_key: string;
|
|
||||||
status: 'approved';
|
|
||||||
fid: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
provider?: string;
|
|
||||||
signers?: Array<{
|
|
||||||
object: 'signer';
|
|
||||||
signer_uuid: string;
|
|
||||||
public_key: string;
|
|
||||||
status: 'approved';
|
|
||||||
fid: number;
|
|
||||||
}>;
|
|
||||||
user?: {
|
|
||||||
object: 'user';
|
|
||||||
fid: number;
|
|
||||||
username: string;
|
|
||||||
display_name: string;
|
|
||||||
pfp_url: string;
|
|
||||||
custody_address: string;
|
|
||||||
profile: {
|
|
||||||
bio: {
|
|
||||||
text: string;
|
|
||||||
mentioned_profiles?: Array<{
|
|
||||||
object: 'user_dehydrated';
|
|
||||||
fid: number;
|
|
||||||
username: string;
|
|
||||||
display_name: string;
|
|
||||||
pfp_url: string;
|
|
||||||
custody_address: string;
|
|
||||||
}>;
|
|
||||||
mentioned_profiles_ranges?: Array<{
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
location?: {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
address: {
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
country: string;
|
|
||||||
country_code: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
follower_count: number;
|
|
||||||
following_count: number;
|
|
||||||
verifications: string[];
|
|
||||||
verified_addresses: {
|
|
||||||
eth_addresses: string[];
|
|
||||||
sol_addresses: string[];
|
|
||||||
primary: {
|
|
||||||
eth_address: string;
|
|
||||||
sol_address: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
verified_accounts: Array<Record<string, unknown>>;
|
|
||||||
power_badge: boolean;
|
|
||||||
url?: string;
|
|
||||||
experimental?: {
|
|
||||||
neynar_user_score: number;
|
|
||||||
deprecation_notice: string;
|
|
||||||
};
|
|
||||||
score: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JWT {
|
|
||||||
provider?: string;
|
|
||||||
signers?: Array<{
|
|
||||||
object: 'signer';
|
|
||||||
signer_uuid: string;
|
|
||||||
public_key: string;
|
|
||||||
status: 'approved';
|
|
||||||
fid: number;
|
|
||||||
}>;
|
|
||||||
user?: {
|
|
||||||
object: 'user';
|
|
||||||
fid: number;
|
|
||||||
username: string;
|
|
||||||
display_name: string;
|
|
||||||
pfp_url: string;
|
|
||||||
custody_address: string;
|
|
||||||
profile: {
|
|
||||||
bio: {
|
|
||||||
text: string;
|
|
||||||
mentioned_profiles?: Array<{
|
|
||||||
object: 'user_dehydrated';
|
|
||||||
fid: number;
|
|
||||||
username: string;
|
|
||||||
display_name: string;
|
|
||||||
pfp_url: string;
|
|
||||||
custody_address: string;
|
|
||||||
}>;
|
|
||||||
mentioned_profiles_ranges?: Array<{
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
location?: {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
address: {
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
country: string;
|
|
||||||
country_code: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
follower_count: number;
|
|
||||||
following_count: number;
|
|
||||||
verifications: string[];
|
|
||||||
verified_addresses: {
|
|
||||||
eth_addresses: string[];
|
|
||||||
sol_addresses: string[];
|
|
||||||
primary: {
|
|
||||||
eth_address: string;
|
|
||||||
sol_address: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
verified_accounts?: Array<Record<string, unknown>>;
|
|
||||||
power_badge?: boolean;
|
|
||||||
url?: string;
|
|
||||||
experimental?: {
|
|
||||||
neynar_user_score: number;
|
|
||||||
deprecation_notice: string;
|
|
||||||
};
|
|
||||||
score?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDomainFromUrl(urlString: string | undefined): string {
|
|
||||||
if (!urlString) {
|
|
||||||
console.warn('NEXTAUTH_URL is not set, using localhost:3000 as fallback');
|
|
||||||
return 'localhost:3000';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString);
|
|
||||||
return url.hostname;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid NEXTAUTH_URL:', urlString, error);
|
|
||||||
console.warn('Using localhost:3000 as fallback');
|
|
||||||
return 'localhost:3000';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authOptions: AuthOptions = {
|
|
||||||
// Configure one or more authentication providers
|
|
||||||
providers: [
|
|
||||||
CredentialsProvider({
|
|
||||||
id: 'farcaster',
|
|
||||||
name: 'Sign in with Farcaster',
|
|
||||||
credentials: {
|
|
||||||
message: {
|
|
||||||
label: 'Message',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '0x0',
|
|
||||||
},
|
|
||||||
signature: {
|
|
||||||
label: 'Signature',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '0x0',
|
|
||||||
},
|
|
||||||
nonce: {
|
|
||||||
label: 'Nonce',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'Custom nonce (optional)',
|
|
||||||
},
|
|
||||||
// In a production app with a server, these should be fetched from
|
|
||||||
// your Farcaster data indexer rather than have them accepted as part
|
|
||||||
// of credentials.
|
|
||||||
// question: should these natively use the Neynar API?
|
|
||||||
name: {
|
|
||||||
label: 'Name',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '0x0',
|
|
||||||
},
|
|
||||||
pfp: {
|
|
||||||
label: 'Pfp',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '0x0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async authorize(credentials, req) {
|
|
||||||
const nonce = req?.body?.csrfToken;
|
|
||||||
|
|
||||||
if (!nonce) {
|
|
||||||
console.error('No nonce or CSRF token provided');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const appClient = createAppClient({
|
|
||||||
ethereum: viemConnector(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const domain = getDomainFromUrl(process.env.NEXTAUTH_URL);
|
|
||||||
|
|
||||||
const verifyResponse = await appClient.verifySignInMessage({
|
|
||||||
message: credentials?.message as string,
|
|
||||||
signature: credentials?.signature as `0x${string}`,
|
|
||||||
domain,
|
|
||||||
nonce,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success, fid } = verifyResponse;
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fid.toString(),
|
|
||||||
name: credentials?.name || `User ${fid}`,
|
|
||||||
image: credentials?.pfp || null,
|
|
||||||
provider: 'farcaster',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
CredentialsProvider({
|
|
||||||
id: 'neynar',
|
|
||||||
name: 'Sign in with Neynar',
|
|
||||||
credentials: {
|
|
||||||
message: {
|
|
||||||
label: 'Message',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '0x0',
|
|
||||||
},
|
|
||||||
signature: {
|
|
||||||
label: 'Signature',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '0x0',
|
|
||||||
},
|
|
||||||
nonce: {
|
|
||||||
label: 'Nonce',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'Custom nonce (optional)',
|
|
||||||
},
|
|
||||||
fid: {
|
|
||||||
label: 'FID',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '0',
|
|
||||||
},
|
|
||||||
signers: {
|
|
||||||
label: 'Signers',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'JSON string of signers',
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
label: 'User Data',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'JSON string of user data',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async authorize(credentials) {
|
|
||||||
const nonce = credentials?.nonce;
|
|
||||||
|
|
||||||
if (!nonce) {
|
|
||||||
console.error('No nonce or CSRF token provided for Neynar auth');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Neynar, we can use a different validation approach
|
|
||||||
// This could involve validating against Neynar's API or using their SDK
|
|
||||||
try {
|
|
||||||
// Validate the signature using Farcaster's auth client (same as Farcaster provider)
|
|
||||||
const appClient = createAppClient({
|
|
||||||
ethereum: viemConnector(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const domain = getDomainFromUrl(process.env.NEXTAUTH_URL);
|
|
||||||
|
|
||||||
const verifyResponse = await appClient.verifySignInMessage({
|
|
||||||
message: credentials?.message as string,
|
|
||||||
signature: credentials?.signature as `0x${string}`,
|
|
||||||
domain,
|
|
||||||
nonce,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success, fid } = verifyResponse;
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that the provided FID matches the verified FID
|
|
||||||
if (credentials?.fid && parseInt(credentials.fid) !== fid) {
|
|
||||||
console.error('FID mismatch in Neynar auth');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fid.toString(),
|
|
||||||
provider: 'neynar',
|
|
||||||
signers: credentials?.signers
|
|
||||||
? JSON.parse(credentials.signers)
|
|
||||||
: undefined,
|
|
||||||
user: credentials?.user ? JSON.parse(credentials.user) : undefined,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in Neynar auth:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
|
||||||
session: async ({ session, token }) => {
|
|
||||||
// Set provider at the root level
|
|
||||||
session.provider = token.provider as string;
|
|
||||||
|
|
||||||
if (token.provider === 'farcaster') {
|
|
||||||
// For Farcaster, simple structure
|
|
||||||
session.user = {
|
|
||||||
fid: parseInt(token.sub ?? ''),
|
|
||||||
};
|
|
||||||
} else if (token.provider === 'neynar') {
|
|
||||||
// For Neynar, use full user data structure from user
|
|
||||||
session.user = token.user as typeof session.user;
|
|
||||||
session.signers = token.signers as typeof session.signers;
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
jwt: async ({ token, user }) => {
|
|
||||||
if (user) {
|
|
||||||
token.provider = user.provider;
|
|
||||||
token.signers = user.signers;
|
|
||||||
token.user = user.user;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cookies: {
|
|
||||||
sessionToken: {
|
|
||||||
name: `next-auth.session-token`,
|
|
||||||
options: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'none',
|
|
||||||
path: '/',
|
|
||||||
secure: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
callbackUrl: {
|
|
||||||
name: `next-auth.callback-url`,
|
|
||||||
options: {
|
|
||||||
sameSite: 'none',
|
|
||||||
path: '/',
|
|
||||||
secure: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
csrfToken: {
|
|
||||||
name: `next-auth.csrf-token`,
|
|
||||||
options: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'none',
|
|
||||||
path: '/',
|
|
||||||
secure: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Helper function to get session (for server-side compatibility)
|
||||||
export const getSession = async () => {
|
export const getSession = async () => {
|
||||||
try {
|
// For QuickAuth, sessions are managed by the SDK
|
||||||
return await getServerSession(authOptions);
|
return null;
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting server session:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -16,8 +16,8 @@ export function ProfileButton({
|
|||||||
|
|
||||||
useDetectClickOutside(ref, () => setShowDropdown(false));
|
useDetectClickOutside(ref, () => setShowDropdown(false));
|
||||||
|
|
||||||
const name = userData?.username ?? `!${userData?.fid}`;
|
const name = userData?.username && userData.username.trim() !== '' ? userData.username : `!${userData?.fid}`;
|
||||||
const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png';
|
const pfpUrl = userData?.pfpUrl && userData.pfpUrl.trim() !== '' ? userData.pfpUrl : 'https://farcaster.xyz/avatar.png';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
|
|||||||
@ -1,20 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import '@farcaster/auth-kit/styles.css';
|
|
||||||
import { useSignIn, UseSignInData } from '@farcaster/auth-kit';
|
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import { cn } from '~/lib/utils';
|
import { cn } from '~/lib/utils';
|
||||||
import { Button } from '~/components/ui/Button';
|
import { Button } from '~/components/ui/Button';
|
||||||
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
|
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
|
||||||
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
|
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
|
||||||
import { getItem, removeItem, setItem } from '~/lib/localStorage';
|
|
||||||
import { useMiniApp } from '@neynar/react';
|
import { useMiniApp } from '@neynar/react';
|
||||||
import {
|
|
||||||
signIn as backendSignIn,
|
|
||||||
signOut as backendSignOut,
|
|
||||||
useSession,
|
|
||||||
} from 'next-auth/react';
|
|
||||||
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk';
|
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk';
|
||||||
|
import { useQuickAuth } from '~/hooks/useQuickAuth';
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
fid: number;
|
fid: number;
|
||||||
@ -24,7 +17,6 @@ type User = {
|
|||||||
// Add other user properties as needed
|
// Add other user properties as needed
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'neynar_authenticated_user';
|
|
||||||
const FARCASTER_FID = 9152;
|
const FARCASTER_FID = 9152;
|
||||||
|
|
||||||
interface StoredAuthState {
|
interface StoredAuthState {
|
||||||
@ -98,7 +90,8 @@ export function NeynarAuthButton() {
|
|||||||
const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(null);
|
const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(null);
|
||||||
const [signersLoading, setSignersLoading] = useState(false);
|
const [signersLoading, setSignersLoading] = useState(false);
|
||||||
const { context } = useMiniApp();
|
const { context } = useMiniApp();
|
||||||
const { data: session } = useSession();
|
const { authenticatedUser: quickAuthUser, signIn: quickAuthSignIn, signOut: quickAuthSignOut } = useQuickAuth();
|
||||||
|
|
||||||
// New state for unified dialog flow
|
// New state for unified dialog flow
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>(
|
const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>(
|
||||||
@ -114,6 +107,7 @@ export function NeynarAuthButton() {
|
|||||||
const [signature, setSignature] = useState<string | null>(null);
|
const [signature, setSignature] = useState<string | null>(null);
|
||||||
const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false);
|
const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false);
|
||||||
const signerFlowStartedRef = useRef(false);
|
const signerFlowStartedRef = useRef(false);
|
||||||
|
const [backendUserProfile, setBackendUserProfile] = useState<{ username?: string; pfpUrl?: string }>({});
|
||||||
|
|
||||||
// Determine which flow to use based on context
|
// Determine which flow to use based on context
|
||||||
const useBackendFlow = context !== undefined;
|
const useBackendFlow = context !== undefined;
|
||||||
@ -146,25 +140,15 @@ export function NeynarAuthButton() {
|
|||||||
if (!useBackendFlow) return;
|
if (!useBackendFlow) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For backend flow, we need to sign in again with the additional data
|
// For backend flow, use QuickAuth to sign in
|
||||||
if (message && signature) {
|
if (signers && signers.length > 0) {
|
||||||
const signInData = {
|
await quickAuthSignIn();
|
||||||
message,
|
|
||||||
signature,
|
|
||||||
redirect: false,
|
|
||||||
nonce: nonce || '',
|
|
||||||
fid: user?.fid?.toString() || '',
|
|
||||||
signers: JSON.stringify(signers),
|
|
||||||
user: JSON.stringify(user),
|
|
||||||
};
|
|
||||||
|
|
||||||
await backendSignIn('neynar', signInData);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error updating session with signers:', error);
|
console.error('❌ Error updating session with signers:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[useBackendFlow, message, signature, nonce]
|
[useBackendFlow, quickAuthSignIn]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to fetch user data from Neynar API
|
// Helper function to fetch user data from Neynar API
|
||||||
@ -245,15 +229,17 @@ export function NeynarAuthButton() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if (useBackendFlow) {
|
if (useBackendFlow) {
|
||||||
// For backend flow, update session with signers
|
// For backend flow, update session with signers
|
||||||
if (signerData.signers && signerData.signers.length > 0) {
|
if (signerData.signers && signerData.signers.length > 0) {
|
||||||
const user =
|
// Get user data for the first signer
|
||||||
signerData.user ||
|
let user: StoredAuthState['user'] | null = null;
|
||||||
(await fetchUserData(signerData.signers[0].fid));
|
if (signerData.signers[0].fid) {
|
||||||
|
user = await fetchUserData(signerData.signers[0].fid) as StoredAuthState['user'];
|
||||||
|
}
|
||||||
await updateSessionWithSigners(signerData.signers, user);
|
await updateSessionWithSigners(signerData.signers, user);
|
||||||
}
|
}
|
||||||
return signerData.signers;
|
return signerData.signers;
|
||||||
} else {
|
} else {
|
||||||
// For frontend flow, store in localStorage
|
// For frontend flow, store in memory only
|
||||||
let user: StoredAuthState['user'] | null = null;
|
let user: StoredAuthState['user'] | null = null;
|
||||||
|
|
||||||
if (signerData.signers && signerData.signers.length > 0) {
|
if (signerData.signers && signerData.signers.length > 0) {
|
||||||
@ -263,13 +249,12 @@ export function NeynarAuthButton() {
|
|||||||
user = fetchedUser;
|
user = fetchedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store signers in localStorage, preserving existing auth data
|
// Store signers in memory only
|
||||||
const updatedState: StoredAuthState = {
|
const updatedState: StoredAuthState = {
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
signers: signerData.signers || [],
|
signers: signerData.signers || [],
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
setItem<StoredAuthState>(STORAGE_KEY, updatedState);
|
|
||||||
setStoredAuth(updatedState);
|
setStoredAuth(updatedState);
|
||||||
|
|
||||||
return signerData.signers;
|
return signerData.signers;
|
||||||
@ -384,78 +369,105 @@ export function NeynarAuthButton() {
|
|||||||
generateNonce();
|
generateNonce();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load stored auth state on mount (only for frontend flow)
|
// Backend flow using QuickAuth
|
||||||
useEffect(() => {
|
const handleBackendSignIn = useCallback(async () => {
|
||||||
if (!useBackendFlow) {
|
if (!nonce) {
|
||||||
const stored = getItem<StoredAuthState>(STORAGE_KEY);
|
console.error('❌ No nonce available for backend sign-in');
|
||||||
if (stored && stored.isAuthenticated) {
|
return;
|
||||||
setStoredAuth(stored);
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSignersLoading(true);
|
||||||
|
const result = await sdk.actions.signIn({ nonce });
|
||||||
|
|
||||||
|
setMessage(result.message);
|
||||||
|
setSignature(result.signature);
|
||||||
|
// Use QuickAuth to sign in
|
||||||
|
const signInResult = await quickAuthSignIn();
|
||||||
|
// Fetch user profile after sign in
|
||||||
|
if (quickAuthUser?.fid) {
|
||||||
|
const user = await fetchUserData(quickAuthUser.fid);
|
||||||
|
setBackendUserProfile({
|
||||||
|
username: user?.username || '',
|
||||||
|
pfpUrl: user?.pfp_url || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SignInCore.RejectedByUser) {
|
||||||
|
console.log('ℹ️ Sign-in rejected by user');
|
||||||
|
} else {
|
||||||
|
console.error('❌ Backend sign-in error:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [useBackendFlow]);
|
}, [nonce, quickAuthSignIn, quickAuthUser, fetchUserData]);
|
||||||
|
|
||||||
// Success callback - this is critical!
|
|
||||||
const onSuccessCallback = useCallback(
|
|
||||||
async (res: UseSignInData) => {
|
|
||||||
if (!useBackendFlow) {
|
|
||||||
// Only handle localStorage for frontend flow
|
|
||||||
const existingAuth = getItem<StoredAuthState>(STORAGE_KEY);
|
|
||||||
const user = res.fid ? await fetchUserData(res.fid) : null;
|
|
||||||
const authState: StoredAuthState = {
|
|
||||||
...existingAuth,
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: user as StoredAuthState['user'],
|
|
||||||
signers: existingAuth?.signers || [], // Preserve existing signers
|
|
||||||
};
|
|
||||||
setItem<StoredAuthState>(STORAGE_KEY, authState);
|
|
||||||
setStoredAuth(authState);
|
|
||||||
}
|
|
||||||
// For backend flow, the session will be handled by NextAuth
|
|
||||||
},
|
|
||||||
[useBackendFlow, fetchUserData]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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: frontendSignIn,
|
|
||||||
signOut: frontendSignOut,
|
|
||||||
connect,
|
|
||||||
reconnect,
|
|
||||||
isSuccess,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
channelToken,
|
|
||||||
url,
|
|
||||||
data,
|
|
||||||
validSignature,
|
|
||||||
} = signInState;
|
|
||||||
|
|
||||||
|
// Fetch user profile when quickAuthUser.fid changes (for backend flow)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessage(data?.message || null);
|
if (useBackendFlow && quickAuthUser?.fid) {
|
||||||
setSignature(data?.signature || null);
|
(async () => {
|
||||||
|
const user = await fetchUserData(quickAuthUser.fid);
|
||||||
// Reset the signer flow flag when message/signature change
|
setBackendUserProfile({
|
||||||
if (data?.message && data?.signature) {
|
username: user?.username || '',
|
||||||
|
pfpUrl: user?.pfp_url || '',
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [useBackendFlow, quickAuthUser?.fid, fetchUserData]);
|
||||||
|
|
||||||
|
const handleFrontEndSignIn = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSignersLoading(true);
|
||||||
|
const result = await sdk.actions.signIn({ nonce: nonce || '' });
|
||||||
|
|
||||||
|
setMessage(result.message);
|
||||||
|
setSignature(result.signature);
|
||||||
|
|
||||||
|
// For frontend flow, we'll handle the signer flow in the useEffect
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SignInCore.RejectedByUser) {
|
||||||
|
console.log('ℹ️ Sign-in rejected by user');
|
||||||
|
} else {
|
||||||
|
console.error('❌ Frontend sign-in error:', e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSignersLoading(false);
|
||||||
|
}
|
||||||
|
}, [nonce]);
|
||||||
|
|
||||||
|
const handleSignOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSignersLoading(true);
|
||||||
|
|
||||||
|
if (useBackendFlow) {
|
||||||
|
// Use QuickAuth sign out
|
||||||
|
await quickAuthSignOut();
|
||||||
|
} else {
|
||||||
|
// Frontend flow sign out
|
||||||
|
setStoredAuth(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common cleanup for both flows
|
||||||
|
setShowDialog(false);
|
||||||
|
setDialogStep('signin');
|
||||||
|
setSignerApprovalUrl(null);
|
||||||
|
setMessage(null);
|
||||||
|
setSignature(null);
|
||||||
|
|
||||||
|
// Reset polling interval
|
||||||
|
if (pollingInterval) {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
setPollingInterval(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset signer flow flag
|
||||||
signerFlowStartedRef.current = false;
|
signerFlowStartedRef.current = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error during sign out:', error);
|
||||||
|
// Optionally handle error state
|
||||||
|
} finally {
|
||||||
|
setSignersLoading(false);
|
||||||
}
|
}
|
||||||
}, [data?.message, data?.signature]);
|
}, [useBackendFlow, pollingInterval, quickAuthSignOut]);
|
||||||
|
|
||||||
// Connect for frontend flow when nonce is available
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useBackendFlow && nonce && !channelToken) {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
}, [useBackendFlow, nonce, channelToken, connect]);
|
|
||||||
|
|
||||||
// Handle fetching signers after successful authentication
|
// Handle fetching signers after successful authentication
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -533,103 +545,15 @@ export function NeynarAuthButton() {
|
|||||||
}
|
}
|
||||||
}, [message, signature]); // Simplified dependencies
|
}, [message, signature]); // Simplified dependencies
|
||||||
|
|
||||||
// Backend flow using NextAuth
|
|
||||||
const handleBackendSignIn = useCallback(async () => {
|
|
||||||
if (!nonce) {
|
|
||||||
console.error('❌ No nonce available for backend sign-in');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignersLoading(true);
|
|
||||||
const result = await sdk.actions.signIn({ nonce });
|
|
||||||
|
|
||||||
const signInData = {
|
|
||||||
message: result.message,
|
|
||||||
signature: result.signature,
|
|
||||||
redirect: false,
|
|
||||||
nonce: nonce,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextAuthResult = await backendSignIn('neynar', signInData);
|
|
||||||
if (nextAuthResult?.ok) {
|
|
||||||
setMessage(result.message);
|
|
||||||
setSignature(result.signature);
|
|
||||||
} else {
|
|
||||||
console.error('❌ NextAuth sign-in failed:', nextAuthResult);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof SignInCore.RejectedByUser) {
|
|
||||||
console.log('ℹ️ Sign-in rejected by user');
|
|
||||||
} else {
|
|
||||||
console.error('❌ Backend sign-in error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [nonce]);
|
|
||||||
|
|
||||||
const handleFrontEndSignIn = useCallback(() => {
|
|
||||||
if (isError) {
|
|
||||||
reconnect();
|
|
||||||
}
|
|
||||||
setDialogStep('signin');
|
|
||||||
setShowDialog(true);
|
|
||||||
frontendSignIn();
|
|
||||||
}, [isError, reconnect, frontendSignIn]);
|
|
||||||
|
|
||||||
const handleSignOut = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setSignersLoading(true);
|
|
||||||
|
|
||||||
if (useBackendFlow) {
|
|
||||||
// Only sign out from NextAuth if the current session is from Neynar provider
|
|
||||||
if (session?.provider === 'neynar') {
|
|
||||||
await backendSignOut({ redirect: false });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Frontend flow sign out
|
|
||||||
frontendSignOut();
|
|
||||||
removeItem(STORAGE_KEY);
|
|
||||||
setStoredAuth(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common cleanup for both flows
|
|
||||||
setShowDialog(false);
|
|
||||||
setDialogStep('signin');
|
|
||||||
setSignerApprovalUrl(null);
|
|
||||||
setMessage(null);
|
|
||||||
setSignature(null);
|
|
||||||
|
|
||||||
// Reset polling interval
|
|
||||||
if (pollingInterval) {
|
|
||||||
clearInterval(pollingInterval);
|
|
||||||
setPollingInterval(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset signer flow flag
|
|
||||||
signerFlowStartedRef.current = false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error during sign out:', error);
|
|
||||||
// Optionally handle error state
|
|
||||||
} finally {
|
|
||||||
setSignersLoading(false);
|
|
||||||
}
|
|
||||||
}, [useBackendFlow, frontendSignOut, pollingInterval, session]);
|
|
||||||
|
|
||||||
const authenticated = useBackendFlow
|
const authenticated = useBackendFlow
|
||||||
? !!(
|
? !!quickAuthUser?.fid
|
||||||
session?.provider === 'neynar' &&
|
: storedAuth?.isAuthenticated && !!(storedAuth?.signers && storedAuth.signers.length > 0);
|
||||||
session?.user?.fid &&
|
|
||||||
session?.signers &&
|
|
||||||
session.signers.length > 0
|
|
||||||
)
|
|
||||||
: ((isSuccess && validSignature) || storedAuth?.isAuthenticated) &&
|
|
||||||
!!(storedAuth?.signers && storedAuth.signers.length > 0);
|
|
||||||
|
|
||||||
const userData = useBackendFlow
|
const userData = useBackendFlow
|
||||||
? {
|
? {
|
||||||
fid: session?.user?.fid,
|
fid: quickAuthUser?.fid,
|
||||||
username: session?.user?.username || '',
|
username: backendUserProfile.username ?? '',
|
||||||
pfpUrl: session?.user?.pfp_url || '',
|
pfpUrl: backendUserProfile.pfpUrl ?? '',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
fid: storedAuth?.user?.fid,
|
fid: storedAuth?.user?.fid,
|
||||||
@ -658,18 +582,17 @@ export function NeynarAuthButton() {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={useBackendFlow ? handleBackendSignIn : handleFrontEndSignIn}
|
onClick={useBackendFlow ? handleBackendSignIn : handleFrontEndSignIn}
|
||||||
disabled={!useBackendFlow && !url}
|
disabled={signersLoading}
|
||||||
className={cn(
|
className={cn(
|
||||||
'btn btn-primary flex items-center gap-3',
|
'btn btn-primary flex items-center gap-3',
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
'transform transition-all duration-200 active:scale-[0.98]',
|
'transform transition-all duration-200 active:scale-[0.98]'
|
||||||
!url && !useBackendFlow && 'cursor-not-allowed'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!useBackendFlow && !url ? (
|
{signersLoading ? (
|
||||||
<>
|
<>
|
||||||
<div className="spinner-primary w-5 h-5" />
|
<div className="spinner-primary w-5 h-5" />
|
||||||
<span>Initializing...</span>
|
<span>Loading...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -692,9 +615,9 @@ export function NeynarAuthButton() {
|
|||||||
setPollingInterval(null);
|
setPollingInterval(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
url={url}
|
url={undefined}
|
||||||
isError={isError}
|
isError={false}
|
||||||
error={error}
|
error={null}
|
||||||
step={dialogStep}
|
step={dialogStep}
|
||||||
isLoading={signersLoading}
|
isLoading={signersLoading}
|
||||||
signerApprovalUrl={signerApprovalUrl}
|
signerApprovalUrl={signerApprovalUrl}
|
||||||
|
|||||||
@ -1,22 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { signIn, signOut, getCsrfToken } from "next-auth/react";
|
|
||||||
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk";
|
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
|
import { useQuickAuth } from "~/hooks/useQuickAuth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF).
|
* SignIn component handles Farcaster authentication using QuickAuth.
|
||||||
*
|
*
|
||||||
* This component provides a complete authentication flow for Farcaster users:
|
* This component provides a complete authentication flow for Farcaster users:
|
||||||
* - Generates nonces for secure authentication
|
* - Uses the built-in QuickAuth functionality from the Farcaster SDK
|
||||||
* - Handles the SIWF flow using the Farcaster SDK
|
* - Manages authentication state in memory (no persistence)
|
||||||
* - Manages NextAuth session state
|
|
||||||
* - Provides sign-out functionality
|
* - Provides sign-out functionality
|
||||||
* - Displays authentication status and results
|
* - Displays authentication status and results
|
||||||
*
|
*
|
||||||
* The component integrates with both the Farcaster Frame SDK and NextAuth
|
* The component integrates with the Farcaster Frame SDK and QuickAuth
|
||||||
* to provide seamless authentication within mini apps.
|
* to provide seamless authentication within mini apps.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@ -36,37 +34,19 @@ export function SignIn() {
|
|||||||
signingIn: false,
|
signingIn: false,
|
||||||
signingOut: false,
|
signingOut: false,
|
||||||
});
|
});
|
||||||
const [signInResult, setSignInResult] = useState<SignInCore.SignInResult>();
|
|
||||||
const [signInFailure, setSignInFailure] = useState<string>();
|
const [signInFailure, setSignInFailure] = useState<string>();
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
const { data: session, status } = useSession();
|
const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
/**
|
/**
|
||||||
* Generates a nonce for the sign-in process.
|
* Handles the sign-in process using QuickAuth.
|
||||||
*
|
*
|
||||||
* This function retrieves a CSRF token from NextAuth to use as a nonce
|
* This function uses the built-in QuickAuth functionality:
|
||||||
* for the SIWF authentication flow. The nonce ensures the authentication
|
* 1. Gets a token from QuickAuth (handles SIWF flow automatically)
|
||||||
* request is fresh and prevents replay attacks.
|
* 2. Validates the token with our server
|
||||||
*
|
* 3. Updates the session state
|
||||||
* @returns Promise<string> - The generated nonce token
|
|
||||||
* @throws Error if unable to generate nonce
|
|
||||||
*/
|
|
||||||
const getNonce = useCallback(async () => {
|
|
||||||
const nonce = await getCsrfToken();
|
|
||||||
if (!nonce) throw new Error('Unable to generate nonce');
|
|
||||||
return nonce;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the sign-in process using Farcaster SDK.
|
|
||||||
*
|
|
||||||
* This function orchestrates the complete SIWF flow:
|
|
||||||
* 1. Generates a nonce for security
|
|
||||||
* 2. Calls the Farcaster SDK to initiate sign-in
|
|
||||||
* 3. Submits the result to NextAuth for session management
|
|
||||||
* 4. Handles various error conditions including user rejection
|
|
||||||
*
|
*
|
||||||
* @returns Promise<void>
|
* @returns Promise<void>
|
||||||
*/
|
*/
|
||||||
@ -74,14 +54,12 @@ export function SignIn() {
|
|||||||
try {
|
try {
|
||||||
setAuthState((prev) => ({ ...prev, signingIn: true }));
|
setAuthState((prev) => ({ ...prev, signingIn: true }));
|
||||||
setSignInFailure(undefined);
|
setSignInFailure(undefined);
|
||||||
const nonce = await getNonce();
|
|
||||||
const result = await sdk.actions.signIn({ nonce });
|
const success = await signIn();
|
||||||
setSignInResult(result);
|
|
||||||
await signIn('farcaster', {
|
if (!success) {
|
||||||
message: result.message,
|
setSignInFailure('Authentication failed');
|
||||||
signature: result.signature,
|
}
|
||||||
redirect: false,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof SignInCore.RejectedByUser) {
|
if (e instanceof SignInCore.RejectedByUser) {
|
||||||
setSignInFailure('Rejected by user');
|
setSignInFailure('Rejected by user');
|
||||||
@ -91,50 +69,45 @@ export function SignIn() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAuthState((prev) => ({ ...prev, signingIn: false }));
|
setAuthState((prev) => ({ ...prev, signingIn: false }));
|
||||||
}
|
}
|
||||||
}, [getNonce]);
|
}, [signIn]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the sign-out process.
|
* Handles the sign-out process.
|
||||||
*
|
*
|
||||||
* This function clears the NextAuth session only if the current session
|
* This function clears the QuickAuth session and resets the local state.
|
||||||
* is using the Farcaster provider, and resets the local sign-in result state.
|
|
||||||
*
|
*
|
||||||
* @returns Promise<void>
|
* @returns Promise<void>
|
||||||
*/
|
*/
|
||||||
const handleSignOut = useCallback(async () => {
|
const handleSignOut = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setAuthState((prev) => ({ ...prev, signingOut: true }));
|
setAuthState((prev) => ({ ...prev, signingOut: true }));
|
||||||
// Only sign out if the current session is from Farcaster provider
|
await signOut();
|
||||||
if (session?.provider === 'farcaster') {
|
|
||||||
await signOut({ redirect: false });
|
|
||||||
}
|
|
||||||
setSignInResult(undefined);
|
|
||||||
} finally {
|
} finally {
|
||||||
setAuthState((prev) => ({ ...prev, signingOut: false }));
|
setAuthState((prev) => ({ ...prev, signingOut: false }));
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [signOut]);
|
||||||
|
|
||||||
// --- Render ---
|
// --- Render ---
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Authentication Buttons */}
|
{/* Authentication Buttons */}
|
||||||
{(status !== 'authenticated' || session?.provider !== 'farcaster') && (
|
{status !== 'authenticated' && (
|
||||||
<Button onClick={handleSignIn} disabled={authState.signingIn}>
|
<Button onClick={handleSignIn} disabled={authState.signingIn}>
|
||||||
Sign In with Farcaster
|
Sign In with Farcaster
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === 'authenticated' && session?.provider === 'farcaster' && (
|
{status === 'authenticated' && (
|
||||||
<Button onClick={handleSignOut} disabled={authState.signingOut}>
|
<Button onClick={handleSignOut} disabled={authState.signingOut}>
|
||||||
Sign out
|
Sign out
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Session Information */}
|
{/* Session Information */}
|
||||||
{session && (
|
{authenticatedUser && (
|
||||||
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
|
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
|
||||||
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">Session</div>
|
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">Authenticated User</div>
|
||||||
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
|
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
|
||||||
{JSON.stringify(session, null, 2)}
|
{JSON.stringify(authenticatedUser, null, 2)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -142,20 +115,10 @@ export function SignIn() {
|
|||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{signInFailure && !authState.signingIn && (
|
{signInFailure && !authState.signingIn && (
|
||||||
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
|
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
|
||||||
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
|
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">Authentication Error</div>
|
||||||
<div className="whitespace-pre text-gray-700 dark:text-gray-200">{signInFailure}</div>
|
<div className="whitespace-pre text-gray-700 dark:text-gray-200">{signInFailure}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success Result Display */}
|
|
||||||
{signInResult && !authState.signingIn && (
|
|
||||||
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
|
|
||||||
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
|
|
||||||
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
|
|
||||||
{JSON.stringify(signInResult, null, 2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
204
src/hooks/useQuickAuth.ts
Normal file
204
src/hooks/useQuickAuth.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { sdk } from '@farcaster/miniapp-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the current authenticated user state
|
||||||
|
*/
|
||||||
|
interface AuthenticatedUser {
|
||||||
|
/** The user's Farcaster ID (FID) */
|
||||||
|
fid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible authentication states for QuickAuth
|
||||||
|
*/
|
||||||
|
type QuickAuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for the useQuickAuth hook
|
||||||
|
*/
|
||||||
|
interface UseQuickAuthReturn {
|
||||||
|
/** Current authenticated user data, or null if not authenticated */
|
||||||
|
authenticatedUser: AuthenticatedUser | null;
|
||||||
|
/** Current authentication status */
|
||||||
|
status: QuickAuthStatus;
|
||||||
|
/** Function to initiate the sign-in process using QuickAuth */
|
||||||
|
signIn: () => Promise<boolean>;
|
||||||
|
/** Function to sign out and clear the current authentication state */
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
/** Function to retrieve the current authentication token */
|
||||||
|
getToken: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing QuickAuth authentication state
|
||||||
|
*
|
||||||
|
* This hook provides a complete authentication flow using Farcaster's QuickAuth:
|
||||||
|
* - Automatically checks for existing authentication on mount
|
||||||
|
* - Validates tokens with the server-side API
|
||||||
|
* - Manages authentication state in memory (no persistence)
|
||||||
|
* - Provides sign-in/sign-out functionality
|
||||||
|
*
|
||||||
|
* QuickAuth tokens are managed in memory only, so signing out of the Farcaster
|
||||||
|
* client will automatically sign the user out of this mini app as well.
|
||||||
|
*
|
||||||
|
* @returns {UseQuickAuthReturn} Object containing user state and authentication methods
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
|
||||||
|
*
|
||||||
|
* if (status === 'loading') return <div>Loading...</div>;
|
||||||
|
* if (status === 'unauthenticated') return <button onClick={signIn}>Sign In</button>;
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <p>Welcome, FID: {authenticatedUser?.fid}</p>
|
||||||
|
* <button onClick={signOut}>Sign Out</button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useQuickAuth(): UseQuickAuthReturn {
|
||||||
|
// Current authenticated user data
|
||||||
|
const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(null);
|
||||||
|
// Current authentication status
|
||||||
|
const [status, setStatus] = useState<QuickAuthStatus>('loading');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a QuickAuth token with the server-side API
|
||||||
|
*
|
||||||
|
* @param {string} authToken - The JWT token to validate
|
||||||
|
* @returns {Promise<AuthenticatedUser | null>} User data if valid, null otherwise
|
||||||
|
*/
|
||||||
|
const validateTokenWithServer = async (authToken: string): Promise<AuthenticatedUser | null> => {
|
||||||
|
try {
|
||||||
|
const validationResponse = await fetch('/api/auth/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: authToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationResponse.ok) {
|
||||||
|
const responseData = await validationResponse.json();
|
||||||
|
return responseData.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token validation failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for existing authentication token and validates it on component mount
|
||||||
|
* This runs automatically when the hook is first used
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const checkExistingAuthentication = async () => {
|
||||||
|
try {
|
||||||
|
// Attempt to retrieve existing token from QuickAuth SDK
|
||||||
|
const { token } = await sdk.quickAuth.getToken();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Validate the token with our server-side API
|
||||||
|
const validatedUserSession = await validateTokenWithServer(token);
|
||||||
|
|
||||||
|
if (validatedUserSession) {
|
||||||
|
// Token is valid, set authenticated state
|
||||||
|
setAuthenticatedUser(validatedUserSession);
|
||||||
|
setStatus('authenticated');
|
||||||
|
} else {
|
||||||
|
// Token is invalid or expired, clear authentication state
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No existing token found, user is not authenticated
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking existing authentication:', error);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkExistingAuthentication();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates the QuickAuth sign-in process
|
||||||
|
*
|
||||||
|
* Uses sdk.quickAuth.getToken() to get a QuickAuth session token.
|
||||||
|
* If there is already a session token in memory that hasn't expired,
|
||||||
|
* it will be immediately returned, otherwise a fresh one will be acquired.
|
||||||
|
*
|
||||||
|
* @returns {Promise<boolean>} True if sign-in was successful, false otherwise
|
||||||
|
*/
|
||||||
|
const signIn = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
setStatus('loading');
|
||||||
|
|
||||||
|
// Get QuickAuth session token
|
||||||
|
const { token } = await sdk.quickAuth.getToken();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Validate the token with our server-side API
|
||||||
|
const validatedUserSession = await validateTokenWithServer(token);
|
||||||
|
|
||||||
|
if (validatedUserSession) {
|
||||||
|
// Authentication successful, update user state
|
||||||
|
setAuthenticatedUser(validatedUserSession);
|
||||||
|
setStatus('authenticated');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication failed, clear user state
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign-in process failed:', error);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs out the current user and clears the authentication state
|
||||||
|
*
|
||||||
|
* Since QuickAuth tokens are managed in memory only, this simply clears
|
||||||
|
* the local user state. The actual token will be cleared when the
|
||||||
|
* user signs out of their Farcaster client.
|
||||||
|
*/
|
||||||
|
const signOut = useCallback(async (): Promise<void> => {
|
||||||
|
// Clear local user state
|
||||||
|
setAuthenticatedUser(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the current authentication token from QuickAuth
|
||||||
|
*
|
||||||
|
* @returns {Promise<string | null>} The current auth token, or null if not authenticated
|
||||||
|
*/
|
||||||
|
const getToken = useCallback(async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const { token } = await sdk.quickAuth.getToken();
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to retrieve authentication token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticatedUser,
|
||||||
|
status,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
getToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user