import { useState, useCallback, useEffect, useRef } from 'react' import { gql, useMutation } from '@apollo/client' import { signIn } from 'next-auth/react' import Col from 'react-bootstrap/Col' import Row from 'react-bootstrap/Row' import { useRouter } from 'next/router' import AccordianItem from './accordian-item' import BackIcon from '@/svgs/arrow-left-line.svg' import Nostr from '@/lib/nostr' import { NDKNip46Signer } from '@nostr-dev-kit/ndk' import { useToast } from '@/components/toast' import { Button, Container } from 'react-bootstrap' import { Form, Input, SubmitButton } from '@/components/form' import Moon from '@/svgs/moon-fill.svg' import styles from './lightning-auth.module.css' const sanitizeURL = (s) => { try { const url = new URL(s) if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('invalid protocol') return url.href } catch (e) { return null } } function NostrError ({ message }) { return ( <> <h4 className='fw-bold text-danger pb-1'>error</h4> <div className='text-muted pb-4'>{message}</div> </> ) } export function NostrAuth ({ text, callbackUrl, multiAuth }) { const [status, setStatus] = useState({ msg: '', error: false, loading: false, title: undefined, button: undefined }) const [suggestion, setSuggestion] = useState(null) const suggestionTimeout = useRef(null) const toaster = useToast() const challengeResolver = useCallback(async (challenge) => { const challengeUrl = sanitizeURL(challenge) if (challengeUrl) { setStatus({ title: 'Waiting for confirmation', msg: 'Please confirm this action on your remote signer', error: false, loading: true, button: { label: 'open signer', action: () => { window.open(challengeUrl, '_blank') } } }) } else { setStatus({ title: 'Waiting for confirmation', msg: challenge, error: false, loading: true }) } }, []) // create auth challenge const [createAuth] = useMutation(gql` mutation createAuth { createAuth { k1 } }`, { // don't cache this mutation fetchPolicy: 'no-cache' }) // print an error message const setError = useCallback((e) => { console.error(e) toaster.danger(e.message || e.toString()) setStatus({ msg: e.message || e.toString(), error: true, loading: false }) }, []) const clearSuggestionTimer = () => { if (suggestionTimeout.current) clearTimeout(suggestionTimeout.current) } const setSuggestionWithTimer = (msg) => { clearSuggestionTimer() suggestionTimeout.current = setTimeout(() => { setSuggestion(msg) }, 10_000) } useEffect(() => { return () => { clearSuggestionTimer() } }, []) // authorize user const auth = useCallback(async (nip46token) => { setStatus({ msg: 'Waiting for authorization', error: false, loading: true }) const nostr = new Nostr() try { const { data, error } = await createAuth() if (error) throw error const k1 = data?.createAuth.k1 if (!k1) throw new Error('Error generating challenge') // should never happen const useExtension = !nip46token const signer = nostr.getSigner({ nip46token, supportNip07: useExtension }) if (!signer && useExtension) throw new Error('No extension found') if (signer instanceof NDKNip46Signer) { signer.once('authUrl', challengeResolver) } setSuggestionWithTimer('Having trouble? Make sure you used a fresh token or valid NIP-05 address') await signer.blockUntilReady() clearSuggestionTimer() setStatus({ msg: 'Signing in', error: false, loading: true }) const signedEvent = await nostr.sign({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [ ['challenge', k1], ['u', process.env.NEXT_PUBLIC_URL], ['method', 'GET'] ], content: 'Stacker News Authentication' }, { signer }) await signIn('nostr', { event: JSON.stringify(signedEvent), callbackUrl, multiAuth }) } catch (e) { setError(e) } finally { nostr.close() clearSuggestionTimer() } }, []) return ( <> {status.error && <NostrError message={status.msg} />} {status.loading ? ( <> <div className='text-muted py-4 w-100 line-height-1 d-flex align-items-center gap-2'> <Moon className='spin fill-grey flex-shrink-0' width='30' height='30' /> {status.msg} </div> {status.button && ( <Button className='w-100' variant='primary' onClick={() => status.button.action()} > {status.button.label} </Button> )} {suggestion && ( <div className='text-muted text-center small pt-2'>{suggestion}</div> )} </> ) : ( <> <Form initial={{ token: '' }} onSubmit={values => { if (!values.token) { setError(new Error('Token or NIP-05 address is required')) } else { auth(values.token) } }} > <Input label='Connect with token or NIP-05 address' name='token' placeholder='bunker://... or NIP-05 address' required autoFocus /> <div className='mt-2'> <SubmitButton className='w-100' variant='primary'> {text || 'Login'} with token or NIP-05 </SubmitButton> </div> </Form> <div className='text-center text-muted fw-bold my-2'>or</div> <Button variant='nostr' className='w-100' type='submit' onClick={async () => { try { await auth() } catch (e) { setError(e) } }} > {text || 'Login'} with extension </Button> </> )} </> ) } function NostrExplainer ({ text, children }) { const router = useRouter() return ( <Container> <div className={styles.login}> <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div> <h3 className='w-100 pb-2'> {text || 'Login'} with Nostr </h3> <Row className='w-100 text-muted'> <Col className='ps-0 mb-4' md> <AccordianItem header='Which NIP-46 signers can I use?' body={ <> <Row> <Col xs> <ul> <li> <a href='https://nsec.app/'>Nsec.app</a> <ul> <li>available for: chrome, firefox, and safari</li> </ul> </li> <li> <a href='https://app.nsecbunker.com/'>nsecBunker</a> <ul> <li>available as: SaaS or self-hosted</li> </ul> </li> </ul> </Col> </Row> </> } /> <AccordianItem header='Which extensions can I use?' body={ <> <Row> <Col> <ul> <li> <a href='https://getalby.com'>Alby</a> <ul> <li>available for: chrome, firefox, and safari</li> </ul> </li> <li> <a href='https://www.getflamingo.org/'>Flamingo</a> <ul> <li>available for: chrome</li> </ul> </li> <li> <a href='https://github.com/fiatjaf/nos2x'>nos2x</a> <ul> <li>available for: chrome</li> </ul> </li> <li> <a href='https://diegogurpegui.com/nos2x-fox/'>nos2x-fox</a> <ul> <li>available for: firefox</li> </ul> </li> <li> <a href='https://github.com/fiatjaf/horse'>horse</a> <ul> <li>available for: chrome</li> <li>supports hardware signing</li> </ul> </li> </ul> </Col> </Row> </> } /> </Col> <Col md className='mx-auto' style={{ maxWidth: '300px' }}> {children} </Col> </Row> </div> </Container> ) } export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) { return ( <NostrExplainer text={text}> <NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} /> </NostrExplainer> ) }