/** * AmberCallbackPage * * Amber (NIP-55) redirects back to /amber-callback?event= * after the user approves a request. This page processes the result and * redirects the user back to where they came from (or home). * * On Android browsers (including GrapheneOS), window.location.href navigates * the tab away, so we use localStorage (not sessionStorage) for persistence. */ import { useEffect, useRef, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; import { getAmberCallbackResult, getAmberPendingOperationAsync, clearAmberPendingOperation, cleanAmberCallbackUrl, getAmberLogin, reconstructSignedEvent, } from '@/lib/amber'; import { CheckCircle2, XCircle, Loader2, Home } from 'lucide-react'; import { Button } from '@/components/ui/button'; type Status = 'processing' | 'success' | 'error'; export default function AmberCallbackPage() { const navigate = useNavigate(); const login = useLoginActions(); const { toast } = useToast(); const hasProcessed = useRef(false); const [status, setStatus] = useState('processing'); const [message, setMessage] = useState('Completing login with Amber...'); useSeoMeta({ title: 'Signing in with Amber - SkateSpots', description: 'Completing your Amber (NIP-55) login.', }); useEffect(() => { if (hasProcessed.current) return; hasProcessed.current = true; processCallback(); async function processCallback() { const result = getAmberCallbackResult(); cleanAmberCallbackUrl(); if (!result) { setStatus('error'); setMessage('No result received from Amber. The request may have been cancelled.'); clearAmberPendingOperation(); return; } const pendingOp = await getAmberPendingOperationAsync(); const pubkeyRegex = /^[0-9a-fA-F]{64}$/; // SECURITY: only accept a result that matches an operation this app // actually started. Never treat a bare `?event=` URL as a login — // that would let any link switch the user into an arbitrary account. if (!pendingOp) { setStatus('error'); setMessage('Login session expired or not found. Return to SkateSpots and try again.'); clearAmberPendingOperation(); return; } // ── get_public_key (login) ────────────────────────────────────────── const isLoginOp = pendingOp.type === 'get_public_key'; if (isLoginOp) { if (pubkeyRegex.test(result)) { // Store pubkey under a SEPARATE key so the original tab's polling // detects it and calls login.amber() itself — this works even when // Amber opens the callback in a brand new tab (GrapheneOS behaviour) localStorage.setItem('amber_pending_pubkey', result.toLowerCase()); clearAmberPendingOperation(); setStatus('success'); setMessage('Approved! You can close this tab and return to SkateSpots.'); // Also try to complete login in THIS tab in case it is the same tab try { login.amber(result.toLowerCase()); } catch { /* ignore — may fail if called twice */ } redirectBack(true); } else { setStatus('error'); setMessage('Invalid response from Amber. Please try again.'); clearAmberPendingOperation(); } return; } // ── sign_event ────────────────────────────────────────────────────── if (pendingOp.type === 'sign_event') { // Amber returns a BARE signature (returnType='signature'), so we // reconstruct the full event from the original template + signature and // compute its id locally. See reconstructSignedEvent() for the why. const signedEvent = reconstructSignedEvent( result, pendingOp.eventJson, getAmberLogin() ?? '', ); if (signedEvent) { localStorage.setItem('amber_signed_event', JSON.stringify(signedEvent)); clearAmberPendingOperation(); setStatus('success'); setMessage('Event signed! Returning...'); toast({ title: 'Event signed', description: 'Your event was signed by Amber.' }); redirectBack(true); } else { setStatus('error'); setMessage('Failed to process signed event from Amber.'); clearAmberPendingOperation(); } return; } // ── encrypt / decrypt ─────────────────────────────────────────────── if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) { localStorage.setItem('amber_crypto_result', result); clearAmberPendingOperation(); setStatus('success'); setMessage('Operation complete! Returning...'); redirectBack(true); return; } clearAmberPendingOperation(); redirectBack(false); } function redirectBack(success: boolean) { // Use localStorage — survives navigation on Android browsers const returnTo = localStorage.getItem('amber_return_path') || '/'; localStorage.removeItem('amber_return_path'); localStorage.removeItem('amber_login_attempt_ts'); setTimeout(() => navigate(returnTo, { replace: true }), success ? 800 : 0); } }, [login, toast, navigate]); return (
{/* Icon */}
{status === 'processing' && (
)} {status === 'success' && (
)} {status === 'error' && (
)}
{/* Message */}

Amber · NIP-55

{message}

{/* Processing dots */} {status === 'processing' && (
{[0, 1, 2].map(i => ( ))}
)} {/* Error fallback buttons */} {status === 'error' && (
)}
); }