SkateSpots/src/components/auth/AmberCallbackHandler.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

229 lines
8 KiB
TypeScript

/**
* 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 {
isAmberCallback,
getAmberCallbackResult,
getAmberPendingOperationAsync,
clearAmberPendingOperation,
cleanAmberCallbackUrl,
} from '@/lib/amber';
export function AmberCallbackHandler() {
const login = useLoginActions();
const { toast } = useToast();
const hasProcessed = useRef(false);
const [debugInfo, setDebugInfo] = useState<string | null>(null);
useEffect(() => {
// Log on mount for debugging
console.log('[AmberCallback] Component mounted');
console.log('[AmberCallback] URL:', window.location.href);
console.log('[AmberCallback] Search:', window.location.search);
console.log('[AmberCallback] Pre-captured event:', sessionStorage.getItem('amber_callback_event'));
// Check if this is an Amber callback
const isCallback = isAmberCallback();
console.log('[AmberCallback] isAmberCallback():', isCallback);
if (!isCallback) {
return;
}
// Only process once per page load
if (hasProcessed.current) {
console.log('[AmberCallback] Already processed, skipping');
return;
}
hasProcessed.current = true;
console.log('[AmberCallback] Processing Amber callback...');
// Show debug info temporarily
setDebugInfo('Processing Amber login...');
// Process async to allow IndexedDB lookup
processAmberCallback();
async function processAmberCallback() {
const result = getAmberCallbackResult();
console.log('[AmberCallback] Result:', result);
setDebugInfo(`Got 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('No result from Amber');
toast({
title: 'Amber',
description: 'No result received from Amber. Operation may have been cancelled.',
variant: 'destructive',
});
return;
}
// Try to get pending operation from any storage
const pendingOp = await getAmberPendingOperationAsync();
console.log('[AmberCallback] Pending operation:', pendingOp);
// FALLBACK: If no pending operation found, but result looks like a valid pubkey,
// assume this is a login callback (PWA storage was cleared during redirect)
const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
if (!pendingOp) {
console.warn('[AmberCallback] No pending operation found - storage may have been cleared');
// Check if result looks like a pubkey (login callback)
if (pubkeyRegex.test(result)) {
console.log('[AmberCallback] Result looks like pubkey, treating as login callback');
setDebugInfo('Valid pubkey! Logging in...');
try {
login.amber(result.toLowerCase());
setDebugInfo('Login successful!');
toast({
title: 'Logged in',
description: 'Successfully logged in with Amber!',
});
// Clear debug after success
setTimeout(() => setDebugInfo(null), 2000);
return;
} catch (error) {
console.error('[AmberCallback] Amber login failed:', error);
setDebugInfo(`Login error: ${error}`);
toast({
title: 'Login failed',
description: 'Failed to complete Amber login. Please try again.',
variant: 'destructive',
});
return;
}
}
// Not a pubkey - can't determine what operation this was for
setDebugInfo(`Invalid result format: ${result.substring(0, 20)}...`);
toast({
title: 'Amber',
description: 'Session data was lost. Please try again.',
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: 'Logged in',
description: 'Successfully logged in with Amber!',
});
} catch (error) {
console.error('[AmberCallback] Amber login failed:', error);
clearAmberPendingOperation();
toast({
title: 'Login failed',
description: 'Failed to complete Amber login. Please try again.',
variant: 'destructive',
});
}
} else {
console.warn('[AmberCallback] Invalid pubkey format:', result);
clearAmberPendingOperation();
toast({
title: 'Login failed',
description: 'Invalid pubkey received from Amber.',
variant: 'destructive',
});
}
break;
}
case 'sign_event': {
// Store the signed event result for the caller to retrieve
// This is tricky because the original caller's promise can't be resolved
// after a page redirect. For now, we'll store it and show a toast.
try {
const signedEvent = JSON.parse(result);
// Store in sessionStorage for potential retrieval
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation();
toast({
title: 'Event signed',
description: 'Your event has been signed by Amber.',
});
// Reload the page to re-submit the event
// This is a workaround since we can't resolve the original promise
window.location.reload();
} catch (e) {
console.error('Failed to parse signed event:', e);
clearAmberPendingOperation();
toast({
title: 'Signing failed',
description: 'Failed to process signed event from Amber.',
variant: 'destructive',
});
}
break;
}
case 'nip04_encrypt':
case 'nip44_encrypt':
case 'nip04_decrypt':
case 'nip44_decrypt': {
// Store encryption/decryption results
sessionStorage.setItem('amber_crypto_result', result);
clearAmberPendingOperation();
toast({
title: 'Operation complete',
description: `${pendingOp.type.replace('_', ' ')} completed.`,
});
break;
}
default: {
console.warn('Unknown Amber operation type:', pendingOp.type);
clearAmberPendingOperation();
}
}
}
}, [login, toast]);
// Show debug overlay when processing Amber callback
if (debugInfo) {
return (
<div className="fixed top-0 left-0 right-0 z-[9999] bg-yellow-500 text-black px-4 py-2 text-center text-sm font-mono">
🔐 Amber: {debugInfo}
</div>
);
}
return null;
}