Improve Amber login flow - zero friction, auto-complete on return

- Add dedicated /amber-callback route so Amber always has a stable return URL
  - Handles get_public_key (login), sign_event, nip44/nip04 encrypt/decrypt
  - Shows animated processing screen while completing, then redirects back
- Add subscribeToAmberReturn() helper in amber.ts using visibilitychange
  - Auto-detects when user returns to the tab after cancelling in Amber
  - Resets login UI to initial state gracefully
- Update buildCallbackUrl() to use /amber-callback instead of current page URL
- Rewrite Amber tab in LoginDialog:
  - Remove 2-step clipboard paste flow entirely
  - Single tap opens Amber, login completes automatically on return
  - Waiting state shows animated dots + cancel button
  - No manual pubkey entry needed
- Save return path in sessionStorage so AmberCallbackPage redirects user back

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-17 22:29:06 +07:00
parent aac2b7b9a2
commit 03d0fe93b0
4 changed files with 316 additions and 173 deletions

View file

@ -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() {
<Route path="/card" element={<RiderCardPage />} />
<Route path="/license" element={<LicensePage />} />
<Route path="/supporters" element={<SupportersPage />} />
{/* Amber NIP-55 callback — Amber redirects here after signing/login */}
<Route path="/amber-callback" element={<AmberCallbackPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View file

@ -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<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ 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=<pubkey> 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<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
</AlertDescription>
</Alert>
)}
{/* Step 1: Open Amber */}
{/* Initial state — single tap */}
{amberStep === 'initial' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30'>
<Smartphone className='w-12 h-12 mx-auto mb-3 text-[#ccff00]' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
Login with Amber (Android)
</p>
<p className='text-xs text-gray-400 mb-4'>
Tap the button below to open Amber. Select your profile and approve the request. Then return here to complete login.
</p>
<div className="flex justify-center">
<Button
className='w-full rounded-full py-4 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 border-2 border-[#ccff00] shadow-[0_0_20px_rgba(204,255,0,0.5)] font-bold uppercase'
onClick={handleAmberLogin}
disabled={isLoading || isIOS}
>
{isIOS ? 'Not available on iOS' : '1. Open Amber'}
</Button>
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className="w-16 h-16 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Smartphone className='w-8 h-8 text-[#ccff00]' />
</div>
<p className='text-xs text-gray-400 mt-3'>
Don't have Amber? <a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer" className="text-[#ccff00] hover:underline">Download from GitHub</a> or F-Droid
<div>
<p className='text-sm text-white font-bold mb-1'>
Login with Amber
</p>
<p className='text-xs text-gray-400'>
Tap below — Amber opens, you approve once, and you're automatically logged in. No copy-paste needed.
</p>
</div>
<Button
className='w-full rounded-full py-4 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 border-2 border-[#ccff00] shadow-[0_0_20px_rgba(204,255,0,0.5)] font-bold uppercase text-base h-auto'
onClick={handleAmberLogin}
disabled={isLoading || isIOS}
>
<Smartphone className="w-4 h-4 mr-2" />
{isIOS ? 'Android only' : 'Open Amber'}
</Button>
<p className='text-xs text-gray-500'>
Don't have Amber?{' '}
<a
href="https://github.com/greenart7c3/Amber/releases"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00] hover:underline"
>
Download on GitHub
</a>
{' '}or{' '}
<a
href="https://f-droid.org/packages/com.greenart7c3.nostrsigner/"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00] hover:underline"
>
F-Droid
</a>
</p>
</div>
)}
{/* Step 2: Paste from clipboard */}
{/* Waiting — user is in Amber */}
{amberStep === 'waiting' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30'>
<ClipboardPaste className='w-12 h-12 mx-auto mb-3 text-[#ccff00]' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
Step 2: Complete Login
</p>
<p className='text-xs text-gray-400 mb-4'>
After approving in Amber, your pubkey was copied to clipboard. Tap the button below to paste it and complete login.
</p>
{errors.amber && (
<Alert variant="destructive" className="mb-4 bg-red-950/50 border-red-500/50 text-left">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">{errors.amber}</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<Button
className='w-full rounded-full py-4 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 border-2 border-[#ccff00] shadow-[0_0_20px_rgba(204,255,0,0.5)] font-bold uppercase'
onClick={handleAmberPaste}
>
<ClipboardPaste className="w-4 h-4 mr-2" />
2. Paste from Clipboard
</Button>
<div className="text-xs text-gray-500">or paste manually:</div>
<div className="flex gap-2">
<Input
value={amberPubkeyInput}
onChange={(e) => setAmberPubkeyInput(e.target.value)}
placeholder="Paste your pubkey (64 hex chars)"
className="flex-1 text-xs"
/>
<Button
onClick={handleAmberManualInput}
disabled={!amberPubkeyInput.trim()}
className="bg-[#ccff00]/20 text-[#ccff00] hover:bg-[#ccff00]/30"
>
Go
</Button>
</div>
<Button
variant="ghost"
className="w-full text-xs text-gray-500"
onClick={() => setAmberStep('initial')}
>
← Back to Step 1
</Button>
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
<div className="w-16 h-16 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin' />
</div>
<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.
</p>
</div>
{/* Animated dots */}
<div className="flex justify-center gap-1.5">
{[0, 1, 2].map(i => (
<span
key={i}
className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
<Button
variant="ghost"
className="text-xs text-gray-500 hover:text-gray-300"
onClick={() => {
setAmberStep('initial');
if (unsubAmberRef.current) {
unsubAmberRef.current();
unsubAmberRef.current = null;
}
}}
>
Cancel
</Button>
</div>
)}
{/* Success state */}
{/* Success */}
{amberStep === 'success' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-green-500/30'>
<Check className='w-12 h-12 mx-auto mb-3 text-green-500' />
<p className='text-sm text-green-400 mb-2 font-bold'>
Successfully logged in!
</p>
<Loader2 className="w-6 h-6 mx-auto animate-spin text-green-500" />
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-green-500/30 space-y-3'>
<Check className='w-12 h-12 mx-auto text-green-500' />
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
<Loader2 className="w-5 h-5 mx-auto animate-spin text-green-500" />
</div>
)}
</TabsContent>

View file

@ -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.

View file

@ -0,0 +1,169 @@
/**
* 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).
*
* 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<Status>('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 (
<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' && (
<div className="w-20 h-20 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center">
<Loader2 className="w-10 h-10 text-[#ccff00] animate-spin" />
</div>
)}
{status === 'success' && (
<div className="w-20 h-20 rounded-full bg-green-500/10 border-2 border-green-500/40 flex items-center justify-center">
<CheckCircle2 className="w-10 h-10 text-green-500" />
</div>
)}
{status === 'error' && (
<div className="w-20 h-20 rounded-full bg-red-500/10 border-2 border-red-500/40 flex items-center justify-center">
<XCircle className="w-10 h-10 text-red-500" />
</div>
)}
</div>
{/* Amber logo text */}
<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 */}
{status === 'processing' && (
<div className="flex justify-center gap-1.5">
{[0, 1, 2].map(i => (
<span
key={i}
className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
)}
</div>
</div>
);
}