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
isOpen={nsecSignupDialogOpen}
onClose={() => setNsecSignupDialogOpen(false)}
onUseAmber={() => {
setNsecSignupDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
</div>
);

View file

@ -36,6 +36,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
// Amber NIP-55 state
const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'paste' | 'done' | 'error'>('idle');
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 { toast } = useToast();
@ -77,45 +80,34 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
}
// ── 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(/^#/, '') || '/';
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(() => {
checkAmberPendingPubkey();
checkAmberReturnSilently();
});
window.__amberUnsubRef = unsub;
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
// (happens when Amber opens the callback in a new browser tab on Android)
const checkAmberPendingPubkey = () => {
// Silent variant used when the app regains focus: complete login if Amber's
// callback delivered the pubkey, otherwise stay on the loading/waiting screen
// (do NOT force the paste step — the redirect may still be in flight).
const checkAmberReturnSilently = async () => {
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
localStorage.removeItem('amber_pending_pubkey');
completeAmberLogin(storedPubkey.toLowerCase());
return;
}
// Also try clipboard as fallback
tryClipboardForPubkey();
};
// Try reading the pubkey from clipboard (Amber may have copied it there)
const tryClipboardForPubkey = async () => {
// Best-effort clipboard read (some Amber builds copy the key). Never blocks.
try {
const text = (await navigator.clipboard.readText()).trim();
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')) {
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(text);
if (decoded.type === 'npub') {
completeAmberLogin(decoded.data as string);
return;
}
if (decoded.type === 'npub') completeAmberLogin(decoded.data as string);
}
} catch { /* clipboard denied — fall through to manual paste */ }
// Couldn't auto-detect — show the manual paste UI
setAmberStep('paste');
} catch { /* clipboard denied — remain on waiting screen */ }
};
// Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate
// tab). We keep polling on BOTH 'loading' and 'paste' so that even after we
// drop the user on the manual paste step, a late-arriving callback still
// auto-completes the login for them.
// Poll for amber_pending_pubkey (written by AmberCallbackPage). This is what
// makes the proper NIP-55 redirect flow complete automatically — no paste.
// We keep polling on BOTH 'loading' and 'paste' so a late-arriving callback
// still auto-completes even if the user opened the manual escape hatch.
useEffect(() => {
if (amberStep !== 'loading' && amberStep !== 'paste') return;
const interval = setInterval(() => {
@ -202,6 +189,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
window.__amberUnsubRef = undefined;
setAmberStep('idle');
setAmberError('');
setShowManualPaste(false);
setPubkeyInput('');
setPubkeyError('');
setBunkerUri('');
@ -305,34 +293,76 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
</div>
)}
{/* Idle / loading — open Amber button */}
{(amberStep === 'idle' || amberStep === 'loading') && (
{/* Idle — the primary, one-tap NIP-55 flow */}
{amberStep === 'idle' && (
<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">
{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>
<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>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
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>
<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>
)}
{/* Paste pubkey — after returning from Amber */}
{(amberStep === 'paste' || amberStep === 'error') && (
{/* Waiting — the redirect flow is in progress. Auto-completes via
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='text-center space-y-1'>
<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'>
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>
</div>
{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
</Button>
<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
</Button>
</div>

View file

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef } from 'react';
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 { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@ -20,13 +20,17 @@ interface SignupDialogProps {
isOpen: boolean;
onClose: () => 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) => {
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 [nsec, setNsec] = useState('');
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
@ -249,7 +253,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
</DialogTitle>
</DialogHeader>
<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' && (
<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'>
@ -262,37 +266,76 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
Create your free account
</h3>
<p className='text-sm text-gray-300'>
No email, no phone, no password. We'll generate a private key that lives
<strong className='text-white'> only on your device</strong> — that key <em>is</em> your account.
No email, no phone, no password. Your account is a Nostr key — choose
where it lives.
</p>
</div>
</div>
<div className='space-y-2 text-left'>
{[
['🔑', 'A secure key is created just for you'],
['📱', 'It stays on this device — nobody else can see it'],
['💾', "You'll get a chance to back it up right after"],
].map(([icon, label]) => (
<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>
{/* RECOMMENDED: Amber keeps the key in a dedicated signer app.
Amber is Android-only, so only surface it there. */}
{onUseAmber && isAndroid && (
<div className='rounded-2xl border-2 border-[#ccff00]/50 bg-black/40 p-4 text-left space-y-3'>
<div className='flex items-center gap-2'>
<span className='rounded-full bg-[#ccff00] px-2 py-0.5 text-[10px] font-black uppercase text-black'>Recommended</span>
</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>
<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'>
By continuing you're generating a Nostr keypair. Advanced users can
{' '}
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>use a signer app</Link>
{' '}instead.
Prefer another signer?{' '}
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>See all options</Link>
</p>
</div>
)}