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:
parent
03d0fe93b0
commit
4398c2e73b
2 changed files with 95 additions and 48 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue