Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions app/api/magic/provider/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* QualifyFirst - Magic Identity Provider Registration Route
*
* Supports registering one or many providers (QualifyFirst,
* JustTheTip, TiltCheck, etc.) against the same Magic project so
* every app can authenticate the exact same user accounts.
*/

import { NextResponse } from 'next/server'

import {
REQUIRED_FIELDS,
type MagicProviderPayload,
type MagicProviderBatchRequest,
registerMagicProvider,
registerMagicProvidersBatch,
sanitizeProviderPayload,
findMissingFields,
} from '../../../lib/magic/provider'

const invalidBodyResponse = () =>
NextResponse.json(
{
error:
'Invalid payload. Provide issuer/audience/jwks_uri or a providers array with those fields.',
},
{ status: 400 },
)

const isBatchRequest = (body: unknown): body is MagicProviderBatchRequest => {
if (!body || typeof body !== 'object') return false
const maybe = body as Partial<MagicProviderBatchRequest>
return Array.isArray(maybe.providers)
}

export async function POST(request: Request) {
const secretKey = process.env.MAGIC_SECRET_KEY

if (!secretKey) {
return NextResponse.json(
{
error:
'Magic secret key is not configured. Set MAGIC_SECRET_KEY in your environment.',
},
{ status: 500 },
)
}

let body: unknown

try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}

if (isBatchRequest(body)) {
if (body.providers.length === 0) {
return NextResponse.json(
{ error: 'At least one provider payload is required.' },
{ status: 400 },
)
}

const batchResult = await registerMagicProvidersBatch(
secretKey,
body.providers,
body.shared_audience,
)

if (!batchResult.success) {
const status = 'details' in batchResult ? 400 : 502
return NextResponse.json(batchResult, { status })
}

return NextResponse.json(batchResult)
}

if (!body || typeof body !== 'object') {
return invalidBodyResponse()
}

const payload = sanitizeProviderPayload(body as MagicProviderPayload)
const missingFields = findMissingFields(payload)

if (missingFields.length > 0) {
return NextResponse.json(
{
error: `Missing required field${missingFields.length > 1 ? 's' : ''}: ${missingFields.join(', ')}`,
},
{ status: 400 },
)
}

const result = await registerMagicProvider(secretKey, payload)

if (!result.success) {
return NextResponse.json(
{
error: result.error,
details: result.details,
},
{ status: result.status },
)
}

return NextResponse.json({ success: true, provider: result.provider })
}

export async function GET() {
return NextResponse.json(
{
message:
'POST issuer, audience, and jwks_uri or provide a providers array. Use shared_audience to keep QualifyFirst, JustTheTip, and TiltCheck on the same Magic user pool. Use the provider id from the response when calling /api/magic/wallet.',
required_fields: REQUIRED_FIELDS,
examples: {
single: {
issuer: 'https://your-auth-provider.com',
audience: 'qualifyfirst-users',
jwks_uri: 'https://your-auth-provider.com/.well-known/jwks.json',
},
batch: {
shared_audience: 'qualifyfirst-users',
providers: [
{
name: 'QualifyFirst Dashboard',
issuer: 'https://your-auth-provider.com',
jwks_uri: 'https://your-auth-provider.com/.well-known/jwks.json',
},
{
name: 'JustTheTip Discord Bot',
issuer: 'https://your-justthetip-provider.com',
jwks_uri: 'https://your-justthetip-provider.com/.well-known/jwks.json',
},
{
name: 'TiltCheck Tools',
issuer: 'https://your-tiltcheck-provider.com',
jwks_uri: 'https://your-tiltcheck-provider.com/.well-known/jwks.json',
},
],
},
},
},
{ status: 200 },
)
}
104 changes: 104 additions & 0 deletions app/api/magic/wallet/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* QualifyFirst - Magic Wallet Creation Route
*
* Creates (or fetches) a blockchain wallet for a user via the Magic Admin API.
* Requires the OIDC provider ID from the provider registration step and the
* JWT issued by your auth provider (e.g., Supabase) for the current user.
*/

import { NextResponse } from 'next/server'

import {
createMagicWallet,
type MagicWalletRequest,
} from '../../../lib/magic/provider'

const invalidBodyResponse = () =>
NextResponse.json(
{
error:
'Invalid payload. Provide provider_id, user_jwt, and optionally chain.',
},
{ status: 400 },
)

const REQUIRED_FIELDS: (keyof MagicWalletRequest)[] = ['providerId', 'userJwt']

