diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 28bff0c..9f6f53e 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -41,6 +41,7 @@ import { NIP19Page } from "./pages/NIP19Page"; import LicensePage from "./pages/LicensePage"; import SupportersPage from "./pages/SupportersPage"; import RiderCardPage from "./pages/RiderCardPage"; +import AmberCallbackPage from "./pages/AmberCallbackPage"; import NotFound from "./pages/NotFound"; export function AppRouter() { @@ -93,6 +94,8 @@ export function AppRouter() { } /> } /> } /> + {/* Amber NIP-55 callback — Amber redirects here after signing/login */} + } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index fffef8d..54b500e 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -1,16 +1,17 @@ // NOTE: This file is stable and usually should not be modified. // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. -import React, { useState, useEffect } from 'react'; -import { Shield, AlertTriangle, UserPlus, Sparkles, Cloud, Smartphone, ClipboardPaste, Check, Loader2 } from 'lucide-react'; +import React, { useState, useEffect, useRef } from 'react'; +import { Shield, AlertTriangle, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { Input } from '@/components/ui/input'; // used for bunker URI input import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; 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'; interface LoginDialogProps { isOpen: boolean; @@ -32,22 +33,35 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS amber?: string; }>({}); const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial'); - const [amberPubkeyInput, setAmberPubkeyInput] = useState(''); + const unsubAmberRef = useRef<(() => void) | null>(null); + // Note: amberPubkeyInput removed — login now completes automatically via /amber-callback const login = useLoginActions(); const { toast } = useToast(); // Reset all state when dialog opens/closes useEffect(() => { if (isOpen) { - // Reset state when dialog opens setIsLoading(false); setBunkerUri(''); setErrors({}); setAmberStep('initial'); - setAmberPubkeyInput(''); + } + // Clean up visibility listener when dialog closes + if (!isOpen && unsubAmberRef.current) { + unsubAmberRef.current(); + unsubAmberRef.current = null; } }, [isOpen]); + // Clean up on unmount + useEffect(() => { + return () => { + if (unsubAmberRef.current) { + unsubAmberRef.current(); + } + }; + }, []); + const handleExtensionLogin = async () => { setIsLoading(true); setErrors(prev => ({ ...prev, extension: undefined })); @@ -74,81 +88,25 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS }; const handleAmberLogin = () => { - // For PWA: Open Amber WITHOUT callback URL so it copies to clipboard - // Then user can paste the pubkey setAmberStep('waiting'); setErrors(prev => ({ ...prev, amber: undefined })); - - // Open Amber without callback - it will copy pubkey to clipboard - const amberUrl = 'nostrsigner:?compressionType=none&returnType=signature&type=get_public_key'; - window.location.href = amberUrl; - }; - const handleAmberPaste = async () => { - setErrors(prev => ({ ...prev, amber: undefined })); - - try { - // Try to read from clipboard - const text = await navigator.clipboard.readText(); - const pubkey = text.trim().toLowerCase(); - - // Validate pubkey format (64 hex characters) - const pubkeyRegex = /^[0-9a-f]{64}$/; - if (!pubkeyRegex.test(pubkey)) { - setErrors(prev => ({ - ...prev, - amber: 'Invalid pubkey format. Make sure Amber copied the pubkey to clipboard.' - })); - return; - } - - // Complete login - login.amber(pubkey); - setAmberStep('success'); - toast({ - title: 'Logged in', - description: 'Successfully logged in with Amber!', - }); - - setTimeout(() => { - onLogin(); - onClose(); - }, 500); - - } catch (error) { - console.error('Clipboard read failed:', error); - setErrors(prev => ({ - ...prev, - amber: 'Could not read clipboard. Please paste your pubkey manually below.' - })); - } - }; + // Save current path so AmberCallbackPage can send user back here + sessionStorage.setItem('amber_return_path', window.location.pathname); - const handleAmberManualInput = () => { - const pubkey = amberPubkeyInput.trim().toLowerCase(); - - // Validate pubkey format (64 hex characters) - const pubkeyRegex = /^[0-9a-f]{64}$/; - if (!pubkeyRegex.test(pubkey)) { - setErrors(prev => ({ - ...prev, - amber: 'Invalid pubkey. Must be 64 hexadecimal characters.' - })); - return; - } - - // Complete login - login.amber(pubkey); - setAmberStep('success'); - toast({ - title: 'Logged in', - description: 'Successfully logged in with Amber!', + // Subscribe: when user comes back to this tab (e.g. pressed Back in Amber + // without completing), reset to initial so they can try again + 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; }); - - setTimeout(() => { - onLogin(); - onClose(); - }, 500); + + // Open Amber — it will call /amber-callback?event= on approve + const callbackUrl = `${window.location.origin}/amber-callback?event=`; + const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`; + window.location.href = amberUrl; }; const handleBunkerLogin = async () => { @@ -321,96 +279,96 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS )} - - {/* Step 1: Open Amber */} + + {/* Initial state — single tap */} {amberStep === 'initial' && ( -
- -

