account linking

This commit is contained in:
keyan 2022-06-02 17:55:23 -05:00
parent dd4be45ae8
commit 1df49e03d9
13 changed files with 834 additions and 253 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"search.useIgnoreFiles": true
}

View File

@ -25,6 +25,23 @@ export function topClause (within) {
return interval return interval
} }
async function authMethods (user, args, { models, me }) {
const accounts = await models.account.findMany({
where: {
userId: me.id
}
})
const oauth = accounts.map(a => a.providerId)
return {
lightning: !!user.pubkey,
email: user.emailVerified && user.email,
twitter: oauth.indexOf('twitter') >= 0,
github: oauth.indexOf('github') >= 0
}
}
export default { export default {
Query: { Query: {
me: async (parent, args, { models, me }) => { me: async (parent, args, { models, me }) => {
@ -34,6 +51,13 @@ export default {
return await models.user.update({ where: { id: me.id }, data: { lastSeenAt: new Date() } }) return await models.user.update({ where: { id: me.id }, data: { lastSeenAt: new Date() } })
}, },
settings: async (parent, args, { models, me }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
return await models.user.findUnique({ where: { id: me.id } })
},
user: async (parent, { name }, { models }) => { user: async (parent, { name }, { models }) => {
return await models.user.findUnique({ where: { name } }) return await models.user.findUnique({ where: { name } })
}, },
@ -152,10 +176,54 @@ export default {
await createMentions(item, models) await createMentions(item, models)
return await models.user.findUnique({ where: { id: me.id } }) return await models.user.findUnique({ where: { id: me.id } })
},
unlinkAuth: async (parent, { authType }, { models, me }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
if (authType === 'twitter' || authType === 'github') {
const user = await models.user.findUnique({ where: { id: me.id } })
const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } })
if (!account) {
throw new UserInputError('no such account')
}
await models.account.delete({ where: { id: account.id } })
return await authMethods(user, undefined, { models, me })
}
if (authType === 'lightning') {
const user = await models.user.update({ where: { id: me.id }, data: { pubkey: null } })
return await authMethods(user, undefined, { models, me })
}
if (authType === 'email') {
const user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
return await authMethods(user, undefined, { models, me })
}
throw new UserInputError('no such account')
},
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
try {
await models.user.update({ where: { id: me.id }, data: { email } })
} catch (error) {
if (error.code === 'P2002') {
throw new UserInputError('email taken')
}
throw error
}
return true
} }
}, },
User: { User: {
authMethods,
nitems: async (user, args, { models }) => { nitems: async (user, args, { models }) => {
return await models.item.count({ where: { userId: user.id, parentId: null } }) return await models.item.count({ where: { userId: user.id, parentId: null } })
}, },

View File

@ -3,6 +3,7 @@ import { gql } from 'apollo-server-micro'
export default gql` export default gql`
extend type Query { extend type Query {
me: User me: User
settings: User
user(name: String!): User user(name: String!): User
users: [User!] users: [User!]
nameAvailable(name: String!): Boolean! nameAvailable(name: String!): Boolean!
@ -33,6 +34,15 @@ export default gql`
setPhoto(photoId: ID!): Int! setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User! upsertBio(bio: String!): User!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean
}
type AuthMethods {
lightning: Boolean!
email: String
twitter: Boolean!
github: Boolean!
} }
type User { type User {
@ -61,5 +71,6 @@ export default gql`
noteInvites: Boolean! noteInvites: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
lastCheckedJobs: String lastCheckedJobs: String
authMethods: AuthMethods!
} }
` `

View File

@ -0,0 +1,50 @@
import { gql, useMutation, useQuery } from '@apollo/client'
import { signIn } from 'next-auth/client'
import { useEffect } from 'react'
import LnQR, { LnQRSkeleton } from './lnqr'
function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
const query = gql`
{
lnAuth(k1: "${k1}") {
pubkey
k1
}
}`
const { data } = useQuery(query, { pollInterval: 1000 })
if (data && data.lnAuth.pubkey) {
signIn('credentials', { ...data.lnAuth, callbackUrl })
}
// output pubkey and k1
return (
<>
<small className='mb-2'>
<a className='text-muted text-underline' href='https://github.com/fiatjaf/lnurl-rfc#lnurl-documents' target='_blank' rel='noreferrer' style={{ textDecoration: 'underline' }}>Does my wallet support lnurl-auth?</a>
</small>
<LnQR value={encodedUrl} status='waiting for you' />
</>
)
}
export function LightningAuth ({ callbackUrl }) {
// query for challenge
const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth {
createAuth {
k1
encodedUrl
}
}`)
useEffect(createAuth, [])
if (error) return <div>error</div>
if (!data) {
return <LnQRSkeleton status='generating' />
}
return <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} />
}

View File

@ -0,0 +1,33 @@
import GithubIcon from '../svgs/github-fill.svg'
import TwitterIcon from '../svgs/twitter-fill.svg'
import LightningIcon from '../svgs/bolt.svg'
import { Button } from 'react-bootstrap'
export default function LoginButton ({ text, type, className, onClick }) {
let Icon, variant
switch (type) {
case 'twitter':
Icon = TwitterIcon
variant = 'twitter'
break
case 'github':
Icon = GithubIcon
variant = 'dark'
break
case 'lightning':
Icon = LightningIcon
variant = 'primary'
break
}
const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase()
return (
<Button className={className} variant={variant} onClick={onClick}>
<Icon
width={20}
height={20} className='mr-2'
/>
{text} {name}
</Button>
)
}

View File

@ -6,17 +6,39 @@ import TwitterIcon from '../svgs/twitter-fill.svg'
import LightningIcon from '../svgs/bolt.svg' import LightningIcon from '../svgs/bolt.svg'
import { Form, Input, SubmitButton } from '../components/form' import { Form, Input, SubmitButton } from '../components/form'
import * as Yup from 'yup' import * as Yup from 'yup'
import { useEffect, useState } from 'react' import { useState } from 'react'
import Alert from 'react-bootstrap/Alert' import Alert from 'react-bootstrap/Alert'
import LayoutCenter from '../components/layout-center' import LayoutCenter from '../components/layout-center'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import LnQR, { LnQRSkeleton } from '../components/lnqr' import { LightningAuth } from './lightning-auth'
import { gql, useMutation, useQuery } from '@apollo/client'
export const EmailSchema = Yup.object({ export const EmailSchema = Yup.object({
email: Yup.string().email('email is no good').required('required') email: Yup.string().email('email is no good').required('required')
}) })
export function EmailLoginForm ({ callbackUrl }) {
return (
<Form
initial={{
email: ''
}}
schema={EmailSchema}
onSubmit={async ({ email }) => {
signIn('email', { email, callbackUrl })
}}
>
<Input
label='Email'
name='email'
placeholder='email@example.com'
required
autoFocus
/>
<SubmitButton variant='secondary' className={styles.providerButton}>Login with Email</SubmitButton>
</Form>
)
}
export default function Login ({ providers, callbackUrl, error, Header }) { export default function Login ({ providers, callbackUrl, error, Header }) {
const errors = { const errors = {
Signin: 'Try signing with a different account.', Signin: 'Try signing with a different account.',
@ -84,72 +106,9 @@ export default function Login ({ providers, callbackUrl, error, Header }) {
) )
})} })}
<div className='mt-2 text-center text-muted font-weight-bold'>or</div> <div className='mt-2 text-center text-muted font-weight-bold'>or</div>
<Form <EmailLoginForm callbackUrl={callbackUrl} />
initial={{
email: ''
}}
schema={EmailSchema}
onSubmit={async ({ email }) => {
signIn('email', { email, callbackUrl })
}}
>
<Input
label='Email'
name='email'
placeholder='email@example.com'
required
autoFocus
/>
<SubmitButton variant='secondary' className={styles.providerButton}>Login with Email</SubmitButton>
</Form>
</>)} </>)}
</div> </div>
</LayoutCenter> </LayoutCenter>
) )
} }
function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
const query = gql`
{
lnAuth(k1: "${k1}") {
pubkey
k1
}
}`
const { data } = useQuery(query, { pollInterval: 1000 })
if (data && data.lnAuth.pubkey) {
signIn('credentials', { ...data.lnAuth, callbackUrl })
}
// output pubkey and k1
return (
<>
<small className='mb-2'>
<a className='text-muted text-underline' href='https://github.com/fiatjaf/lnurl-rfc#lnurl-documents' target='_blank' rel='noreferrer' style={{ textDecoration: 'underline' }}>Does my wallet support lnurl-auth?</a>
</small>
<LnQR value={encodedUrl} status='waiting for you' />
</>
)
}
export function LightningAuth ({ callbackUrl }) {
// query for challenge
const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth {
createAuth {
k1
encodedUrl
}
}`)
useEffect(createAuth, [])
if (error) return <div>error</div>
if (!data) {
return <LnQRSkeleton status='generating' />
}
return <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} />
}

View File

@ -12,7 +12,7 @@ export default function ModalButton ({ children, clicker }) {
> >
<div className='modal-close' onClick={() => setShow(false)}>X</div> <div className='modal-close' onClick={() => setShow(false)}>X</div>
<Modal.Body> <Modal.Body>
{children} {show && children}
</Modal.Body> </Modal.Body>
</Modal> </Modal>
<div className='pointer' onClick={() => setShow(true)}>{clicker}</div> <div className='pointer' onClick={() => setShow(true)}>{clicker}</div>

View File

@ -52,6 +52,26 @@ export const ME_SSR = gql`
} }
}` }`
export const SETTINGS = gql`
{
settings {
tipDefault
noteItemSats
noteEarning
noteAllDescendants
noteMentions
noteDeposits
noteInvites
noteJobIndicator
authMethods {
lightning
email
twitter
github
}
}
}`
export const NAME_QUERY = export const NAME_QUERY =
gql` gql`
query nameAvailable($name: String!) { query nameAvailable($name: String!) {

296
lib/prisma-adapter.js Normal file
View File

@ -0,0 +1,296 @@
/* eslint-disable */
'use strict'
Object.defineProperty(exports, '__esModule', {
value: true
})
exports.getCompoundId = getCompoundId
exports.Adapter = exports.PrismaLegacyAdapter = PrismaLegacyAdapter
const _crypto = require('crypto')
function getCompoundId (a, b) {
return (0, _crypto.createHash)('sha256').update(`${a}:${b}`).digest('hex')
}
function PrismaLegacyAdapter (config) {
const {
prisma,
modelMapping = {
User: 'user',
Account: 'account',
Session: 'session',
VerificationRequest: 'verificationRequest'
}
} = config
const {
User,
Account,
Session,
VerificationRequest
} = modelMapping
return {
async getAdapter ({
session: {
maxAge,
updateAge
},
secret,
...appOptions
}) {
const sessionMaxAge = maxAge * 1000
const sessionUpdateAge = updateAge * 1000
const hashToken = token => (0, _crypto.createHash)('sha256').update(`${token}${secret}`).digest('hex')
return {
displayName: 'PRISMA_LEGACY',
createUser (profile) {
let _profile$emailVerifie
return prisma[User].create({
data: {
name: profile.name,
email: profile.email,
image: profile.image,
emailVerified: (_profile$emailVerifie = profile.emailVerified) === null || _profile$emailVerifie === void 0 ? void 0 : _profile$emailVerifie.toISOString()
}
})
},
getUser (id) {
return prisma[User].findUnique({
where: {
id: Number(id)
}
})
},
getUserByEmail (email) {
if (email) {
return prisma[User].findUnique({
where: {
email
}
})
}
return null
},
async getUserByProviderAccountId (providerId, providerAccountId) {
const account = await prisma[Account].findUnique({
where: {
compoundId: getCompoundId(providerId, providerAccountId)
}
})
if (account) {
return prisma[User].findUnique({
where: {
id: account.userId
}
})
}
return null
},
updateUser (user) {
const {
id,
name,
email,
image,
emailVerified
} = user
return prisma[User].update({
where: {
id
},
data: {
name,
email,
image,
emailVerified: emailVerified === null || emailVerified === void 0 ? void 0 : emailVerified.toISOString()
}
})
},
deleteUser (userId) {
return prisma[User].delete({
where: {
id: userId
}
})
},
linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
return prisma[Account].create({
data: {
accessToken,
refreshToken,
compoundId: getCompoundId(providerId, providerAccountId),
providerAccountId: `${providerAccountId}`,
providerId,
providerType,
accessTokenExpires,
userId
}
})
},
unlinkAccount (_, providerId, providerAccountId) {
return prisma[Account].delete({
where: {
compoundId: getCompoundId(providerId, providerAccountId)
}
})
},
createSession (user) {
let expires = null
if (sessionMaxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
expires = dateExpires.toISOString()
}
return prisma[Session].create({
data: {
expires,
userId: user.id,
sessionToken: (0, _crypto.randomBytes)(32).toString('hex'),
accessToken: (0, _crypto.randomBytes)(32).toString('hex')
}
})
},
async getSession (sessionToken) {
const session = await prisma[Session].findUnique({
where: {
sessionToken
}
})
if (session !== null && session !== void 0 && session.expires && new Date() > session.expires) {
await prisma[Session].delete({
where: {
sessionToken
}
})
return null
}
return session
},
updateSession (session, force) {
if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) {
const dateSessionIsDueToBeUpdated = new Date(session.expires)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
if (new Date() > dateSessionIsDueToBeUpdated) {
const newExpiryDate = new Date()
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
session.expires = newExpiryDate
} else if (!force) {
return null
}
} else {
if (!force) {
return null
}
}
const {
id,
expires
} = session
return prisma[Session].update({
where: {
id
},
data: {
expires: expires.toISOString()
}
})
},
deleteSession (sessionToken) {
return prisma[Session].delete({
where: {
sessionToken
}
})
},
async createVerificationRequest (identifier, url, token, _, provider) {
const {
sendVerificationRequest,
maxAge
} = provider
let expires = null
if (maxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + maxAge * 1000)
expires = dateExpires.toISOString()
}
const verificationRequest = await prisma[VerificationRequest].create({
data: {
identifier,
token: hashToken(token),
expires
}
})
await sendVerificationRequest({
identifier,
url,
token,
baseUrl: appOptions.baseUrl,
provider
})
return verificationRequest
},
async getVerificationRequest (identifier, token) {
const hashedToken = hashToken(token)
const verificationRequest = await prisma[VerificationRequest].findFirst({
where: {
identifier,
token: hashedToken
}
})
if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) {
await prisma[VerificationRequest].deleteMany({
where: {
identifier,
token: hashedToken
}
})
return null
}
return verificationRequest
},
async deleteVerificationRequest (identifier, token) {
await prisma[VerificationRequest].deleteMany({
where: {
identifier,
token: hashToken(token)
}
})
}
}
}
}
}

147
package-lock.json generated
View File

@ -1153,7 +1153,7 @@
"any-promise": { "any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="
}, },
"anymatch": { "anymatch": {
"version": "3.1.2", "version": "3.1.2",
@ -2082,7 +2082,7 @@
"buffer-equal-constant-time": { "buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
}, },
"buffer-writer": { "buffer-writer": {
"version": "2.0.0", "version": "2.0.0",
@ -3558,11 +3558,6 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"figlet": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/figlet/-/figlet-1.5.2.tgz",
"integrity": "sha512-WOn21V8AhyE1QqVfPIVxe3tupJacq1xGkPTB4iagT6o+P2cAgEOOwIxMftr4+ZCTI6d551ij9j61DFr0nsP2uQ=="
},
"file-entry-cache": { "file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -3771,9 +3766,9 @@
"dev": true "dev": true
}, },
"futoin-hkdf": { "futoin-hkdf": {
"version": "1.4.2", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.4.2.tgz", "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.1.tgz",
"integrity": "sha512-2BggwLEJOTfXzKq4Tl2bIT37p0IqqKkblH4e0cMp2sXTdmwg/ADBKMxvxaEytYYcgdxgng8+acsi3WgMVUl6CQ==" "integrity": "sha512-g5d0Qp7ks55hYmYmfqn4Nz18XH49lcCR+vvIvHT92xXnsJaGZmY1EtWQWilJ6BQp57heCIXM/rRo+AFep8hGgg=="
}, },
"get-caller-file": { "get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
@ -3946,21 +3941,6 @@
"function-bind": "^1.1.1" "function-bind": "^1.1.1"
} }
}, },
"has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
"integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
"requires": {
"ansi-regex": "^2.0.0"
},
"dependencies": {
"ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
}
}
},
"has-bigints": { "has-bigints": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
@ -4811,22 +4791,22 @@
"lodash.includes": { "lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
}, },
"lodash.isboolean": { "lodash.isboolean": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
}, },
"lodash.isinteger": { "lodash.isinteger": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
}, },
"lodash.isnumber": { "lodash.isnumber": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
}, },
"lodash.isplainobject": { "lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
@ -4836,7 +4816,7 @@
"lodash.isstring": { "lodash.isstring": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
}, },
"lodash.memoize": { "lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
@ -4853,7 +4833,7 @@
"lodash.once": { "lodash.once": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
}, },
"lodash.sortby": { "lodash.sortby": {
"version": "4.7.0", "version": "4.7.0",
@ -6086,9 +6066,9 @@
} }
}, },
"next-auth": { "next-auth": {
"version": "3.29.0", "version": "3.29.3",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-3.29.0.tgz", "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-3.29.3.tgz",
"integrity": "sha512-B//4QTv/1Of0D+roZ82URmI6L2JSbkKgeaKI7Mdrioq8lAzp9ff8NdmouvZL/7zwrPe2cUyM6MLYlasfuI3ZIQ==", "integrity": "sha512-OoG5y8oFV7MWF2VVs20AfdF41ndoXtPBFIlLfCHbrvFWHfPGsjnyAnhDxyJZX91Taknd4MD3zrCGOlBJKrLU7A==",
"requires": { "requires": {
"@babel/runtime": "^7.14.0", "@babel/runtime": "^7.14.0",
"@next-auth/prisma-legacy-adapter": "0.1.2", "@next-auth/prisma-legacy-adapter": "0.1.2",
@ -6302,9 +6282,9 @@
"integrity": "sha1-VWD8abweqQ46THhB8jPSAHU3hQM=" "integrity": "sha1-VWD8abweqQ46THhB8jPSAHU3hQM="
}, },
"nodemailer": { "nodemailer": {
"version": "6.6.5", "version": "6.7.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.6.5.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.5.tgz",
"integrity": "sha512-C/v856DBijUzHcHIgGpQoTrfsH3suKIRAGliIzCstatM2cAa+MYX3LuyCrABiO/cdJTxgBBHXxV1ztiqUwst5A==" "integrity": "sha512-6VtMpwhsrixq1HDYSBBHvW0GwiWawE75dS3oal48VqRhUvKJNnKnJo2RI/bCVQubj1vgrgscMNW4DHaD6xtMCg=="
}, },
"nofilter": { "nofilter": {
"version": "3.0.3", "version": "3.0.3",
@ -6355,7 +6335,7 @@
"oauth": { "oauth": {
"version": "0.9.15", "version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE=" "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
@ -6615,11 +6595,6 @@
"callsites": "^3.0.0" "callsites": "^3.0.0"
} }
}, },
"parent-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/parent-require/-/parent-require-1.0.0.tgz",
"integrity": "sha1-dGoWdjgIOoYLDu9nMssn7UbDKXc="
},
"parse-asn1": { "parse-asn1": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz", "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz",
@ -7097,14 +7072,14 @@
} }
}, },
"preact": { "preact": {
"version": "10.5.14", "version": "10.7.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.5.14.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.7.3.tgz",
"integrity": "sha512-KojoltCrshZ099ksUZ2OQKfbH66uquFoxHSbnwKbTJHeQNvx42EmC7wQVWNuDt6vC5s3nudRHFtKbpY4ijKlaQ==" "integrity": "sha512-giqJXP8VbtA1tyGa3f1n9wiN7PrHtONrDyE3T+ifjr/tTkg+2N4d/6sjC9WyJKv8wM7rOYDveqy5ZoFmYlwo4w=="
}, },
"preact-render-to-string": { "preact-render-to-string": {
"version": "5.1.19", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.1.19.tgz", "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.0.tgz",
"integrity": "sha512-bj8sn/oytIKO6RtOGSS/1+5CrQyRSC99eLUnEVbqUa6MzJX5dYh7wu9bmT0d6lm/Vea21k9KhCQwvr2sYN3rrQ==", "integrity": "sha512-+RGwSW78Cl+NsZRUbFW1MGB++didsfqRk+IyRVTaqy+3OjtpKK/6HgBtfszUX0YXMfo41k2iaQSseAHGKEwrbg==",
"requires": { "requires": {
"pretty-format": "^3.8.0" "pretty-format": "^3.8.0"
} }
@ -7123,7 +7098,7 @@
"pretty-format": { "pretty-format": {
"version": "3.8.0", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha1-v77VbV6ad2ZF9LH/eqGjrE+jw4U=" "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
}, },
"prisma": { "prisma": {
"version": "2.25.0", "version": "2.25.0",
@ -7800,7 +7775,7 @@
"resolve-from": { "resolve-from": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
"integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" "integrity": "sha512-qpFcKaXsq8+oRoLilkwyc7zHGF5i9Q2/25NIgLQQ/+VVv9rU4qvr6nXVAw1DsnXJyQkZsR4Ytfbtg5ehfcUssQ=="
}, },
"semver": { "semver": {
"version": "5.7.1", "version": "5.7.1",
@ -9148,9 +9123,9 @@
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
}, },
"typeorm": { "typeorm": {
"version": "0.2.37", "version": "0.2.45",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.37.tgz", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.45.tgz",
"integrity": "sha512-7rkW0yCgFC24I5T0f3S/twmLSuccPh1SQmxET/oDWn2sSDVzbyWdnItSdKy27CdJGTlKHYtUVeOcMYw5LRsXVw==", "integrity": "sha512-c0rCO8VMJ3ER7JQ73xfk0zDnVv0WDjpsP6Q1m6CVKul7DB9iVdWLRjPzc8v2eaeBuomsbZ2+gTaYr8k1gm3bYA==",
"requires": { "requires": {
"@sqltools/formatter": "^1.2.2", "@sqltools/formatter": "^1.2.2",
"app-root-path": "^3.0.0", "app-root-path": "^3.0.0",
@ -9165,8 +9140,8 @@
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"sha.js": "^2.4.11", "sha.js": "^2.4.11",
"tslib": "^2.1.0", "tslib": "^2.1.0",
"uuid": "^8.3.2",
"xml2js": "^0.4.23", "xml2js": "^0.4.23",
"yargonaut": "^1.1.4",
"yargs": "^17.0.1", "yargs": "^17.0.1",
"zen-observable-ts": "^1.0.0" "zen-observable-ts": "^1.0.0"
}, },
@ -9237,18 +9212,23 @@
} }
}, },
"yargs": { "yargs": {
"version": "17.2.1", "version": "17.5.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.2.1.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz",
"integrity": "sha512-XfR8du6ua4K6uLGm5S6fA+FIJom/MdJcFNVY8geLlp2v8GYbOXD4EB1tPNZsRn4vBzKGMgb5DRZMeWuFc2GO8Q==", "integrity": "sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==",
"requires": { "requires": {
"cliui": "^7.0.2", "cliui": "^7.0.2",
"escalade": "^3.1.1", "escalade": "^3.1.1",
"get-caller-file": "^2.0.5", "get-caller-file": "^2.0.5",
"require-directory": "^2.1.1", "require-directory": "^2.1.1",
"string-width": "^4.2.0", "string-width": "^4.2.3",
"y18n": "^5.0.5", "y18n": "^5.0.5",
"yargs-parser": "^20.2.2" "yargs-parser": "^21.0.0"
} }
},
"yargs-parser": {
"version": "21.0.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz",
"integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg=="
} }
} }
}, },
@ -9779,53 +9759,6 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"yargonaut": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz",
"integrity": "sha512-rHgFmbgXAAzl+1nngqOcwEljqHGG9uUZoPjsdZEs1w5JW9RXYzrSvH/u70C1JE5qFi0qjsdhnUX/dJRpWqitSA==",
"requires": {
"chalk": "^1.1.1",
"figlet": "^1.1.1",
"parent-require": "^1.0.0"
},
"dependencies": {
"ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
},
"ansi-styles": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
"integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
},
"chalk": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
"requires": {
"ansi-styles": "^2.2.1",
"escape-string-regexp": "^1.0.2",
"has-ansi": "^2.0.0",
"strip-ansi": "^3.0.0",
"supports-color": "^2.0.0"
}
},
"strip-ansi": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"requires": {
"ansi-regex": "^2.0.0"
}
},
"supports-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
"integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
}
}
},
"yargs": { "yargs": {
"version": "16.2.0", "version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",

View File

@ -28,7 +28,7 @@
"ln-service": "^52.8.0", "ln-service": "^52.8.0",
"mdast-util-find-and-replace": "^1.1.1", "mdast-util-find-and-replace": "^1.1.1",
"next": "^11.1.2", "next": "^11.1.2",
"next-auth": "^3.13.3", "next-auth": "^3.29.3",
"next-plausible": "^2.1.3", "next-plausible": "^2.1.3",
"next-seo": "^4.24.0", "next-seo": "^4.24.0",
"nextjs-progressbar": "^0.0.13", "nextjs-progressbar": "^0.0.13",

View File

@ -1,8 +1,9 @@
import NextAuth from 'next-auth' import NextAuth from 'next-auth'
import Providers from 'next-auth/providers' import Providers from 'next-auth/providers'
import Adapters from 'next-auth/adapters' import { PrismaLegacyAdapter } from '../../../lib/prisma-adapter'
import prisma from '../../../api/models' import prisma from '../../../api/models'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { getSession } from 'next-auth/client'
export default (req, res) => NextAuth(req, res, options) export default (req, res) => NextAuth(req, res, options)
@ -19,7 +20,10 @@ const options = {
async jwt (token, user, account, profile, isNewUser) { async jwt (token, user, account, profile, isNewUser) {
// Add additional session params // Add additional session params
if (user?.id) { if (user?.id) {
token.id = user.id token.id = Number(user.id)
// HACK next-auth needs this to do account linking with jwts
// see: https://github.com/nextauthjs/next-auth/issues/625
token.user = { id: Number(user.id) }
} }
// sign them up for the newsletter // sign them up for the newsletter
@ -44,7 +48,7 @@ const options = {
}, },
async session (session, token) { async session (session, token) {
// we need to add additional session params here // we need to add additional session params here
session.user.id = token.id session.user.id = Number(token.id)
return session return session
} }
}, },
@ -65,9 +69,18 @@ const options = {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } }) const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
if (lnauth.pubkey === pubkey) { if (lnauth.pubkey === pubkey) {
let user = await prisma.user.findUnique({ where: { pubkey } }) let user = await prisma.user.findUnique({ where: { pubkey } })
const session = await getSession({ req })
if (!user) { if (!user) {
// if we are logged in, update rather than create
if (session?.user) {
user = await prisma.user.update({ where: { id: session.user.id }, data: { pubkey } })
} else {
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), pubkey } }) user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), pubkey } })
} }
} else if (session && session.user?.id !== user.id) {
throw new Error('account not linked')
}
await prisma.lnAuth.delete({ where: { k1 } }) await prisma.lnAuth.delete({ where: { k1 } })
return user return user
} }
@ -108,7 +121,7 @@ const options = {
} }
}) })
], ],
adapter: Adapters.Prisma.Adapter({ prisma }), adapter: PrismaLegacyAdapter({ prisma }),
secret: process.env.NEXTAUTH_SECRET, secret: process.env.NEXTAUTH_SECRET,
session: { jwt: true }, session: { jwt: true },
jwt: { jwt: {

View File

@ -1,21 +1,31 @@
import { Checkbox, Form, Input, SubmitButton } from '../components/form' import { Checkbox, Form, Input, SubmitButton } from '../components/form'
import * as Yup from 'yup' import * as Yup from 'yup'
import { Alert, Button, InputGroup } from 'react-bootstrap' import { Alert, Button, InputGroup, Modal } from 'react-bootstrap'
import { useMe } from '../components/me'
import LayoutCenter from '../components/layout-center' import LayoutCenter from '../components/layout-center'
import { useState } from 'react' import { useState } from 'react'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation, useQuery } from '@apollo/client'
import { getGetServerSideProps } from '../api/ssrApollo' import { getGetServerSideProps } from '../api/ssrApollo'
import LoginButton from '../components/login-button'
import { signIn } from 'next-auth/client'
import ModalButton from '../components/modal-button'
import { LightningAuth } from '../components/lightning-auth'
import { SETTINGS } from '../fragments/users'
import { useRouter } from 'next/router'
export const getServerSideProps = getGetServerSideProps() export const getServerSideProps = getGetServerSideProps(SETTINGS)
export const SettingsSchema = Yup.object({ export const SettingsSchema = Yup.object({
tipDefault: Yup.number().typeError('must be a number').required('required') tipDefault: Yup.number().typeError('must be a number').required('required')
.positive('must be positive').integer('must be whole') .positive('must be positive').integer('must be whole')
}) })
export default function Settings () { const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
const me = useMe()
export const WarningSchema = Yup.object({
warning: Yup.string().matches(warningMessage, 'does not match').required('required')
})
export default function Settings ({ data: { settings } }) {
const [success, setSuccess] = useState() const [success, setSuccess] = useState()
const [setSettings] = useMutation( const [setSettings] = useMutation(
gql` gql`
@ -29,19 +39,25 @@ export default function Settings () {
}` }`
) )
const { data } = useQuery(SETTINGS)
if (data) {
({ settings } = data)
}
return ( return (
<LayoutCenter> <LayoutCenter>
<h2 className='mb-5 text-left'>settings</h2> <div className='py-3 w-100'>
<h2 className='mb-2 text-left'>settings</h2>
<Form <Form
initial={{ initial={{
tipDefault: me?.tipDefault || 21, tipDefault: settings?.tipDefault || 21,
noteItemSats: me?.noteItemSats, noteItemSats: settings?.noteItemSats,
noteEarning: me?.noteEarning, noteEarning: settings?.noteEarning,
noteAllDescendants: me?.noteAllDescendants, noteAllDescendants: settings?.noteAllDescendants,
noteMentions: me?.noteMentions, noteMentions: settings?.noteMentions,
noteDeposits: me?.noteDeposits, noteDeposits: settings?.noteDeposits,
noteInvites: me?.noteInvites, noteInvites: settings?.noteInvites,
noteJobIndicator: me?.noteJobIndicator noteJobIndicator: settings?.noteJobIndicator
}} }}
schema={SettingsSchema} schema={SettingsSchema}
onSubmit={async ({ tipDefault, ...values }) => { onSubmit={async ({ tipDefault, ...values }) => {
@ -92,12 +108,191 @@ export default function Settings () {
label='there is a new job' label='there is a new job'
name='noteJobIndicator' name='noteJobIndicator'
/> />
<div className='form-label'>saturday newsletter</div>
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
<div className='d-flex'> <div className='d-flex'>
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton> <SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
</div> </div>
</Form> </Form>
<div className='text-left w-100'>
<div className='form-label'>saturday newsletter</div>
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
{settings?.authMethods && <AuthMethods methods={settings.authMethods} />}
</div>
</div>
</LayoutCenter> </LayoutCenter>
) )
} }
function AuthMethods ({ methods }) {
const router = useRouter()
const [unlinkAuth] = useMutation(
gql`
mutation unlinkAuth($authType: String!) {
unlinkAuth(authType: $authType) {
lightning
email
twitter
github
}
}`, {
update (cache, { data: { unlinkAuth } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings (existing) {
return { ...existing, authMethods: { ...unlinkAuth } }
}
}
})
}
}
)
const [obstacle, setObstacle] = useState()
const unlink = async type => {
// if there's only one auth method left
let links = 0
links += methods.lightning ? 1 : 0
links += methods.email ? 1 : 0
links += methods.twitter ? 1 : 0
links += methods.github ? 1 : 0
if (links === 1) {
setObstacle(type)
} else {
await unlinkAuth({ variables: { authType: type } })
}
}
return (
<>
<Modal
show={obstacle}
onHide={() => setObstacle(null)}
>
<div className='modal-close' onClick={() => setObstacle(null)}>X</div>
<Modal.Body>
You are removing your last auth method. It is recommended you link another auth method before removing
your last auth method. If you'd like to proceed anyway, type the following below
<div className='text-danger font-weight-bold my-2'>
If I logout, even accidentally, I will never be able to access my account again
</div>
<Form
className='mt-3'
initial={{
warning: ''
}}
schema={WarningSchema}
onSubmit={async () => {
await unlinkAuth({ variables: { authType: obstacle } })
router.push('/settings')
setObstacle(null)
}}
>
<Input
name='warning'
required
/>
<SubmitButton className='d-flex ml-auto' variant='danger'>do it</SubmitButton>
</Form>
</Modal.Body>
</Modal>
<div className='form-label mt-3'>auth methods</div>
{methods.lightning
? <LoginButton
className='d-block' type='lightning' text='Unlink' onClick={
async () => {
await unlink('lightning')
}
}
/>
: (
<ModalButton clicker={<LoginButton className='d-block' type='lightning' text='Link' />}>
<div className='d-flex flex-column align-items-center'>
<LightningAuth />
</div>
</ModalButton>)}
<LoginButton
className='d-block mt-2' type='twitter' text={methods.twitter ? 'Unlink' : 'Link'} onClick={
async () => {
if (methods.twitter) {
await unlink('twitter')
} else {
signIn('twitter')
}
}
}
/>
<LoginButton
className='d-block mt-2' type='github' text={methods.github ? 'Unlink' : 'Link'} onClick={
async () => {
if (methods.github) {
await unlink('github')
} else {
signIn('github')
}
}
}
/>
{methods.email
? (
<div className='mt-2 d-flex align-items-center'>
<Input
name='email'
placeholder={methods.email}
groupClassName='mb-0'
readOnly
/>
<Button
className='ml-2' variant='secondary' onClick={
async () => {
await unlink('email')
}
}
>Unlink Email
</Button>
</div>
)
: <div className='mt-2'><EmailLinkForm /></div>}
</>
)
}
export const EmailSchema = Yup.object({
email: Yup.string().email('email is no good').required('required')
})
export function EmailLinkForm ({ callbackUrl }) {
const [linkUnverifiedEmail] = useMutation(
gql`
mutation linkUnverifiedEmail($email: String!) {
linkUnverifiedEmail(email: $email)
}`
)
return (
<Form
initial={{
email: ''
}}
schema={EmailSchema}
onSubmit={async ({ email }) => {
// add email to user's account
// then call signIn
const { data } = await linkUnverifiedEmail({ variables: { email } })
if (data.linkUnverifiedEmail) {
signIn('email', { email, callbackUrl })
}
}}
>
<div className='d-flex align-items-center'>
<Input
name='email'
placeholder='email@example.com'
required
groupClassName='mb-0'
/>
<SubmitButton className='ml-2' variant='secondary'>Link Email</SubmitButton>
</div>
</Form>
)
}