export async function POST(request: Request) {
const secretKey = process.env.MAGIC_SECRET_KEY

if (!secretKey) {
return NextResponse.json(
{
error:
'Magic secret key is not configured. Set MAGIC_SECRET_KEY in your environment.',
},
{ status: 500 },
)
}

let body: unknown

try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}

if (!body || typeof body !== 'object') {
return invalidBodyResponse()
}

const {
provider_id: providerIdRaw,
user_jwt: userJwtRaw,
chain: chainRaw,
} = body as Record<string, unknown>

const payload: MagicWalletRequest = {
providerId: typeof providerIdRaw === 'string' ? providerIdRaw.trim() : '',
userJwt: typeof userJwtRaw === 'string' ? userJwtRaw.trim() : '',
chain: typeof chainRaw === 'string' ? chainRaw.trim() : undefined,
}

const missingFields = REQUIRED_FIELDS.filter((field) => !payload[field])

if (missingFields.length > 0) {
return NextResponse.json(
{
error: `Missing required field${missingFields.length > 1 ? 's' : ''}: ${missingFields.join(', ')}`,
},
{ status: 400 },
)
}

const result = await createMagicWallet(secretKey, payload)

if (!result.success) {
return NextResponse.json(
{
error: result.error,
details: result.details,
},
{ status: result.status },
)
}

return NextResponse.json({ success: true, public_address: result.publicAddress })
}

export async function GET() {
return NextResponse.json(
{
message:
'POST provider_id and user_jwt to mint or fetch a Magic wallet. Optionally override chain (default: SOL).',
required_fields: ['provider_id', 'user_jwt'],
example: {
provider_id: 'magic_provider_id_from_registration',
user_jwt: 'jwt_from_your_auth_provider',
chain: 'SOL',
},
},
{ status: 200 },
)
}
140 changes: 140 additions & 0 deletions app/auth/magic/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* QualifyFirst - Magic Auth Callback Page
*
* Handles the redirect from Magic email links, exchanges the Magic DID token
* for a Supabase session, and forwards the user to the appropriate page.
*/

'use client';

import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';

import { getMagicClient } from '../../lib/magic/client';
import { supabase } from '../../lib/supabase';

export default function MagicCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const magicCredential = searchParams?.get('magic_credential');
const returnTo = searchParams?.get('returnTo');

const [status, setStatus] = useState<'verifying' | 'error'>('verifying');
const [message, setMessage] = useState('Verifying your magic link...');

useEffect(() => {
let cancelled = false;

const verifyMagicLink = async () => {
if (!magicCredential) {
setStatus('error');
setMessage('Missing Magic credential. Please request a new login link.');
return;
}

try {
setStatus('verifying');
setMessage('Verifying your magic link...');

const magic = await getMagicClient();

if (!magic) {
throw new Error(
'Magic publishable key is missing. Add NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY to your environment.',
);
}

const didToken = await magic.auth.loginWithCredential(magicCredential);

const { error } = await supabase.auth.signInWithIdToken({
provider: 'magic',
token: didToken,
});

if (error) {
throw error;
}

if (cancelled) {
return;
}

if (returnTo === 'profile-complete') {
router.replace('/profile/save-profile');
return;
}

if (returnTo) {
const normalized = returnTo.startsWith('/') ? returnTo : `/${returnTo}`;
router.replace(normalized);
return;
}

router.replace('/dashboard');
} catch (err) {
if (cancelled) {
return;
}

console.error('Magic auth callback error:', err);
const fallback =
err instanceof Error
? err.message
: 'Unable to verify the magic link. Please request a new one.';

setStatus('error');
setMessage(fallback);
}
};

verifyMagicLink();

return () => {
cancelled = true;
};
}, [magicCredential, returnTo, router]);

if (status === 'verifying') {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="animate-spin w-8 h-8 border-4 border-indigo-600 border-t-transparent rounded-full" />
</div>
<h2 className="text-2xl font-bold mb-2">Verifying your magic link...</h2>
<p className="text-gray-600">
Hang tight while we confirm your login with Magic. You&apos;ll be redirected in a moment.
</p>
</div>
</div>
);
}

return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<h2 className="text-2xl font-bold mb-2">Magic link verification failed</h2>
<p className="text-red-600 mb-4">{message}</p>
<Link
href="/login"
className="inline-block bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Return to login
</Link>
<p className="text-sm text-gray-500 mt-4">
Still stuck? Request another Magic link and double-check you&apos;re opening it on the same device.
</p>
</div>
</div>
);
}
Loading