diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 54b500e..20ffce5 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -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 = ({ 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 | null>(null); const login = useLoginActions(); const { toast } = useToast(); @@ -46,19 +46,18 @@ const LoginDialog: React.FC = ({ 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 = ({ 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 = ({ isOpen, onClose, onLogin, onS

Waiting for Amber...

- Approve the request in Amber. You'll be logged in automatically when you return. + Approve the request in Amber, then come back here.

{/* Animated dots */} @@ -347,18 +360,44 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS /> ))} + {/* 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 */} +

+ Already approved in Amber? +

+ {errors.amber && ( +

{errors.amber}

+ )} + )} diff --git a/src/pages/AmberCallbackPage.tsx b/src/pages/AmberCallbackPage.tsx index d8ce71e..26f720e 100644 --- a/src/pages/AmberCallbackPage.tsx +++ b/src/pages/AmberCallbackPage.tsx @@ -2,17 +2,15 @@ * AmberCallbackPage * * Amber (NIP-55) redirects back to /amber-callback?event= - * 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 (
+ {/* Icon */}
{status === 'processing' && ( @@ -145,13 +141,13 @@ export default function AmberCallbackPage() { )}
- {/* Amber logo text */} + {/* Message */}

Amber ยท NIP-55

{message}

- {/* Dots loader */} + {/* Processing dots */} {status === 'processing' && (
{[0, 1, 2].map(i => ( @@ -163,6 +159,18 @@ export default function AmberCallbackPage() { ))}
)} + + {/* Error fallback buttons */} + {status === 'error' && ( +
+ + + +
+ )}
);