Fix Amber login stuck on GrapheneOS/Android browsers

Root cause: window.location.href navigates the tab away entirely on Android,
destroying sessionStorage. When Amber calls back to /amber-callback the login
completes there, but returning to the dialog the state is lost.

Fixes:
- Switch from sessionStorage to localStorage for amber_return_path and
  amber_login_attempt_ts — survives tab navigation on Android
- Add 500ms polling loop in handleAmberLogin that checks getAmberLogin()
  every 500ms — detects when AmberCallbackPage stored the pubkey and
  auto-completes login without needing visibilitychange to fire
- Add "I approved — complete login" manual button in waiting state as
  explicit fallback for stuck users (reads localStorage right away)
- AmberCallbackPage now uses localStorage for all persistence
- Clean up poll interval on dialog close and unmount

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-17 22:37:26 +07:00
parent 03d0fe93b0
commit 4398c2e73b
2 changed files with 95 additions and 48 deletions

View file

@ -11,7 +11,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { subscribeToAmberReturn } from '@/lib/amber'; import { subscribeToAmberReturn, getAmberLogin } from '@/lib/amber';
interface LoginDialogProps { interface LoginDialogProps {
isOpen: boolean; isOpen: boolean;
@ -34,7 +34,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
}>({}); }>({});
const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial'); const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial');
const unsubAmberRef = useRef<(() => void) | null>(null); const unsubAmberRef = useRef<(() => void) | null>(null);
// Note: amberPubkeyInput removed — login now completes automatically via /amber-callback const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const login = useLoginActions(); const login = useLoginActions();
const { toast } = useToast(); const { toast } = useToast();
@ -46,19 +46,18 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
setErrors({}); setErrors({});
setAmberStep('initial'); setAmberStep('initial');
} }
// Clean up visibility listener when dialog closes if (!isOpen) {
if (!isOpen && unsubAmberRef.current) { // Clean up listeners when dialog closes
unsubAmberRef.current(); if (unsubAmberRef.current) { unsubAmberRef.current(); unsubAmberRef.current = null; }
unsubAmberRef.current = null; if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
} }
}, [isOpen]); }, [isOpen]);
// Clean up on unmount // Clean up on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (unsubAmberRef.current) { if (unsubAmberRef.current) unsubAmberRef.current();
unsubAmberRef.current(); if (pollRef.current) clearInterval(pollRef.current);
}
}; };
}, []); }, []);
@ -91,14 +90,28 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
setAmberStep('waiting'); setAmberStep('waiting');
setErrors(prev => ({ ...prev, amber: undefined })); setErrors(prev => ({ ...prev, amber: undefined }));
// Save current path so AmberCallbackPage can send user back here // Use localStorage (not sessionStorage) — survives navigation on Android browsers
sessionStorage.setItem('amber_return_path', window.location.pathname); localStorage.setItem('amber_return_path', window.location.pathname);
// Stamp the time so we know this is a fresh attempt
localStorage.setItem('amber_login_attempt_ts', String(Date.now()));
// Subscribe: when user comes back to this tab (e.g. pressed Back in Amber // Poll localStorage every 500ms — on Android, the page navigates away and
// without completing), reset to initial so they can try again // AmberCallbackPage completes the login in a new page load. When user comes
// back here, polling will detect the stored amber_login pubkey and complete.
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(() => {
const storedPubkey = getAmberLogin();
if (storedPubkey) {
clearInterval(pollRef.current!);
pollRef.current = null;
setAmberStep('success');
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 600);
}
}, 500);
// Also try visibilitychange for browsers that keep the tab alive
unsubAmberRef.current = subscribeToAmberReturn(() => { unsubAmberRef.current = subscribeToAmberReturn(() => {
// If we're still in 'waiting' state the callback page didn't fire —
// user likely cancelled. Reset gracefully.
setAmberStep(prev => prev === 'waiting' ? 'initial' : prev); setAmberStep(prev => prev === 'waiting' ? 'initial' : prev);
unsubAmberRef.current = null; unsubAmberRef.current = null;
}); });
@ -334,7 +347,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
<div> <div>
<p className='text-sm text-white font-bold mb-1'>Waiting for Amber...</p> <p className='text-sm text-white font-bold mb-1'>Waiting for Amber...</p>
<p className='text-xs text-gray-400'> <p className='text-xs text-gray-400'>
Approve the request in Amber. You'll be logged in automatically when you return. Approve the request in Amber, then come back here.
</p> </p>
</div> </div>
{/* Animated dots */} {/* Animated dots */}
@ -347,18 +360,44 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
/> />
))} ))}
</div> </div>
{/* Fallback for Android browsers that navigate away —
polling handles auto-complete, but this button is a
manual escape hatch if the user lands back here stuck */}
<p className="text-xs text-gray-500">
Already approved in Amber?
</p>
<Button <Button
variant="ghost" className="w-full rounded-full bg-[#ccff00]/20 border border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/30 font-bold text-sm h-auto py-3"
className="text-xs text-gray-500 hover:text-gray-300"
onClick={() => { onClick={() => {
setAmberStep('initial'); // Check localStorage right now — AmberCallbackPage may
if (unsubAmberRef.current) { // have already stored the pubkey during the redirect
unsubAmberRef.current(); const storedPubkey = getAmberLogin();
unsubAmberRef.current = null; if (storedPubkey) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setAmberStep('success');
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 600);
} else {
setErrors(prev => ({ ...prev, amber: 'Not logged in yet — make sure you approved in Amber first.' }));
} }
}} }}
> >
Cancel I approved — complete login
</Button>
{errors.amber && (
<p className="text-xs text-red-400">{errors.amber}</p>
)}
<Button
variant="ghost"
className="text-xs text-gray-600 hover:text-gray-400 h-auto py-1"
onClick={() => {
setAmberStep('initial');
setErrors(prev => ({ ...prev, amber: undefined }));
if (unsubAmberRef.current) { unsubAmberRef.current(); unsubAmberRef.current = null; }
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}}
>
Cancel, try again
</Button> </Button>
</div> </div>
)} )}