- Login with Amber (Android) -

-

- Tap the button below to open Amber. Select your profile and approve the request. Then return here to complete login. -

-
- +
+
+
-

- Don't have Amber? Download from GitHub or F-Droid +

+

+ Login with Amber +

+

+ Tap below — Amber opens, you approve once, and you're automatically logged in. No copy-paste needed. +

+
+ +

+ Don't have Amber?{' '} + + Download on GitHub + + {' '}or{' '} + + F-Droid +

)} - - {/* Step 2: Paste from clipboard */} + + {/* Waiting — user is in Amber */} {amberStep === 'waiting' && ( -
- -

- Step 2: Complete Login -

-

- After approving in Amber, your pubkey was copied to clipboard. Tap the button below to paste it and complete login. -

- - {errors.amber && ( - - - {errors.amber} - - )} - -
- - -
or paste manually:
- -
- setAmberPubkeyInput(e.target.value)} - placeholder="Paste your pubkey (64 hex chars)" - className="flex-1 text-xs" - /> - -
- - +
+
+
+
+

Waiting for Amber...

+

+ Approve the request in Amber. You'll be logged in automatically when you return. +

+
+ {/* Animated dots */} +
+ {[0, 1, 2].map(i => ( + + ))} +
+
)} - - {/* Success state */} + + {/* Success */} {amberStep === 'success' && ( -
- -

- Successfully logged in! -

- +
+ +

Logged in!

+
)} diff --git a/src/lib/amber.ts b/src/lib/amber.ts index 3b03b59..10b0fe6 100644 --- a/src/lib/amber.ts +++ b/src/lib/amber.ts @@ -239,43 +239,56 @@ function generateOperationId(): string { } /** - * Build callback URL for Amber operations. + * Build the dedicated Amber callback URL. + * Using a fixed /amber-callback path so Amber always returns to the same place. */ function buildCallbackUrl(): string { - const baseUrl = window.location.origin + window.location.pathname; - return `${baseUrl}?event=`; + return `${window.location.origin}/amber-callback?event=`; } /** * Open Amber for get_public_key operation. + * Uses a dedicated /amber-callback route so the app state is preserved + * and the callback is processed reliably on return. */ export function openAmberForLogin(): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); - + const pendingOp = { type: 'get_public_key' as const, id: operationId, timestamp: Date.now(), }; - - console.log('[Amber] Opening Amber for login...'); - console.log('[Amber] Callback URL:', callbackUrl); - console.log('[Amber] Pending operation:', pendingOp); - + setAmberPendingOperation(pendingOp); - - // Verify it was stored - const stored = localStorage.getItem('amber_pending_operation'); - console.log('[Amber] Stored in localStorage:', stored); - - // NIP-55 format for web applications + + // NIP-55 nostrsigner: URI — Amber opens, user approves, Amber calls callbackUrl const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`; - - console.log('[Amber] Opening URL:', amberUrl); + + console.log('[Amber] Opening Amber for login, callback:', callbackUrl); window.location.href = amberUrl; } +/** + * Subscribe to app becoming visible again after user switches back from Amber. + * Calls `onReturn` once when the page regains visibility. + * Returns an unsubscribe function. + * + * Usage: + * const unsub = subscribeToAmberReturn(() => checkClipboardForPubkey()); + */ +export function subscribeToAmberReturn(onReturn: () => void): () => void { + const handler = () => { + if (document.visibilityState === 'visible') { + document.removeEventListener('visibilitychange', handler); + onReturn(); + } + }; + document.addEventListener('visibilitychange', handler); + return () => document.removeEventListener('visibilitychange', handler); +} + /** * Open Amber for sign_event operation. * Returns a promise that resolves when user returns to the page. diff --git a/src/pages/AmberCallbackPage.tsx b/src/pages/AmberCallbackPage.tsx new file mode 100644 index 0000000..d8ce71e --- /dev/null +++ b/src/pages/AmberCallbackPage.tsx @@ -0,0 +1,169 @@ +/** + * 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). + * + * 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 + */ + +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useLoginActions } from '@/hooks/useLoginActions'; +import { useToast } from '@/hooks/useToast'; +import { + getAmberCallbackResult, + getAmberPendingOperationAsync, + clearAmberPendingOperation, + cleanAmberCallbackUrl, +} from '@/lib/amber'; +import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'; + +type Status = 'processing' | 'success' | 'error'; + +export default function AmberCallbackPage() { + const navigate = useNavigate(); + const login = useLoginActions(); + const { toast } = useToast(); + const hasProcessed = useRef(false); + const [status, setStatus] = useState('processing'); + const [message, setMessage] = useState('Completing login with Amber...'); + + 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(); + redirectBack(false); + return; + } + + const pendingOp = await getAmberPendingOperationAsync(); + const pubkeyRegex = /^[0-9a-fA-F]{64}$/; + + // ── get_public_key (login) ────────────────────────────────────────────── + const isLoginOp = !pendingOp || pendingOp.type === 'get_public_key'; + if (isLoginOp) { + if (pubkeyRegex.test(result)) { + try { + login.amber(result.toLowerCase()); + clearAmberPendingOperation(); + setStatus('success'); + setMessage('Logged in! Returning to SkateSpots...'); + toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' }); + redirectBack(true); + } catch (err) { + console.error('[AmberCallback] login.amber failed:', err); + setStatus('error'); + setMessage('Login failed. Please try again.'); + clearAmberPendingOperation(); + redirectBack(false); + } + } else { + setStatus('error'); + setMessage('Invalid pubkey received from Amber.'); + clearAmberPendingOperation(); + redirectBack(false); + } + return; + } + + // ── sign_event ────────────────────────────────────────────────────────── + if (pendingOp.type === 'sign_event') { + try { + const signedEvent = JSON.parse(result); + sessionStorage.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); + } catch { + setStatus('error'); + setMessage('Failed to process signed event from Amber.'); + clearAmberPendingOperation(); + redirectBack(false); + } + return; + } + + // ── encrypt / decrypt ─────────────────────────────────────────────────── + if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) { + sessionStorage.setItem('amber_crypto_result', result); + clearAmberPendingOperation(); + setStatus('success'); + setMessage('Operation complete! Returning...'); + redirectBack(true); + 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); + } + }, [login, toast, navigate]); + + return ( +
+
+ {/* Icon */} +
+ {status === 'processing' && ( +
+ +
+ )} + {status === 'success' && ( +
+ +
+ )} + {status === 'error' && ( +
+ +
+ )} +
+ + {/* Amber logo text */} +
+

Amber · NIP-55

+

{message}

+
+ + {/* Dots loader */} + {status === 'processing' && ( +
+ {[0, 1, 2].map(i => ( + + ))} +
+ )} +
+
+ ); +}