203 lines
7.8 KiB
TypeScript
203 lines
7.8 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 { useSeoMeta } from '@unhead/react';
|
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const login = useLoginActions();
|
|
const { toast } = useToast();
|
|
const hasProcessed = useRef(false);
|
|
const [status, setStatus] = useState<Status>('processing');
|
|
const [message, setMessage] = useState(t('miscUi.completingLoginAmber'));
|
|
|
|
useSeoMeta({
|
|
title: t('seoUi.amberCallbackTitle'),
|
|
description: t('seoUi.amberCallbackDesc'),
|
|
});
|
|
|
|
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=<pubkey>` 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 (
|
|
<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>
|
|
);
|
|
}
|