View file

@ -2,17 +2,15 @@
* AmberCallbackPage * AmberCallbackPage
* *
* Amber (NIP-55) redirects back to /amber-callback?event=<pubkey|signedEvent> * Amber (NIP-55) redirects back to /amber-callback?event=<pubkey|signedEvent>
* after the user approves a request. This page processes the result immediately * after the user approves a request. This page processes the result and
* and redirects the user back to where they came from (or home). * redirects the user back to where they came from (or home).
* *
* Having a dedicated route means: * On Android browsers (including GrapheneOS), window.location.href navigates
* - The main app state is never destroyed on outgoing redirect * the tab away, so we use localStorage (not sessionStorage) for persistence.
* - Amber always knows where to return regardless of which page opened it
* - Processing is fast and automatic — no manual pasting needed
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { import {
@ -21,7 +19,8 @@ import {
clearAmberPendingOperation, clearAmberPendingOperation,
cleanAmberCallbackUrl, cleanAmberCallbackUrl,
} from '@/lib/amber'; } from '@/lib/amber';
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'; import { CheckCircle2, XCircle, Loader2, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
type Status = 'processing' | 'success' | 'error'; type Status = 'processing' | 'success' | 'error';
@ -47,14 +46,13 @@ export default function AmberCallbackPage() {
setStatus('error'); setStatus('error');
setMessage('No result received from Amber. The request may have been cancelled.'); setMessage('No result received from Amber. The request may have been cancelled.');
clearAmberPendingOperation(); clearAmberPendingOperation();
redirectBack(false);
return; return;
} }
const pendingOp = await getAmberPendingOperationAsync(); const pendingOp = await getAmberPendingOperationAsync();
const pubkeyRegex = /^[0-9a-fA-F]{64}$/; const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
// ── get_public_key (login) ────────────────────────────────────────────── // ── get_public_key (login) ──────────────────────────────────────────
const isLoginOp = !pendingOp || pendingOp.type === 'get_public_key'; const isLoginOp = !pendingOp || pendingOp.type === 'get_public_key';
if (isLoginOp) { if (isLoginOp) {
if (pubkeyRegex.test(result)) { if (pubkeyRegex.test(result)) {
@ -70,22 +68,20 @@ export default function AmberCallbackPage() {
setStatus('error'); setStatus('error');
setMessage('Login failed. Please try again.'); setMessage('Login failed. Please try again.');
clearAmberPendingOperation(); clearAmberPendingOperation();
redirectBack(false);
} }
} else { } else {
setStatus('error'); setStatus('error');
setMessage('Invalid pubkey received from Amber.'); setMessage('Invalid response from Amber. Please try again.');
clearAmberPendingOperation(); clearAmberPendingOperation();
redirectBack(false);
} }
return; return;
} }
// ── sign_event ────────────────────────────────────────────────────────── // ── sign_event ──────────────────────────────────────────────────────
if (pendingOp.type === 'sign_event') { if (pendingOp.type === 'sign_event') {
try { try {
const signedEvent = JSON.parse(result); const signedEvent = JSON.parse(result);
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent)); localStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation(); clearAmberPendingOperation();
setStatus('success'); setStatus('success');
setMessage('Event signed! Returning...'); setMessage('Event signed! Returning...');
@ -95,14 +91,13 @@ export default function AmberCallbackPage() {
setStatus('error'); setStatus('error');
setMessage('Failed to process signed event from Amber.'); setMessage('Failed to process signed event from Amber.');
clearAmberPendingOperation(); clearAmberPendingOperation();
redirectBack(false);
} }
return; return;
} }
// ── encrypt / decrypt ─────────────────────────────────────────────────── // ── encrypt / decrypt ───────────────────────────────────────────────
if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) { if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) {
sessionStorage.setItem('amber_crypto_result', result); localStorage.setItem('amber_crypto_result', result);
clearAmberPendingOperation(); clearAmberPendingOperation();
setStatus('success'); setStatus('success');
setMessage('Operation complete! Returning...'); setMessage('Operation complete! Returning...');
@ -110,22 +105,23 @@ export default function AmberCallbackPage() {
return; return;
} }
// Unknown
clearAmberPendingOperation(); clearAmberPendingOperation();
redirectBack(false); redirectBack(false);
} }
function redirectBack(success: boolean) { function redirectBack(success: boolean) {
// Try to go back to where we came from, fall back to home // Use localStorage — survives navigation on Android browsers
const returnTo = sessionStorage.getItem('amber_return_path') || '/'; const returnTo = localStorage.getItem('amber_return_path') || '/';
sessionStorage.removeItem('amber_return_path'); localStorage.removeItem('amber_return_path');
setTimeout(() => navigate(returnTo, { replace: true }), success ? 800 : 1500); localStorage.removeItem('amber_login_attempt_ts');
setTimeout(() => navigate(returnTo, { replace: true }), success ? 800 : 0);
} }
}, [login, toast, navigate]); }, [login, toast, navigate]);
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center px-4"> <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"> <div className="max-w-sm w-full text-center space-y-6">
{/* Icon */} {/* Icon */}
<div className="flex justify-center"> <div className="flex justify-center">
{status === 'processing' && ( {status === 'processing' && (
@ -145,13 +141,13 @@ export default function AmberCallbackPage() {
)} )}
</div> </div>
{/* Amber logo text */} {/* Message */}
<div> <div>
<p className="text-xs text-gray-500 uppercase tracking-widest mb-1">Amber · NIP-55</p> <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> <p className="text-white font-bold text-lg">{message}</p>
</div> </div>
{/* Dots loader */} {/* Processing dots */}
{status === 'processing' && ( {status === 'processing' && (
<div className="flex justify-center gap-1.5"> <div className="flex justify-center gap-1.5">
{[0, 1, 2].map(i => ( {[0, 1, 2].map(i => (
@ -163,6 +159,18 @@ export default function AmberCallbackPage() {
))} ))}
</div> </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>
</div> </div>
); );