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 { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { subscribeToAmberReturn } from '@/lib/amber';
import { subscribeToAmberReturn, getAmberLogin } from '@/lib/amber';
interface LoginDialogProps {
isOpen: boolean;
@ -34,7 +34,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
}>({});
const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial');
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 { toast } = useToast();
@ -46,19 +46,18 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
setErrors({});
setAmberStep('initial');
}
// Clean up visibility listener when dialog closes
if (!isOpen && unsubAmberRef.current) {
unsubAmberRef.current();
unsubAmberRef.current = null;
if (!isOpen) {
// Clean up listeners when dialog closes
if (unsubAmberRef.current) { unsubAmberRef.current(); unsubAmberRef.current = null; }
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}
}, [isOpen]);
// Clean up on unmount
useEffect(() => {
return () => {
if (unsubAmberRef.current) {
unsubAmberRef.current();
}
if (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');
setErrors(prev => ({ ...prev, amber: undefined }));
// Save current path so AmberCallbackPage can send user back here
sessionStorage.setItem('amber_return_path', window.location.pathname);
// Use localStorage (not sessionStorage) — survives navigation on Android browsers
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
// without completing), reset to initial so they can try again
// Poll localStorage every 500ms — on Android, the page navigates away and
// 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(() => {
// If we're still in 'waiting' state the callback page didn't fire —
// user likely cancelled. Reset gracefully.
setAmberStep(prev => prev === 'waiting' ? 'initial' : prev);
unsubAmberRef.current = null;
});
@ -334,7 +347,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
<div>
<p className='text-sm text-white font-bold mb-1'>Waiting for Amber...</p>
<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>
</div>
{/* Animated dots */}
@ -347,18 +360,44 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
/>
))}
</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
variant="ghost"
className="text-xs text-gray-500 hover:text-gray-300"
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"
onClick={() => {
setAmberStep('initial');
if (unsubAmberRef.current) {
unsubAmberRef.current();
unsubAmberRef.current = null;
// Check localStorage right now — AmberCallbackPage may
// have already stored the pubkey during the redirect
const storedPubkey = getAmberLogin();
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>
</div>
)}

View file

@ -2,17 +2,15 @@
* AmberCallbackPage
*
* Amber (NIP-55) redirects back to /amber-callback?event=<pubkey|signedEvent>
* after the user approves a request. This page processes the result immediately
* and redirects the user back to where they came from (or home).
* after the user approves a request. This page processes the result and
* redirects the user back to where they came from (or home).
*
* Having a dedicated route means:
* - The main app state is never destroyed on outgoing redirect
* - Amber always knows where to return regardless of which page opened it
* - Processing is fast and automatic — no manual pasting needed
* 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 } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import {
@ -21,7 +19,8 @@ import {
clearAmberPendingOperation,
cleanAmberCallbackUrl,
} 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';
@ -47,14 +46,13 @@ export default function AmberCallbackPage() {
setStatus('error');
setMessage('No result received from Amber. The request may have been cancelled.');
clearAmberPendingOperation();
redirectBack(false);
return;
}
const pendingOp = await getAmberPendingOperationAsync();
const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
// ── get_public_key (login) ──────────────────────────────────────────────
// ── get_public_key (login) ──────────────────────────────────────────
const isLoginOp = !pendingOp || pendingOp.type === 'get_public_key';
if (isLoginOp) {
if (pubkeyRegex.test(result)) {
@ -70,22 +68,20 @@ export default function AmberCallbackPage() {
setStatus('error');
setMessage('Login failed. Please try again.');
clearAmberPendingOperation();
redirectBack(false);
}
} else {
setStatus('error');
setMessage('Invalid pubkey received from Amber.');
setMessage('Invalid response from Amber. Please try again.');
clearAmberPendingOperation();
redirectBack(false);
}
return;
}
// ── sign_event ──────────────────────────────────────────────────────────
// ── sign_event ──────────────────────────────────────────────────────
if (pendingOp.type === 'sign_event') {
try {
const signedEvent = JSON.parse(result);
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
localStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation();
setStatus('success');
setMessage('Event signed! Returning...');
@ -95,14 +91,13 @@ export default function AmberCallbackPage() {
setStatus('error');
setMessage('Failed to process signed event from Amber.');
clearAmberPendingOperation();
redirectBack(false);
}
return;
}
// ── encrypt / decrypt ───────────────────────────────────────────────────
// ── encrypt / decrypt ───────────────────────────────────────────────
if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) {
sessionStorage.setItem('amber_crypto_result', result);
localStorage.setItem('amber_crypto_result', result);
clearAmberPendingOperation();
setStatus('success');
setMessage('Operation complete! Returning...');
@ -110,22 +105,23 @@ export default function AmberCallbackPage() {
return;
}
// Unknown
clearAmberPendingOperation();
redirectBack(false);
}
function redirectBack(success: boolean) {
// Try to go back to where we came from, fall back to home
const returnTo = sessionStorage.getItem('amber_return_path') || '/';
sessionStorage.removeItem('amber_return_path');
setTimeout(() => navigate(returnTo, { replace: true }), success ? 800 : 1500);
// 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' && (
@ -145,13 +141,13 @@ export default function AmberCallbackPage() {
)}
</div>
{/* Amber logo text */}
{/* 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>
{/* Dots loader */}
{/* Processing dots */}
{status === 'processing' && (
<div className="flex justify-center gap-1.5">
{[0, 1, 2].map(i => (
@ -163,6 +159,18 @@ export default function AmberCallbackPage() {
))}
</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>
);