Make Amber login/signup the polished, no-copy-paste primary flow (NIP-55)

Login (Amber tab):
- Emphasize the proper NIP-55 redirect flow: one tap → approve in Amber →
  redirected back to /amber-callback → poll auto-completes login. No copy/paste.
- Replace the indefinite spinner with a clear "Waiting for Amber…" state that
  auto-signs-in on return, with an "Open Amber again" retry.
- Demote manual pubkey entry to an explicit "Having trouble?" escape hatch that
  the user must tap — it never hijacks the flow anymore.
- Removed the aggressive 2.5s auto-switch to paste; keep background polling on
  both waiting and paste states so a late callback still completes.

Signup:
- Welcome step now recommends "Sign up with Amber" first on Android (key lives
  inside Amber, SkateSpots never touches it), with a clearly-secondary "quick
  account on this device" option. Wired onUseAmber through LoginArea to open the
  Amber login flow.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 13:35:13 +07:00
parent 4ed1d9a803
commit 90a6af810e
3 changed files with 150 additions and 73 deletions

View file

@ -110,6 +110,10 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
<SignupDialog <SignupDialog
isOpen={nsecSignupDialogOpen} isOpen={nsecSignupDialogOpen}
onClose={() => setNsecSignupDialogOpen(false)} onClose={() => setNsecSignupDialogOpen(false)}
onUseAmber={() => {
setNsecSignupDialogOpen(false);
setLoginDialogOpen(true);
}}
/> />
</div> </div>
); );

View file

@ -36,6 +36,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
// Amber NIP-55 state // Amber NIP-55 state
const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'paste' | 'done' | 'error'>('idle'); const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'paste' | 'done' | 'error'>('idle');
const [amberError, setAmberError] = useState(''); const [amberError, setAmberError] = useState('');
// Whether the user explicitly opened the manual-paste escape hatch. The proper
// NIP-55 redirect flow never needs this; it's only a "having trouble?" fallback.
const [showManualPaste, setShowManualPaste] = useState(false);
const login = useLoginActions(); const login = useLoginActions();
const { toast } = useToast(); const { toast } = useToast();
@ -77,45 +80,34 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
} }
// ── Web/PWA path ───────────────────────────────────────────────────────── // ── Web/PWA path ─────────────────────────────────────────────────────────
// Store return path before navigating away // This is the proper NIP-55 web flow — NO copy/paste required:
// 1. We open Amber via an intent:// URL carrying a callbackUrl.
// 2. Amber signs the get_public_key request and redirects the browser to
// /amber-callback?event=<pubkey>.
// 3. AmberCallbackPage stores the pubkey; the poll below (or the
// visibilitychange handler) picks it up and completes login.amber().
// The manual-paste step exists ONLY as a last-resort "having trouble?"
// escape hatch the user must explicitly open — it is never forced.
const currentPath = window.location.hash.replace(/^#/, '') || '/'; const currentPath = window.location.hash.replace(/^#/, '') || '/';
localStorage.setItem('amber_return_path', currentPath); localStorage.setItem('amber_return_path', currentPath);
// Listen for app regaining focus (after Amber redirect). If Amber's
// redirect succeeded, checkAmberPendingPubkey completes the login; if not,
// it drops the user on the paste step.
const unsub = subscribeToAmberReturn(() => { const unsub = subscribeToAmberReturn(() => {
checkAmberPendingPubkey(); checkAmberReturnSilently();
}); });
window.__amberUnsubRef = unsub; window.__amberUnsubRef = unsub;
openAmberForLogin(); openAmberForLogin();
// CRITICAL web-flow fallback: on mobile browsers Amber frequently opens the
// callback in a NEW tab (so the original tab never fires visibilitychange)
// and/or the clipboard read is blocked by permissions. In those cases the
// spinner would hang until the 3-minute timeout — exactly the "stuck
// loading" bug. So after a short grace period we surface the manual paste
// step, which ALWAYS works (user taps "Copy public key" in Amber, pastes
// here). The background poll still auto-completes if the callback lands.
window.setTimeout(() => {
setAmberStep((step) => (step === 'loading' ? 'paste' : step));
}, 2500);
}; };
// Check if AmberCallbackPage already wrote a pubkey to localStorage // Silent variant used when the app regains focus: complete login if Amber's
// (happens when Amber opens the callback in a new browser tab on Android) // callback delivered the pubkey, otherwise stay on the loading/waiting screen
const checkAmberPendingPubkey = () => { // (do NOT force the paste step — the redirect may still be in flight).
const checkAmberReturnSilently = async () => {
const storedPubkey = localStorage.getItem('amber_pending_pubkey'); const storedPubkey = localStorage.getItem('amber_pending_pubkey');
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) { if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
localStorage.removeItem('amber_pending_pubkey'); localStorage.removeItem('amber_pending_pubkey');
completeAmberLogin(storedPubkey.toLowerCase()); completeAmberLogin(storedPubkey.toLowerCase());
return; return;
} }
// Also try clipboard as fallback // Best-effort clipboard read (some Amber builds copy the key). Never blocks.
tryClipboardForPubkey();
};
// Try reading the pubkey from clipboard (Amber may have copied it there)
const tryClipboardForPubkey = async () => {
try { try {
const text = (await navigator.clipboard.readText()).trim(); const text = (await navigator.clipboard.readText()).trim();
if (/^[0-9a-f]{64}$/i.test(text)) { if (/^[0-9a-f]{64}$/i.test(text)) {
@ -125,20 +117,15 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
if (text.startsWith('npub1')) { if (text.startsWith('npub1')) {
const { nip19 } = await import('nostr-tools'); const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(text); const decoded = nip19.decode(text);
if (decoded.type === 'npub') { if (decoded.type === 'npub') completeAmberLogin(decoded.data as string);
completeAmberLogin(decoded.data as string);
return;
}
} }
} catch { /* clipboard denied — fall through to manual paste */ } } catch { /* clipboard denied — remain on waiting screen */ }
// Couldn't auto-detect — show the manual paste UI
setAmberStep('paste');
}; };
// Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate // Poll for amber_pending_pubkey (written by AmberCallbackPage). This is what
// tab). We keep polling on BOTH 'loading' and 'paste' so that even after we // makes the proper NIP-55 redirect flow complete automatically — no paste.
// drop the user on the manual paste step, a late-arriving callback still // We keep polling on BOTH 'loading' and 'paste' so a late-arriving callback
// auto-completes the login for them. // still auto-completes even if the user opened the manual escape hatch.
useEffect(() => { useEffect(() => {
if (amberStep !== 'loading' && amberStep !== 'paste') return; if (amberStep !== 'loading' && amberStep !== 'paste') return;
const interval = setInterval(() => { const interval = setInterval(() => {
@ -202,6 +189,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
window.__amberUnsubRef = undefined; window.__amberUnsubRef = undefined;
setAmberStep('idle'); setAmberStep('idle');
setAmberError(''); setAmberError('');
setShowManualPaste(false);
setPubkeyInput(''); setPubkeyInput('');
setPubkeyError(''); setPubkeyError('');
setBunkerUri(''); setBunkerUri('');
@ -305,34 +293,76 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
</div> </div>
)} )}
{/* Idle / loading — open Amber button */} {/* Idle — the primary, one-tap NIP-55 flow */}
{(amberStep === 'idle' || amberStep === 'loading') && ( {amberStep === 'idle' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'> <div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto"> <div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
{amberStep === 'loading' ? <Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' /> : <Smartphone className='w-7 h-7 text-[#ccff00]' />} <Smartphone className='w-7 h-7 text-[#ccff00]' />
</div> </div>
<div> <div>
<p className='text-sm text-white font-bold'>Login with Amber</p> <p className='text-sm text-white font-bold'>Login with Amber</p>
<p className='text-xs text-gray-400 mt-1'>Opens Amber → approve → come back here.</p> <p className='text-xs text-gray-400 mt-1'>
Your key stays inside Amber. One tap — approve in Amber and you're
back here, signed in. Nothing to copy.
</p>
</div> </div>
<Button <Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase' className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
onClick={handleAmberLogin} onClick={handleAmberLogin}
disabled={amberStep === 'loading'}
> >
<Smartphone className="w-4 h-4 mr-2" />Open Amber <Smartphone className="w-4 h-4 mr-2" />Continue with Amber
</Button> </Button>
<a
href="https://github.com/greenart7c3/Amber"
target="_blank"
rel="noopener noreferrer"
className="block text-[11px] text-gray-600 hover:text-[#ccff00]"
>
Don't have Amber? Get it →
</a>
</div> </div>
)} )}
{/* Paste pubkey — after returning from Amber */} {/* Waiting — the redirect flow is in progress. Auto-completes via
{(amberStep === 'paste' || amberStep === 'error') && ( the poll. Only reveals the manual escape hatch on explicit tap. */}
{amberStep === 'loading' && !showManualPaste && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' />
</div>
<div>
<p className='text-sm text-white font-bold'>Waiting for Amber…</p>
<p className='text-xs text-gray-400 mt-1'>
Approve the request in Amber. You'll be signed in automatically
the moment you come back.
</p>
</div>
<Button
variant='outline'
className='w-full rounded-full py-2.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 font-bold'
onClick={handleAmberLogin}
>
<Smartphone className="w-4 h-4 mr-2" />Open Amber again
</Button>
<button
className='w-full text-[11px] text-gray-600 hover:text-gray-400 py-1'
onClick={() => setShowManualPaste(true)}
>
Having trouble? Enter your key manually
</button>
</div>
)}
{/* Manual paste — LAST-RESORT escape hatch only. Reached when the
user taps "Having trouble?" or an explicit error occurs. */}
{(amberStep === 'paste' || amberStep === 'error' || (amberStep === 'loading' && showManualPaste)) && (
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'> <div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className='text-center space-y-1'> <div className='text-center space-y-1'>
<Smartphone className='w-8 h-8 mx-auto text-[#ccff00]' /> <Smartphone className='w-8 h-8 mx-auto text-[#ccff00]' />
<p className='text-sm text-white font-bold'>Paste your pubkey</p> <p className='text-sm text-white font-bold'>Enter your public key</p>
<p className='text-xs text-gray-400'> <p className='text-xs text-gray-400'>
In Amber: tap your profile → <strong className='text-white'>Copy public key</strong>. Then paste below. Only if the automatic flow didn't work. In Amber: tap your profile →
{' '}<strong className='text-white'>Copy public key</strong>, then paste it here.
</p> </p>
</div> </div>
{amberStep === 'error' && amberError && ( {amberStep === 'error' && amberError && (
@ -356,7 +386,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<Check className='w-4 h-4 mr-2' />Connect <Check className='w-4 h-4 mr-2' />Connect
</Button> </Button>
<Button variant='ghost' className='w-full text-xs text-gray-600 h-auto py-1' <Button variant='ghost' className='w-full text-xs text-gray-600 h-auto py-1'
onClick={() => { setAmberStep('idle'); setPubkeyInput(''); setPubkeyError(''); setAmberError(''); }}> onClick={() => { setAmberStep('idle'); setShowManualPaste(false); setPubkeyInput(''); setPubkeyError(''); setAmberError(''); }}>
← Back ← Back
</Button> </Button>
</div> </div>

View file

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Download, Key, Shield, User, Sparkles, LogIn, CheckCircle, Upload } from 'lucide-react'; import { Download, Key, Shield, User, Sparkles, LogIn, CheckCircle, Upload, Smartphone } 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';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@ -20,13 +20,17 @@ interface SignupDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onComplete?: () => void; onComplete?: () => void;
/** Switch the user over to the Amber (NIP-55) login flow — the recommended,
* more-secure path where the private key lives inside the Amber app. */
onUseAmber?: () => void;
} }
const sanitizeFilename = (filename: string) => { const sanitizeFilename = (filename: string) => {
return filename.replace(/[^a-z0-9_.-]/gi, '_'); return filename.replace(/[^a-z0-9_.-]/gi, '_');
} }
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete }) => { const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete, onUseAmber }) => {
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
const [step, setStep] = useState<'welcome' | 'profile' | 'done'>('welcome'); const [step, setStep] = useState<'welcome' | 'profile' | 'done'>('welcome');
const [nsec, setNsec] = useState(''); const [nsec, setNsec] = useState('');
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none'); const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
@ -249,7 +253,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'> <div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Welcome Step — explain what's about to happen, then create on tap */} {/* Welcome Step — recommend Amber first, then offer the quick local key */}
{step === 'welcome' && ( {step === 'welcome' && (
<div className='space-y-5 text-center'> <div className='space-y-5 text-center'>
<div className='relative overflow-hidden rounded-2xl border-2 border-[#ccff00]/40 bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 p-6'> <div className='relative overflow-hidden rounded-2xl border-2 border-[#ccff00]/40 bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 p-6'>
@ -262,37 +266,76 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
Create your free account Create your free account
</h3> </h3>
<p className='text-sm text-gray-300'> <p className='text-sm text-gray-300'>
No email, no phone, no password. We'll generate a private key that lives No email, no phone, no password. Your account is a Nostr key — choose
<strong className='text-white'> only on your device</strong> — that key <em>is</em> your account. where it lives.
</p> </p>
</div> </div>
</div> </div>
<div className='space-y-2 text-left'> {/* RECOMMENDED: Amber keeps the key in a dedicated signer app.
{[ Amber is Android-only, so only surface it there. */}
['🔑', 'A secure key is created just for you'], {onUseAmber && isAndroid && (
['📱', 'It stays on this device — nobody else can see it'], <div className='rounded-2xl border-2 border-[#ccff00]/50 bg-black/40 p-4 text-left space-y-3'>
['💾', "You'll get a chance to back it up right after"], <div className='flex items-center gap-2'>
].map(([icon, label]) => ( <span className='rounded-full bg-[#ccff00] px-2 py-0.5 text-[10px] font-black uppercase text-black'>Recommended</span>
<div key={label} className='flex items-center gap-3 rounded-lg border border-white/10 bg-black/40 px-3 py-2.5'>
<span className='text-lg'>{icon}</span>
<span className='text-sm text-gray-300'>{label}</span>
</div> </div>
))} <div className='flex items-start gap-3'>
<div className='mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#ccff00]/15'>
<Smartphone className='h-5 w-5 text-[#ccff00]' />
</div>
<div>
<p className='font-bold text-white'>Sign up with Amber</p>
<p className='text-xs text-gray-400'>
Your key is created and stored inside the Amber app — SkateSpots
never touches it. Most secure. One tap to approve.
</p>
</div>
</div>
<Button
className='w-full rounded-full py-3 font-black uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
onClick={() => { onClose(); onUseAmber(); }}
>
<Smartphone className='mr-2 h-4 w-4' />
Continue with Amber
</Button>
</div>
)}
{onUseAmber && isAndroid && (
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-xs text-gray-500'>or the quick way</span>
<div className='flex-1 border-t border-gray-800' />
</div>
)}
{/* QUICK: local key stored on this device */}
<div className='rounded-2xl border border-white/10 bg-black/40 p-4 text-left space-y-3'>
<div className='flex items-start gap-3'>
<div className='mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/5'>
<Key className='h-5 w-5 text-gray-300' />
</div>
<div>
<p className='font-bold text-white'>Quick account on this device</p>
<p className='text-xs text-gray-400'>
We generate a key that lives on this device. Fastest start — we'll
help you save a backup right after.
</p>
</div>
</div>
<Button
variant='outline'
className='w-full rounded-full py-3 font-bold border-white/20 text-white hover:bg-white/5'
onClick={handleCreateAccount}
>
<Sparkles className='mr-2 h-4 w-4' />
Create quick account
</Button>
</div> </div>
<Button
className='w-full rounded-full py-4 text-base font-black uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
onClick={handleCreateAccount}
>
<Sparkles className='mr-2 h-4 w-4' />
Create my account
</Button>
<p className='text-xs text-gray-500'> <p className='text-xs text-gray-500'>
By continuing you're generating a Nostr keypair. Advanced users can Prefer another signer?{' '}
{' '} <Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>See all options</Link>
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>use a signer app</Link>
{' '}instead.
</p> </p>
</div> </div>
)} )}