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:
parent
aac2b7b9a2
commit
03d0fe93b0
4 changed files with 316 additions and 173 deletions
|
|
@ -41,6 +41,7 @@ import { NIP19Page } from "./pages/NIP19Page";
|
||||||
import LicensePage from "./pages/LicensePage";
|
import LicensePage from "./pages/LicensePage";
|
||||||
import SupportersPage from "./pages/SupportersPage";
|
import SupportersPage from "./pages/SupportersPage";
|
||||||
import RiderCardPage from "./pages/RiderCardPage";
|
import RiderCardPage from "./pages/RiderCardPage";
|
||||||
|
import AmberCallbackPage from "./pages/AmberCallbackPage";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
|
||||||
export function AppRouter() {
|
export function AppRouter() {
|
||||||
|
|
@ -93,6 +94,8 @@ export function AppRouter() {
|
||||||
<Route path="/card" element={<RiderCardPage />} />
|
<Route path="/card" element={<RiderCardPage />} />
|
||||||
<Route path="/license" element={<LicensePage />} />
|
<Route path="/license" element={<LicensePage />} />
|
||||||
<Route path="/supporters" element={<SupportersPage />} />
|
<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 */}
|
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||||
<Route path="/:nip19" element={<NIP19Page />} />
|
<Route path="/:nip19" element={<NIP19Page />} />
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
// NOTE: This file is stable and usually should not be modified.
|
// 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.
|
// 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 React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Shield, AlertTriangle, UserPlus, Sparkles, Cloud, Smartphone, ClipboardPaste, Check, Loader2 } from 'lucide-react';
|
import { Shield, AlertTriangle, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { subscribeToAmberReturn } from '@/lib/amber';
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -32,22 +33,35 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
amber?: string;
|
amber?: string;
|
||||||
}>({});
|
}>({});
|
||||||
const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial');
|
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 login = useLoginActions();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Reset all state when dialog opens/closes
|
// Reset all state when dialog opens/closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Reset state when dialog opens
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setBunkerUri('');
|
setBunkerUri('');
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setAmberStep('initial');
|
setAmberStep('initial');
|
||||||
setAmberPubkeyInput('');
|
}
|
||||||
|
// Clean up visibility listener when dialog closes
|
||||||
|
if (!isOpen && unsubAmberRef.current) {
|
||||||
|
unsubAmberRef.current();
|
||||||
|
unsubAmberRef.current = null;
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (unsubAmberRef.current) {
|
||||||
|
unsubAmberRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleExtensionLogin = async () => {
|
const handleExtensionLogin = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrors(prev => ({ ...prev, extension: undefined }));
|
setErrors(prev => ({ ...prev, extension: undefined }));
|
||||||
|
|
@ -74,81 +88,25 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAmberLogin = () => {
|
const handleAmberLogin = () => {
|
||||||
// For PWA: Open Amber WITHOUT callback URL so it copies to clipboard
|
|
||||||
// Then user can paste the pubkey
|
|
||||||
setAmberStep('waiting');
|
setAmberStep('waiting');
|
||||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
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 () => {
|
// Save current path so AmberCallbackPage can send user back here
|
||||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
sessionStorage.setItem('amber_return_path', window.location.pathname);
|
||||||
|
|
||||||
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.'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAmberManualInput = () => {
|
// Subscribe: when user comes back to this tab (e.g. pressed Back in Amber
|
||||||
const pubkey = amberPubkeyInput.trim().toLowerCase();
|
// without completing), reset to initial so they can try again
|
||||||
|
unsubAmberRef.current = subscribeToAmberReturn(() => {
|
||||||
// Validate pubkey format (64 hex characters)
|
// If we're still in 'waiting' state the callback page didn't fire —
|
||||||
const pubkeyRegex = /^[0-9a-f]{64}$/;
|
// user likely cancelled. Reset gracefully.
|
||||||
if (!pubkeyRegex.test(pubkey)) {
|
setAmberStep(prev => prev === 'waiting' ? 'initial' : prev);
|
||||||
setErrors(prev => ({
|
unsubAmberRef.current = null;
|
||||||
...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!',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
// Open Amber — it will call /amber-callback?event=<pubkey> on approve
|
||||||
onLogin();
|
const callbackUrl = `${window.location.origin}/amber-callback?event=`;
|
||||||
onClose();
|
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
||||||
}, 500);
|
window.location.href = amberUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBunkerLogin = async () => {
|
const handleBunkerLogin = async () => {
|
||||||
|
|
@ -321,96 +279,96 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 1: Open Amber */}
|
{/* Initial state — single tap */}
|
||||||
{amberStep === 'initial' && (
|
{amberStep === 'initial' && (
|
||||||
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30'>
|
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
|
||||||
<Smartphone className='w-12 h-12 mx-auto mb-3 text-[#ccff00]' />
|
<div className="w-16 h-16 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
|
||||||
<p className='text-sm text-gray-300 mb-2 font-bold'>
|
<Smartphone className='w-8 h-8 text-[#ccff00]' />
|
||||||
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>
|
</div>
|
||||||
<p className='text-xs text-gray-400 mt-3'>
|
<div>
|
||||||
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
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Paste from clipboard */}
|
{/* Waiting — user is in Amber */}
|
||||||
{amberStep === 'waiting' && (
|
{amberStep === 'waiting' && (
|
||||||
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30'>
|
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
|
||||||
<ClipboardPaste className='w-12 h-12 mx-auto mb-3 text-[#ccff00]' />
|
<div className="w-16 h-16 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
|
||||||
<p className='text-sm text-gray-300 mb-2 font-bold'>
|
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin' />
|
||||||
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success state */}
|
{/* Success */}
|
||||||
{amberStep === 'success' && (
|
{amberStep === 'success' && (
|
||||||
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-green-500/30'>
|
<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 mb-3 text-green-500' />
|
<Check className='w-12 h-12 mx-auto text-green-500' />
|
||||||
<p className='text-sm text-green-400 mb-2 font-bold'>
|
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
|
||||||
Successfully logged in!
|
<Loader2 className="w-5 h-5 mx-auto animate-spin text-green-500" />
|
||||||
</p>
|
|
||||||
<Loader2 className="w-6 h-6 mx-auto animate-spin text-green-500" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function buildCallbackUrl(): string {
|
||||||
const baseUrl = window.location.origin + window.location.pathname;
|
return `${window.location.origin}/amber-callback?event=`;
|
||||||
return `${baseUrl}?event=`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open Amber for get_public_key operation.
|
* 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 {
|
export function openAmberForLogin(): void {
|
||||||
const operationId = generateOperationId();
|
const operationId = generateOperationId();
|
||||||
const callbackUrl = buildCallbackUrl();
|
const callbackUrl = buildCallbackUrl();
|
||||||
|
|
||||||
const pendingOp = {
|
const pendingOp = {
|
||||||
type: 'get_public_key' as const,
|
type: 'get_public_key' as const,
|
||||||
id: operationId,
|
id: operationId,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[Amber] Opening Amber for login...');
|
|
||||||
console.log('[Amber] Callback URL:', callbackUrl);
|
|
||||||
console.log('[Amber] Pending operation:', pendingOp);
|
|
||||||
|
|
||||||
setAmberPendingOperation(pendingOp);
|
setAmberPendingOperation(pendingOp);
|
||||||
|
|
||||||
// Verify it was stored
|
// NIP-55 nostrsigner: URI — Amber opens, user approves, Amber calls callbackUrl
|
||||||
const stored = localStorage.getItem('amber_pending_operation');
|
|
||||||
console.log('[Amber] Stored in localStorage:', stored);
|
|
||||||
|
|
||||||
// NIP-55 format for web applications
|
|
||||||
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(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;
|
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.
|
* Open Amber for sign_event operation.
|
||||||
* Returns a promise that resolves when user returns to the page.
|
* Returns a promise that resolves when user returns to the page.
|
||||||
|
|
|
||||||
169
src/pages/AmberCallbackPage.tsx
Normal file
169
src/pages/AmberCallbackPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue