337 lines
9.7 KiB
JavaScript
337 lines
9.7 KiB
JavaScript
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>
|
|
)
|
|
}
|