SkateSpots/src/components/auth/SignupDialog.tsx
shakespeare.diy 4ed1d9a803 Fix crew card layout, Amber web login hang, Stash encryption gate, and add signup welcome step
- CrewCard: rebuilt to fit any screen width — clip overflow, truncate name/location, single wrapping badge row, consistent avatar sizing; responsive header
- Amber web login: after opening Amber, drop user on manual paste step after 2.5s so the spinner never hangs when the callback opens in a new tab or clipboard is blocked; keep polling on the paste step so a late callback still auto-completes
- Stash: hasNip44/hasNip04 now fall back to the active login when pubkey matching fails, so logged-in nsec/amber users are no longer wrongly told "Encryption Required"
- Signup: added an explicit welcome step that explains account creation and only generates the keypair when the user taps "Create my account" (no more silent auto-creation on dialog open)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-27 13:27:24 +07:00

550 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.
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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent } from '@/components/ui/card';
import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
interface SignupDialogProps {
isOpen: boolean;
onClose: () => void;
onComplete?: () => void;
}
const sanitizeFilename = (filename: string) => {
return filename.replace(/[^a-z0-9_.-]/gi, '_');
}
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete }) => {
const [step, setStep] = useState<'welcome' | 'profile' | 'done'>('welcome');
const [nsec, setNsec] = useState('');
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
const [showNsec, setShowNsec] = useState(false);
const [profileData, setProfileData] = useState({
name: '',
about: '',
picture: ''
});
const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const avatarFileInputRef = useRef<HTMLInputElement>(null);
// Silently generate a key and log the user in immediately.
// No forced download, no crypto vocabulary — the account "just works".
// Backup is deferred to the BackupReminder (shown a couple seconds after login).
const createAccountSilently = () => {
try {
const sk = generateSecretKey();
const newNsec = nip19.nsecEncode(sk);
setNsec(newNsec);
login.nsec(newNsec);
return true;
} catch {
toast({
title: 'Something went wrong',
description: "Couldn't create your account. Please try again.",
variant: 'destructive',
});
return false;
}
};
const downloadKey = () => {
try {
// Create a blob with the key text
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
const url = globalThis.URL.createObjectURL(blob);
// Sanitize filename
const filename = sanitizeFilename('nostr-nsec-key.txt');
// Create a temporary link element and trigger download
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// Clean up immediately
globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Mark as secured
setKeySecured('downloaded');
toast({
title: 'Secret Key Saved!',
description: 'Your key has been safely stored.',
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset file input
e.target.value = '';
// Validate file type
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file for your avatar.',
variant: 'destructive',
});
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Avatar image must be smaller than 5MB.',
variant: 'destructive',
});
return;
}
try {
const tags = await uploadFile(file);
// Get the URL from the first tag
const url = tags[0]?.[1];
if (url) {
setProfileData(prev => ({ ...prev, picture: url }));
toast({
title: 'Avatar uploaded!',
description: 'Your avatar has been uploaded successfully.',
});
}
} catch {
toast({
title: 'Upload failed',
description: 'Failed to upload avatar. Please try again.',
variant: 'destructive',
});
}
};
const finishSignup = async (skipProfile = false) => {
// Mark signup completion time for fallback welcome modal
localStorage.setItem('signup_completed', Date.now().toString());
try {
// Publish profile if user provided information
if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) {
const metadata: Record<string, string> = {};
if (profileData.name) metadata.name = profileData.name;
if (profileData.about) metadata.about = profileData.about;
if (profileData.picture) metadata.picture = profileData.picture;
await publishEvent({
kind: 0,
content: JSON.stringify(metadata),
});
toast({
title: 'Profile Created!',
description: 'Your profile has been set up.',
});
}
// Show the done step with signer recommendation
setStep('done');
} catch {
toast({
title: 'Profile Setup Failed',
description: 'Your account was created but profile setup failed. You can update it later.',
variant: 'destructive',
});
// Still proceed to completion even if profile failed
setStep('done');
}
};
const handleFinishComplete = () => {
onClose();
if (onComplete) {
setTimeout(() => {
onComplete();
}, 600);
} else {
toast({
title: 'Welcome!',
description: 'Your account is ready.',
});
}
};
const getTitle = () => {
if (step === 'welcome') return (
<span className="flex items-center justify-center gap-2">
Join SkateSpots
</span>
);
if (step === 'profile') return (
<span className="flex items-center justify-center gap-2">
Set Up Your Profile
</span>
);
return (
<span className="flex items-center justify-center gap-2">
Welcome!
</span>
);
};
// Reset state when dialog opens. We now start on an explicit WELCOME step and
// only create the keypair when the skater taps "Create my account" — this
// gives signup a real, understandable flow instead of silently minting an
// account behind their back the instant the dialog opens.
useEffect(() => {
if (isOpen) {
setStep('welcome');
setNsec('');
setKeySecured('none');
setShowNsec(false);
setProfileData({ name: '', about: '', picture: '' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Create the account (generate key + log in), then advance to profile setup.
const handleCreateAccount = () => {
if (createAccountSilently()) {
setStep('profile');
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl flex flex-col")}
>
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1 relative flex-shrink-0')}>
<DialogTitle className={cn('font-semibold text-center text-base sm:text-lg')}>
{getTitle()}
</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 */}
{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'>
<div className='absolute inset-0 bg-[#ccff00]/5 blur-2xl' />
<div className='relative z-10 space-y-3'>
<div className='mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ccff00]/15 text-4xl'>
🛹
</div>
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
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.
</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>
</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'>
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.
</p>
</div>
)}
{/* Profile Step - Optional profile setup */}
{step === 'profile' && (
<div className='text-center space-y-4'>
{/* Profile setup illustration */}
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 overflow-hidden'>
{/* Sparkles */}
<div className='absolute inset-0 pointer-events-none'>
<Sparkles className='absolute top-3 left-4 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '0s'}} />
<Sparkles className='absolute top-6 right-6 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
<Sparkles className='absolute bottom-4 left-6 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '1s'}} />
</div>
<div className='relative z-10 flex justify-center items-center mb-3'>
<div className='relative'>
<div className='w-16 h-16 bg-gradient-to-br from-blue-200 to-indigo-300 rounded-full flex items-center justify-center shadow-lg'>
<User className='w-8 h-8 text-blue-800' />
</div>
<div className='absolute -top-1 -right-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center animate-bounce'>
<Sparkles className='w-3 h-3 text-white' />
</div>
</div>
</div>
<div className='relative z-10 space-y-2'>
<p className='text-base font-semibold'>
You're in! 🛹 Add a name so skaters know who you are
</p>
<p className='text-sm text-muted-foreground'>
This is optional — you can skip it and fill it in later.
</p>
</div>
</div>
{/* Publishing status indicator */}
{isPublishing && (
<div className='relative p-4 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800'>
<div className='flex items-center justify-center gap-3'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
<span className='text-sm font-medium text-blue-700 dark:text-blue-300'>
Publishing your profile...
</span>
</div>
</div>
)}
{/* Profile form */}
<div className={`space-y-4 text-left ${isPublishing ? 'opacity-50 pointer-events-none' : ''}`}>
<div className='space-y-2'>
<label htmlFor='profile-name' className='text-sm font-medium'>
Display Name
</label>
<Input
id='profile-name'
value={profileData.name}
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
placeholder='Your name'
className='rounded-lg'
disabled={isPublishing}
/>
</div>
<div className='space-y-2'>
<label htmlFor='profile-about' className='text-sm font-medium'>
Bio
</label>
<Textarea
id='profile-about'
value={profileData.about}
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
placeholder='Tell others about yourself...'
className='rounded-lg resize-none'
rows={3}
disabled={isPublishing}
/>
</div>
<div className='space-y-2'>
<label htmlFor='profile-picture' className='text-sm font-medium'>
Avatar
</label>
<div className='flex gap-2'>
<Input
id='profile-picture'
value={profileData.picture}
onChange={(e) => setProfileData(prev => ({ ...prev, picture: e.target.value }))}
placeholder='https://example.com/your-avatar.jpg'
className='rounded-lg flex-1'
disabled={isPublishing}
/>
<input
type='file'
accept='image/*'
className='hidden'
ref={avatarFileInputRef}
onChange={handleAvatarUpload}
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={() => avatarFileInputRef.current?.click()}
disabled={isUploading || isPublishing}
className='rounded-lg shrink-0'
title='Upload avatar image'
>
{isUploading ? (
<div className='w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin' />
) : (
<Upload className='w-4 h-4' />
)}
</Button>
</div>
</div>
</div>
{/* Action buttons */}
<div className='space-y-3'>
<Button
className='w-full rounded-full py-4 text-base font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 transform transition-all duration-200 hover:scale-105 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none'
onClick={() => finishSignup(false)}
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
Saving...
</>
) : (
<>
<User className='w-4 h-4 mr-2' />
Save & Start Skating
</>
)}
</Button>
<Button
variant='outline'
className='w-full rounded-full py-3 disabled:opacity-50 disabled:cursor-not-allowed'
onClick={() => finishSignup(true)}
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
Setting up account...
</>
) : (
'Skip for now'
)}
</Button>
</div>
</div>
)}
{/* Done Step - Account created, gentle optional backup */}
{step === 'done' && (
<div className='py-4 space-y-4'>
<Card className='bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/10 border-2 border-[#ccff00]/50'>
<CardContent className='p-6 text-center space-y-3'>
<div className='text-5xl mb-2'>🛹</div>
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
You're all set!
</h3>
<p className='text-gray-300 text-sm'>
Your account is ready. Go find some spots.
</p>
<CheckCircle className='h-10 w-10 mx-auto text-[#00ff00]' />
</CardContent>
</Card>
{/* Optional backup — no pressure, no jargon */}
<Card className='bg-amber-950/20 border-2 border-amber-500/30'>
<CardContent className='p-5 space-y-3'>
<div className='flex items-center gap-3'>
<div className='p-2 rounded-lg bg-amber-500/20'>
<Shield className='h-5 w-5 text-amber-400' />
</div>
<div>
<h4 className='font-bold text-amber-400'>Save a backup (optional)</h4>
<p className='text-xs text-gray-400'>So you never lose access to your account</p>
</div>
</div>
<p className='text-sm text-gray-300'>
Your account lives on your device. Save a recovery code somewhere safe and
you'll be able to log back in on any device. You can also do this later from Settings.
</p>
{!showNsec ? (
<Button
variant='outline'
className='w-full border-amber-500/50 text-amber-400 hover:bg-amber-500/10'
onClick={() => setShowNsec(true)}
>
<Key className='h-4 w-4 mr-2' />
Show my recovery code
</Button>
) : (
<div className='space-y-2'>
<div className='p-2 bg-black rounded border border-amber-500/20 font-mono text-xs break-all text-white'>
{nsec}
</div>
<div className='grid grid-cols-2 gap-2'>
<Button
variant='outline'
size='sm'
className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10'
onClick={() => {
navigator.clipboard.writeText(nsec);
toast({ title: 'Copied!', description: 'Recovery code copied. Keep it somewhere safe.' });
}}
>
Copy
</Button>
<Button
variant='outline'
size='sm'
className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10'
onClick={downloadKey}
>
<Download className='h-3.5 w-3.5 mr-1.5' />
{keySecured === 'downloaded' ? 'Saved ✓' : 'Download'}
</Button>
</div>
<p className='text-xs text-amber-300/80'>⚠️ Anyone with this code can access your account. Never share it.</p>
</div>
)}
<Button
className='w-full rounded-full py-3 font-bold uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
onClick={handleFinishComplete}
>
<LogIn className='h-4 w-4 mr-2' />
Start Skating
</Button>
<Link to='/signers' onClick={handleFinishComplete} className='block'>
<button className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'>
Advanced: use a signer app instead
</button>
</Link>
</CardContent>
</Card>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default SignupDialog;