diff --git a/src/components/auth/AuthDialog.tsx b/src/components/auth/AuthDialog.tsx deleted file mode 100644 index 81f1abd..0000000 --- a/src/components/auth/AuthDialog.tsx +++ /dev/null @@ -1,918 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { - Download, - Upload, - Eye, - EyeOff, - Key, - ChevronDown, - ChevronUp, - Loader2, - ExternalLink, -} 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 { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { QRCodeCanvas } from '@/components/ui/qrcode'; -import { toast } from '@/hooks/useToast'; -import { - useLoginActions, - generateNostrConnectParams, - generateNostrConnectURI, - type NostrConnectParams, - type NostrConnectStatus, -} from '@/hooks/useLoginActions'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useUploadFile } from '@/hooks/useUploadFile'; -import { useIsMobile } from '@/hooks/useIsMobile'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; - -interface AuthDialogProps { - isOpen: boolean; - onClose: () => void; -} - -type Step = 'welcome' | 'generate' | 'secure' | 'profile' | 'login' | 'connect'; - -const validateNsec = (nsec: string) => /^nsec1[a-zA-Z0-9]{58}$/.test(nsec); -const validateBunkerUri = (uri: string) => uri.startsWith('bunker://'); - -const connectStatusLabel = (status: NostrConnectStatus | null): string => { - switch (status) { - case 'awaiting-connect': - return 'Waiting for signer connection…'; - case 'getting-public-key': - return 'Getting public key…'; - default: - return ''; - } -}; - -/** Check if running on an actual mobile device (not just a small screen). */ -function isMobileDevice(): boolean { - if (typeof navigator === 'undefined') return false; - return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); -} - -/** Download an nsec to a text file — same behavior as the previous SignupDialog. */ -function downloadNsecFile(nsec: string) { - const decoded = nip19.decode(nsec); - if (decoded.type !== 'nsec') throw new Error('Invalid nsec key'); - const pubkey = getPublicKey(decoded.data); - const npub = nip19.npubEncode(pubkey); - const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`; - - const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' }); - const url = globalThis.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.style.display = 'none'; - document.body.appendChild(a); - a.click(); - globalThis.URL.revokeObjectURL(url); - document.body.removeChild(a); -} - -const AuthDialog: React.FC = ({ isOpen, onClose }) => { - const [step, setStep] = useState('welcome'); - - // Signup state - const [nsec, setNsec] = useState(''); - const [showKey, setShowKey] = useState(false); - const [isGenerating, setIsGenerating] = useState(false); - const [profileData, setProfileData] = useState({ name: '', about: '', picture: '' }); - - // Login state - const [loginNsec, setLoginNsec] = useState(''); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [loginError, setLoginError] = useState(''); - const [showMoreOptions, setShowMoreOptions] = useState(false); - - // Nostrconnect / bunker state - const [nostrConnectParams, setNostrConnectParams] = useState(null); - const [nostrConnectUri, setNostrConnectUri] = useState(''); - const [connectError, setConnectError] = useState(null); - // Progress status for the nostrconnect handshake. `null` means the user - // hasn't kicked off the handshake yet (or they canceled) — we show the QR - // / "Open signer app" button. Once the handshake advances we swap in a - // spinner with a live status line so the user knows something is working. - const [connectStatus, setConnectStatus] = useState(null); - // Tracks whether the user has explicitly initiated the handshake from the - // mobile UI. The listen subscription itself starts the moment params are - // generated — without this flag we'd flip into the progress view as soon - // as the user enters the Remote Signer step, before they've done anything. - // Desktop doesn't need this: it stays on the QR until the handshake - // advances past `awaiting-connect`. - const [hasOpenedSigner, setHasOpenedSigner] = useState(false); - const [showBunkerInput, setShowBunkerInput] = useState(false); - const [bunkerUri, setBunkerUri] = useState(''); - - const login = useLoginActions(); - // Stable refs so the nostrconnect listening effect below doesn't restart on - // every parent render. Parents typically pass inline arrow functions for - // onClose, and useLoginActions returns a fresh object each render — without - // stable refs, an effect depending on them would tear down the in-flight - // subscription on every render and cause approved logins to be swallowed. - const loginRef = useRef(login); - const onCloseRef = useRef(onClose); - useEffect(() => { - loginRef.current = login; - }, [login]); - useEffect(() => { - onCloseRef.current = onClose; - }, [onClose]); - - const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); - const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); - const { user: currentUser } = useCurrentUser(); - const avatarFileInputRef = useRef(null); - const fileInputRef = useRef(null); - const abortControllerRef = useRef(null); - const isMobile = useIsMobile(); - - const hasExtension = typeof window !== 'undefined' && 'nostr' in window; - - // Reset state when the dialog closes. - // This is the "reset state when a prop changes" pattern; the usual - // React-preferred alternative is a `key` prop on the caller, but the - // public API of this component is a simple open/close boolean, so we - // reset here. The multiple setState calls are intentional. - useEffect(() => { - if (!isOpen) { - /* eslint-disable react-hooks/set-state-in-effect */ - setStep('welcome'); - setNsec(''); - setLoginNsec(''); - setShowKey(false); - setIsGenerating(false); - setIsLoggingIn(false); - setLoginError(''); - setShowMoreOptions(false); - setProfileData({ name: '', about: '', picture: '' }); - setNostrConnectParams(null); - setNostrConnectUri(''); - setConnectError(null); - setConnectStatus(null); - setHasOpenedSigner(false); - setShowBunkerInput(false); - setBunkerUri(''); - /* eslint-enable react-hooks/set-state-in-effect */ - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - } - }, [isOpen]); - - // Generate a nostrconnect session (QR code data). - const generateConnectSession = useCallback(() => { - const relayUrls = login.getRelayUrls(); - const params = generateNostrConnectParams(relayUrls); - const uri = generateNostrConnectURI(params, { - callback: isMobileDevice() ? `${window.location.origin}/remoteloginsuccess` : undefined, - }); - setNostrConnectParams(params); - setNostrConnectUri(uri); - setConnectError(null); - }, [login]); - - // Start listening for a nostrconnect response once params are set. - // - // Deps are intentionally limited to `nostrConnectParams` so that parent - // re-renders (which produce fresh onClose closures and a fresh `login` - // object from useLoginActions) do NOT tear down an in-flight - // subscription. An earlier version used a `cancelled` flag flipped by - // the effect's cleanup, which caused a successful nostrconnect response - // to be silently swallowed after the signer approved — the subscription - // was re-created mid-handshake and the first instance's success branch - // saw `cancelled === true`. - // - // Cancellation is handled explicitly by the `isOpen` effect (on dialog - // close) and by handleConnectRetry() (on user cancel/retry). - useEffect(() => { - if (!nostrConnectParams) return; - - const startListening = async () => { - const controller = new AbortController(); - abortControllerRef.current = controller; - - try { - await loginRef.current.nostrconnect( - nostrConnectParams, - controller.signal, - (status) => { - if (controller.signal.aborted) return; - setConnectStatus(status); - }, - ); - // If the dialog was explicitly closed (handled by the isOpen - // effect, which aborts the controller), don't try to re-close it. - // Otherwise the user is logged in — close the dialog. - if (controller.signal.aborted) return; - onCloseRef.current(); - } catch (error) { - // AbortError means we intentionally aborted (dialog closed or retry) - if (error instanceof Error && error.name === 'AbortError') return; - if (controller.signal.aborted) return; - console.error('Nostrconnect failed:', error); - setConnectStatus(null); - setConnectError(error instanceof Error ? error.message : String(error)); - } - }; - - startListening(); - - // No cleanup here: we do NOT want a re-render-triggered effect teardown - // to cancel the in-flight subscription. - }, [nostrConnectParams]); - - const handleConnectRetry = useCallback(() => { - abortControllerRef.current?.abort(); - setNostrConnectParams(null); - setNostrConnectUri(''); - setConnectError(null); - setConnectStatus(null); - setHasOpenedSigner(false); - setTimeout(() => generateConnectSession(), 0); - }, [generateConnectSession]); - - const handleOpenSignerApp = () => { - if (!nostrConnectUri) return; - // Flip into the progress view *synchronously* before navigating so that - // when the user returns from the signer app, the dialog is already - // showing "Waiting for signer connection…" — not the original button - // they're worried they need to re-tap. - setHasOpenedSigner(true); - window.location.href = nostrConnectUri; - }; - - const handleBunkerLogin = async () => { - if (!bunkerUri.trim() || !validateBunkerUri(bunkerUri)) return; - - setIsLoggingIn(true); - try { - await login.bunker(bunkerUri); - onClose(); - } catch { - setConnectError('Failed to connect. Check the bunker URI.'); - setIsLoggingIn(false); - } - }; - - const goToConnect = () => { - setStep('connect'); - if (!nostrConnectParams && !connectError) { - generateConnectSession(); - } - }; - - // Signup: generate a key with a brief spinner for feedback. - const generateKey = () => { - setIsGenerating(true); - setTimeout(() => { - const sk = generateSecretKey(); - setNsec(nip19.nsecEncode(sk)); - setStep('secure'); - setIsGenerating(false); - }, 750); - }; - - // Signup: download the nsec to a file and move on to the profile step. - const downloadAndProceed = () => { - try { - downloadNsecFile(nsec); - login.nsec(nsec); - setStep('profile'); - } catch { - toast({ - title: 'Download failed', - description: 'Could not download the key file. Please copy it manually.', - variant: 'destructive', - }); - } - }; - - // Login: submit the entered nsec. - const handleLogin = () => { - if (!loginNsec.trim()) { - setLoginError('Enter your secret key.'); - return; - } - if (!validateNsec(loginNsec)) { - setLoginError('Invalid secret key. Must start with nsec1.'); - return; - } - - setIsLoggingIn(true); - setLoginError(''); - // Timeout gives the UI a chance to repaint before the synchronous login. - setTimeout(() => { - try { - login.nsec(loginNsec); - onClose(); - } catch { - setLoginError("Couldn't log in with this key."); - setIsLoggingIn(false); - } - }, 50); - }; - - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (event) => { - const content = event.target?.result as string; - if (content && validateNsec(content.trim())) { - setLoginNsec(content.trim()); - } else { - setLoginError('File does not contain a valid secret key.'); - } - }; - reader.onerror = () => setLoginError('Failed to read file.'); - reader.readAsText(file); - }; - - const handleExtensionLogin = async () => { - if (!hasExtension) return; - setIsLoggingIn(true); - try { - await login.extension(); - onClose(); - } catch (e) { - setLoginError(e instanceof Error ? e.message : 'Extension login failed.'); - setIsLoggingIn(false); - } - }; - - const handleAvatarUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - e.target.value = ''; - - if (!file.type.startsWith('image/')) { - toast({ title: 'Please select an image.', variant: 'destructive' }); - return; - } - if (file.size > 5 * 1024 * 1024) { - toast({ title: 'Image too large (max 5MB).', variant: 'destructive' }); - return; - } - - try { - const tags = await uploadFile(file); - const url = tags[0]?.[1]; - if (url) setProfileData((prev) => ({ ...prev, picture: url })); - } catch { - toast({ title: 'Upload failed.', variant: 'destructive' }); - } - }; - - const finishSignup = async (skipProfile = false) => { - try { - if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) { - // Defensive guard: only publish kind 0 if the current signer is - // the freshly generated key. If the auto-switch ever fails (e.g. - // a regression in useLoginActions), publishing here would sign - // with the *previous* user's signer and overwrite their kind 0 - // metadata — destroying their profile. Refuse rather than risk - // it. - let expectedPubkey: string | null = null; - try { - const decoded = nip19.decode(nsec); - if (decoded.type === 'nsec') { - expectedPubkey = getPublicKey(decoded.data); - } - } catch { - // fall through to the mismatch branch below - } - - if (!expectedPubkey || currentUser?.pubkey !== expectedPubkey) { - toast({ - title: 'Profile not saved', - description: - 'The new account is not active yet, so your profile was not published (this prevents overwriting another account). Try again from your profile settings.', - variant: 'destructive', - }); - return; - } - - const metadata: Record = {}; - 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) }); - } - } catch { - toast({ - title: 'Profile setup failed', - description: 'Your account was created but the profile could not be saved. You can update it later.', - variant: 'destructive', - }); - } finally { - onClose(); - } - }; - - const getTitle = () => { - switch (step) { - case 'welcome': - return 'Welcome'; - case 'generate': - return 'Create a Nostr account'; - case 'secure': - return 'Save your key'; - case 'profile': - return 'Your profile'; - case 'login': - return 'Log in'; - case 'connect': - return 'Connect signer'; - } - }; - - // Decide whether to render the progress view in place of the QR/button. - // Mobile: flip in as soon as the user taps "Open signer app" (tracked by - // `hasOpenedSigner`) so they see feedback the moment they return from the - // signer. Desktop: keep the QR visible through the `awaiting-connect` - // phase (it's still actionable — they might scan with another device) and - // only swap in once the signer has acknowledged and we're fetching the - // pubkey. - const showProgressView = connectStatus !== null && ( - connectStatus === 'getting-public-key' || - (isMobile && hasOpenedSigner) - ); - - return ( - - - - - {getTitle()} - - - -
- {/* Welcome step — the unified entry point. */} - {step === 'welcome' && ( -
-
- 🔑 -
- -

- Join with a new Nostr account, or log in with one you already have. -

- -
- - -
-
- )} - - {/* Generate step. */} - {step === 'generate' && ( -
-
- {isGenerating ? ( -
- -
- ) : ( -
- -
- )} -
- -
-

- {isGenerating ? 'Creating your key…' : 'Your key is your identity'} -

-

- {isGenerating - ? 'This only takes a moment.' - : "We'll generate a secret key just for you. Keep it safe — it's the only way to log in."} -

-
- - {!isGenerating && ( - - )} - - -
- )} - - {/* Secure step — show + download nsec. */} - {step === 'secure' && ( -
-
- -
- -

- Store your key somewhere safe. You'll need it to log in again. -

- -
- - -
- -
-

- This key is your only way to access your account. If you lose it, you lose the account. -

-
- - -
- )} - - {/* Profile step — optional metadata. */} - {step === 'profile' && ( -
-

- Tell others a bit about yourself (optional). -

- -
-
- - - setProfileData((prev) => ({ ...prev, name: e.target.value })) - } - placeholder="Your name" - /> -
- -
- -