feat: replace next-auth with quick auth

This commit is contained in:
veganbeef 2025-07-14 13:01:46 -07:00
parent c713d53054
commit 86029b2bd9
No known key found for this signature in database
33 changed files with 1260 additions and 797 deletions

View File

@ -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

View File

@ -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",

View File

@ -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}`,
}; };

View File

@ -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
}); });

View File

@ -1,6 +0,0 @@
import NextAuth from "next-auth"
import { authOptions } from "~/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@ -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 }
);
}
}

View 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 }
);
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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,
}
);
}

View 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 });
}

View 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 }
);
}
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

118
src/app/app/globals.css Normal file
View 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
View 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
View 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
View 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>
);
}

View 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("/");
}

View File

@ -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>
); );

View File

@ -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}>
<AuthKitProvider config={{}}>{children}</AuthKitProvider> {children}
</SafeFarcasterSolanaProvider> </SafeFarcasterSolanaProvider>
</MiniAppProvider> </MiniAppProvider>
</WagmiProvider> </WagmiProvider>
</SessionProvider>
); );
} }

View File

@ -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);
} catch (error) {
console.error('Error getting server session:', error);
return null; return null;
}
}; };

View File

@ -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}>

View File

@ -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
@ -246,14 +230,16 @@ export function NeynarAuthButton() {
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);
} }
}
}, [useBackendFlow]);
// Success callback - this is critical! try {
const onSuccessCallback = useCallback( setSignersLoading(true);
async (res: UseSignInData) => { const result = await sdk.actions.signIn({ nonce });
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 setMessage(result.message);
const onErrorCallback = useCallback((error?: Error | null) => { setSignature(result.signature);
console.error('❌ Sign in error:', error); // Use QuickAuth to sign in
}, []); const signInResult = await quickAuthSignIn();
// Fetch user profile after sign in
const signInState = useSignIn({ if (quickAuthUser?.fid) {
nonce: nonce || undefined, const user = await fetchUserData(quickAuthUser.fid);
onSuccess: onSuccessCallback, setBackendUserProfile({
onError: onErrorCallback, 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);
}
}
}, [nonce, quickAuthSignIn, quickAuthUser, fetchUserData]);
const { // Fetch user profile when quickAuthUser.fid changes (for backend flow)
signIn: frontendSignIn,
signOut: frontendSignOut,
connect,
reconnect,
isSuccess,
isError,
error,
channelToken,
url,
data,
validSignature,
} = signInState;
useEffect(() => { useEffect(() => {
setMessage(data?.message || null); if (useBackendFlow && quickAuthUser?.fid) {
setSignature(data?.signature || null); (async () => {
const user = await fetchUserData(quickAuthUser.fid);
setBackendUserProfile({
username: user?.username || '',
pfpUrl: user?.pfp_url || '',
});
})();
}
}, [useBackendFlow, quickAuthUser?.fid, fetchUserData]);
// Reset the signer flow flag when message/signature change const handleFrontEndSignIn = useCallback(async () => {
if (data?.message && data?.signature) { 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}

View File

@ -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
View 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,
};
}