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:
parent
4ed1d9a803
commit
90a6af810e
3 changed files with 150 additions and 73 deletions
|
|
@ -110,6 +110,10 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
|
|||
<SignupDialog
|
||||
isOpen={nsecSignupDialogOpen}
|
||||
onClose={() => setNsecSignupDialogOpen(false)}
|
||||
onUseAmber={() => {
|
||||
setNsecSignupDialogOpen(false);
|
||||
setLoginDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue