fix: replace NIP-55 Amber login with NIP-46 bunker flow
NIP-55 (nostrsigner: intents) is fundamentally broken from Capacitor WebView — no combination of window.open, window.location.href, or anchor clicks works reliably across all Android WebView versions. Switch to NIP-46 (bunker://) which works everywhere via WebSockets: - User opens Amber → Connect apps → Add app → copies bunker:// URI - Pastes it into the login dialog - NLogin.fromBunker() completes the handshake over a relay - Login works in browser, PWA, and Capacitor APK identically This is exactly how Ditto (soapbox-pub) implements Amber login. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
00d9b69f81
commit
e8bc072787
1 changed files with 71 additions and 139 deletions
|
|
@ -11,7 +11,7 @@ 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, openAmberForLogin, openAmberForLoginNoCallback } from '@/lib/amber';
|
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -87,82 +87,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAmberLogin = () => {
|
const handleAmberLogin = () => {
|
||||||
|
// Show the bunker URI input — the user copies their bunker:// URI from Amber
|
||||||
setAmberStep('waiting');
|
setAmberStep('waiting');
|
||||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||||
|
|
||||||
// Detect if running as Capacitor native app or installed PWA (standalone)
|
|
||||||
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
|
|
||||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches
|
|
||||||
|| ('standalone' in window.navigator && (window.navigator as { standalone?: boolean }).standalone === true);
|
|
||||||
|
|
||||||
if (isCapacitor || isPWA) {
|
|
||||||
// ── Capacitor APK / PWA mode ───────────────────────────────────────────
|
|
||||||
// In Capacitor, callbackUrl flows don't work — Amber treats the custom
|
|
||||||
// scheme as a malformed request. Use no-callback mode: Amber copies the
|
|
||||||
// pubkey to clipboard, we read it when the app regains focus.
|
|
||||||
openAmberForLoginNoCallback();
|
|
||||||
|
|
||||||
// When user returns to the PWA after approving in Amber, auto-read clipboard
|
|
||||||
unsubAmberRef.current = subscribeToAmberReturn(async () => {
|
|
||||||
unsubAmberRef.current = null;
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
const pubkey = text.trim().toLowerCase();
|
|
||||||
const pubkeyRegex = /^[0-9a-f]{64}$/;
|
|
||||||
if (pubkeyRegex.test(pubkey)) {
|
|
||||||
login.amber(pubkey);
|
|
||||||
setAmberStep('success');
|
|
||||||
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
|
|
||||||
setTimeout(() => { onLogin(); onClose(); }, 600);
|
|
||||||
} else {
|
|
||||||
// Clipboard had something else — show manual button
|
|
||||||
setErrors(prev => ({ ...prev, amber: 'Could not auto-read pubkey. Tap "I approved" to complete login.' }));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Clipboard permission denied — show manual button
|
|
||||||
setErrors(prev => ({ ...prev, amber: 'Tap "I approved — complete login" to finish.' }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Browser mode (tab-based) ─────────────────────────────────────────────
|
|
||||||
// Use localStorage cross-tab signal: AmberCallbackPage writes
|
|
||||||
// 'amber_pending_pubkey', this tab's poll picks it up and calls login.amber()
|
|
||||||
localStorage.setItem('amber_return_path', window.location.pathname);
|
|
||||||
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
|
||||||
pollRef.current = setInterval(() => {
|
|
||||||
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
|
|
||||||
if (pendingPubkey) {
|
|
||||||
localStorage.removeItem('amber_pending_pubkey');
|
|
||||||
clearInterval(pollRef.current!);
|
|
||||||
pollRef.current = null;
|
|
||||||
try {
|
|
||||||
login.amber(pendingPubkey);
|
|
||||||
setAmberStep('success');
|
|
||||||
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
|
|
||||||
setTimeout(() => { onLogin(); onClose(); }, 600);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Amber] login.amber failed in poll:', err);
|
|
||||||
setErrors(prev => ({ ...prev, amber: 'Login failed. Please try again.' }));
|
|
||||||
setAmberStep('initial');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
// visibilitychange fallback — if browser keeps tab alive and user cancels
|
|
||||||
unsubAmberRef.current = subscribeToAmberReturn(() => {
|
|
||||||
// Only reset if callback page didn't already complete login
|
|
||||||
const stillPending = !localStorage.getItem('amber_pending_pubkey') === false;
|
|
||||||
if (stillPending) {
|
|
||||||
setAmberStep(prev => prev === 'waiting' ? 'initial' : prev);
|
|
||||||
}
|
|
||||||
unsubAmberRef.current = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Browser (tab) mode — use the callback URL flow
|
|
||||||
openAmberForLogin();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBunkerLogin = async () => {
|
const handleBunkerLogin = async () => {
|
||||||
|
|
@ -347,7 +274,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
Login with Amber
|
Login with Amber
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs text-gray-400'>
|
<p className='text-xs text-gray-400'>
|
||||||
Tap below — Amber opens, you approve once, and you're automatically logged in. No copy-paste needed.
|
Uses NIP-46 — your keys stay in Amber, we connect via relay. Tap below to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -356,7 +283,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
disabled={isLoading || isIOS}
|
disabled={isLoading || isIOS}
|
||||||
>
|
>
|
||||||
<Smartphone className="w-4 h-4 mr-2" />
|
<Smartphone className="w-4 h-4 mr-2" />
|
||||||
{isIOS ? 'Android only' : 'Open Amber'}
|
{isIOS ? 'Android only' : 'Connect Amber'}
|
||||||
</Button>
|
</Button>
|
||||||
<p className='text-xs text-gray-500'>
|
<p className='text-xs text-gray-500'>
|
||||||
Don't have Amber?{' '}
|
Don't have Amber?{' '}
|
||||||
|
|
@ -381,84 +308,89 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Waiting — user is in Amber */}
|
{/* Waiting — show bunker URI instructions */}
|
||||||
{amberStep === 'waiting' && (
|
{amberStep === 'waiting' && (
|
||||||
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
|
<div className='p-4 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">
|
<div className='text-center'>
|
||||||
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin' />
|
<p className='text-sm text-white font-bold mb-1'>Connect with Amber (NIP-46)</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm text-white font-bold mb-1'>Waiting for Amber...</p>
|
|
||||||
<p className='text-xs text-gray-400'>
|
<p className='text-xs text-gray-400'>
|
||||||
Approve in Amber, then come back here. Login completes automatically.
|
Get your Bunker URI from Amber and paste it below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center gap-1.5">
|
|
||||||
{[0, 1, 2].map(i => (
|
{/* Step by step */}
|
||||||
<span key={i} className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
|
<div className='space-y-2 text-xs text-gray-300'>
|
||||||
style={{ animationDelay: `${i * 0.15}s` }} />
|
<div className='flex gap-2'>
|
||||||
))}
|
<span className='text-[#ccff00] font-bold min-w-[18px]'>1.</span>
|
||||||
|
<span>Open <strong className='text-white'>Amber</strong> app</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<span className='text-[#ccff00] font-bold min-w-[18px]'>2.</span>
|
||||||
|
<span>Tap the key icon → <strong className='text-white'>Connect apps</strong> → <strong className='text-white'>Add app</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<span className='text-[#ccff00] font-bold min-w-[18px]'>3.</span>
|
||||||
|
<span>Copy the <strong className='text-white'>bunker://</strong> connection string</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<span className='text-[#ccff00] font-bold min-w-[18px]'>4.</span>
|
||||||
|
<span>Paste it below and tap <strong className='text-white'>Connect</strong></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual fallback — handles both PWA (clipboard) and browser (localStorage) */}
|
<Input
|
||||||
<p className="text-xs text-gray-500">Stuck? Tap below after approving in Amber:</p>
|
placeholder='bunker://...'
|
||||||
<Button
|
value={bunkerUri}
|
||||||
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"
|
onChange={e => {
|
||||||
onClick={async () => {
|
setBunkerUri(e.target.value);
|
||||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||||
|
|
||||||
// 1. Check localStorage first (browser tab / new-tab callback)
|
|
||||||
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
|
|
||||||
if (pendingPubkey) {
|
|
||||||
localStorage.removeItem('amber_pending_pubkey');
|
|
||||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
|
||||||
try {
|
|
||||||
login.amber(pendingPubkey);
|
|
||||||
setAmberStep('success');
|
|
||||||
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
|
|
||||||
setTimeout(() => { onLogin(); onClose(); }, 600);
|
|
||||||
} catch {
|
|
||||||
setErrors(prev => ({ ...prev, amber: 'Login failed. Please try again.' }));
|
|
||||||
setAmberStep('initial');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try clipboard (PWA mode — Amber copies pubkey when no callback URL)
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
const pubkey = text.trim().toLowerCase();
|
|
||||||
const pubkeyRegex = /^[0-9a-f]{64}$/;
|
|
||||||
if (pubkeyRegex.test(pubkey)) {
|
|
||||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
|
||||||
login.amber(pubkey);
|
|
||||||
setAmberStep('success');
|
|
||||||
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
|
|
||||||
setTimeout(() => { onLogin(); onClose(); }, 600);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch { /* clipboard permission denied */ }
|
|
||||||
|
|
||||||
setErrors(prev => ({ ...prev, amber: 'Not detected yet. Make sure you approved in Amber, then try again.' }));
|
|
||||||
}}
|
}}
|
||||||
>
|
className='bg-black/60 border-[#ccff00]/30 text-white placeholder:text-gray-600 text-xs'
|
||||||
I approved — complete login
|
autoComplete='off'
|
||||||
</Button>
|
autoCorrect='off'
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
{errors.amber && (
|
{errors.amber && (
|
||||||
<p className="text-xs text-red-400">{errors.amber}</p>
|
<p className="text-xs text-red-400">{errors.amber}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase text-sm h-auto'
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 h-auto py-1"
|
disabled={isLoading || !bunkerUri.trim().startsWith('bunker://')}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setAmberStep('initial');
|
if (!bunkerUri.trim().startsWith('bunker://')) {
|
||||||
|
setErrors(prev => ({ ...prev, amber: 'Must start with bunker://' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||||
if (unsubAmberRef.current) { unsubAmberRef.current(); unsubAmberRef.current = null; }
|
try {
|
||||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
await login.bunker(bunkerUri.trim());
|
||||||
|
setAmberStep('success');
|
||||||
|
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
||||||
|
setTimeout(() => { onLogin(); onClose(); }, 600);
|
||||||
|
} catch {
|
||||||
|
setErrors(prev => ({ ...prev, amber: 'Connection failed. Check your bunker URI and try again.' }));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel, try again
|
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : null}
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-xs text-gray-600 hover:text-gray-400 h-auto py-1"
|
||||||
|
onClick={() => {
|
||||||
|
setAmberStep('initial');
|
||||||
|
setBunkerUri('');
|
||||||
|
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue