/** * AmberCallbackHandler * * This component handles Amber (NIP-55) callbacks at the app level. * It processes results from Amber after signing operations and login. * * For web apps, Amber communication works via URL redirects: * 1. App opens nostrsigner: URL to request signing * 2. Amber opens and user approves/rejects * 3. Amber redirects back to callbackUrl with result in query params * 4. This component processes the result * * PWA NOTE: PWAs can lose localStorage when redirecting to external apps. * This handler uses multiple fallback strategies: * 1. Check localStorage, sessionStorage, and IndexedDB for pending operation * 2. If all storage is lost but result looks like a pubkey, assume it's a login */ import { useEffect, useRef, useState } from 'react'; import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { isAmberCallback, getAmberCallbackResult, getAmberPendingOperationAsync, clearAmberPendingOperation, cleanAmberCallbackUrl, getAmberLogin, reconstructSignedEvent, } from '@/lib/amber'; export function AmberCallbackHandler() { const login = useLoginActions(); const { toast } = useToast(); const { t } = useTranslation(); const hasProcessed = useRef(false); const [debugInfo, setDebugInfo] = useState(null); useEffect(() => { // If we're already on the /amber-callback route, AmberCallbackPage handles // the processing — this global handler must stay silent to avoid a race. const hashPath = window.location.hash.replace(/^#/, '').split('?')[0]; if (hashPath === '/amber-callback') { return; } // Check if this is an Amber callback const isCallback = isAmberCallback(); if (!isCallback) { return; } // Only process once per page load if (hasProcessed.current) { return; } hasProcessed.current = true; // Show debug info temporarily setDebugInfo(t('settingsUi.amberCallback.processing')); // Process async to allow IndexedDB lookup processAmberCallback(); async function processAmberCallback() { const result = getAmberCallbackResult(); setDebugInfo(t('settingsUi.amberCallback.gotResult', { result: result ? result.substring(0, 16) + '...' : 'null' })); // Clean up URL immediately to prevent re-processing on refresh cleanAmberCallbackUrl(); if (!result) { // User cancelled or Amber failed clearAmberPendingOperation(); setDebugInfo(t('settingsUi.amberCallback.noResultDebug')); toast({ title: 'Amber', description: t('settingsUi.amberCallback.noResultToast'), variant: 'destructive', }); return; } // Try to get pending operation from any storage const pendingOp = await getAmberPendingOperationAsync(); // Log only the operation TYPE — never the payload (encrypt/decrypt ops can // carry plaintext). console.log('[AmberCallback] Pending operation type:', pendingOp?.type ?? 'none'); const pubkeyRegex = /^[0-9a-fA-F]{64}$/; // SECURITY: a callback is only valid when it matches an operation this app // actually started. Never infer a login from a bare `?event=` URL — // that would let any link switch the user into an arbitrary account. if (!pendingOp) { console.warn('[AmberCallback] No pending operation found — ignoring callback'); setDebugInfo(t('settingsUi.amberCallback.sessionLostToast')); toast({ title: 'Amber', description: t('settingsUi.amberCallback.sessionLostToast'), variant: 'destructive', }); return; } // Process based on operation type switch (pendingOp.type) { case 'get_public_key': { console.log('[AmberCallback] Processing get_public_key...'); if (pubkeyRegex.test(result)) { try { login.amber(result.toLowerCase()); clearAmberPendingOperation(); console.log('[AmberCallback] Login successful!'); toast({ title: t('settingsUi.amberCallback.loggedInTitle'), description: t('settingsUi.amberCallback.amberLoginDesc'), }); } catch (error) { console.error('[AmberCallback] Amber login failed:', error); clearAmberPendingOperation(); toast({ title: t('settingsUi.amberCallback.loginFailedTitle'), description: t('settingsUi.amberCallback.loginFailedDesc'), variant: 'destructive', }); } } else { console.warn('[AmberCallback] Invalid pubkey format:', result); clearAmberPendingOperation(); toast({ title: t('settingsUi.amberCallback.loginFailedTitle'), description: t('settingsUi.amberCallback.invalidPubkeyDesc'), variant: 'destructive', }); } break; } case 'sign_event': { // Store the signed event result for the caller to retrieve. // Amber returns a BARE signature (returnType='signature'), so we // reconstruct the full event from the original template + signature // and compute its id locally — a raw JSON.parse(result) would throw on // a bare signature or produce an event with an empty id that stalls // the relay. See reconstructSignedEvent() for details. const signedEvent = reconstructSignedEvent( result, pendingOp.eventJson, getAmberLogin() ?? '', ); if (signedEvent) { sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent)); clearAmberPendingOperation(); toast({ title: t('settingsUi.amberCallback.eventSignedTitle'), description: t('settingsUi.amberCallback.eventSignedDesc'), }); // Reload the page to re-submit the event // This is a workaround since we can't resolve the original promise window.location.reload(); } else { console.error('Failed to reconstruct signed event from Amber result'); clearAmberPendingOperation(); toast({ title: t('settingsUi.amberCallback.signingFailedTitle'), description: t('settingsUi.amberCallback.signingFailedDesc'), variant: 'destructive', }); } break; } case 'nip04_encrypt': case 'nip44_encrypt': case 'nip04_decrypt': case 'nip44_decrypt': { // Store encryption/decryption results // The result is delivered through the callback promise/flow; do not // leave private plaintext in browser storage. clearAmberPendingOperation(); toast({ title: t('settingsUi.amberCallback.operationCompleteTitle'), description: t('settingsUi.amberCallback.operationCompleteDesc', { op: pendingOp.type.replace('_', ' ') }), }); break; } default: { console.warn('Unknown Amber operation type:', pendingOp.type); clearAmberPendingOperation(); } } } }, [login, toast, t]); // Show debug overlay when processing Amber callback if (debugInfo) { return (
🔐 Amber: {debugInfo}
); } return null; }