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>
176 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|