SkateSpots/src/pages/AmberCallbackPage.tsx
shakespeare.diy 0c87fa5827 Fix Amber login not completing on GrapheneOS new-tab callback
Root cause: on GrapheneOS, Amber opens /amber-callback in a BRAND NEW TAB.
login.amber() was being called in that new tab's React instance, which has
no effect on the original tab where the user is waiting.

Fix:
- AmberCallbackPage now writes pubkey to localStorage('amber_pending_pubkey')
  instead of calling login.amber() — a cross-tab signal
- LoginDialog polls for 'amber_pending_pubkey' every 400ms and calls
  login.amber() itself in the correct React context
- Manual "I approved" button also reads the same key
- Removed reliance on getAmberLogin() which checked the wrong localStorage key

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-17 22:46:39 +07:00

176 lines
6.7 KiB
TypeScript

/**
* AmberCallbackPage
*
* Amber (NIP-55) redirects back to /amber-callback?event=<pubkey|signedEvent>
* 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 { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import {
getAmberCallbackResult,
getAmberPendingOperationAsync,
clearAmberPendingOperation,
cleanAmberCallbackUrl,
} 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<Status>('processing');
const [message, setMessage] = useState('Completing login with Amber...');
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}$/;
// ── get_public_key (login) ──────────────────────────────────────────
const isLoginOp = !pendingOp || 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') {
try {
const signedEvent = JSON.parse(result);
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);
} catch {
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 (
<div className="min-h-screen bg-black flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-6">
{/* Icon */}
<div className="flex justify-center">
{status === 'processing' && (
<div className="w-20 h-20 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center">
<Loader2 className="w-10 h-10 text-[#ccff00] animate-spin" />
</div>
)}
{status === 'success' && (
<div className="w-20 h-20 rounded-full bg-green-500/10 border-2 border-green-500/40 flex items-center justify-center">
<CheckCircle2 className="w-10 h-10 text-green-500" />
</div>
)}
{status === 'error' && (
<div className="w-20 h-20 rounded-full bg-red-500/10 border-2 border-red-500/40 flex items-center justify-center">
<XCircle className="w-10 h-10 text-red-500" />
</div>
)}
</div>
{/* Message */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-widest mb-1">Amber · NIP-55</p>
<p className="text-white font-bold text-lg">{message}</p>
</div>
{/* Processing dots */}
{status === 'processing' && (
<div className="flex justify-center gap-1.5">
{[0, 1, 2].map(i => (
<span
key={i}
className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
)}
{/* Error fallback buttons */}
{status === 'error' && (
<div className="space-y-3">
<Link to="/">
<Button className="w-full gap-2 bg-[#ff0040] hover:bg-[#ff0040]/90 font-bold uppercase rounded-full">
<Home className="w-4 h-4" />
Go Home
</Button>
</Link>
</div>
)}
</div>
</div>
);
}