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
}
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 {
Query: {
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() } })
},
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 }) => {
return await models.user.findUnique({ where: { name } })
},
@ -152,10 +176,54 @@ export default {
await createMentions(item, models)
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: {
authMethods,
nitems: async (user, args, { models }) => {
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`
extend type Query {
me: User
settings: User
user(name: String!): User
users: [User!]
nameAvailable(name: String!): Boolean!
@ -33,6 +34,15 @@ export default gql`
setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User!
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 {
@ -61,5 +71,6 @@ export default gql`
noteInvites: Boolean!
noteJobIndicator: Boolean!
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 { Form, Input, SubmitButton } from '../components/form'
import * as Yup from 'yup'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import Alert from 'react-bootstrap/Alert'
import LayoutCenter from '../components/layout-center'
import { useRouter } from 'next/router'
import LnQR, { LnQRSkeleton } from '../components/lnqr'
import { gql, useMutation, useQuery } from '@apollo/client'
import { LightningAuth } from './lightning-auth'
export const EmailSchema = Yup.object({
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 }) {
const errors = {
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>
<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>
<EmailLoginForm callbackUrl={callbackUrl} />
</>)}
</div>
</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>
<Modal.Body>
{children}
{show && children}
</Modal.Body>
</Modal>
<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 =
gql`
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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8="
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="
},
"anymatch": {
"version": "3.1.2",
@ -2082,7 +2082,7 @@
"buffer-equal-constant-time": {
"version": "1.0.1",
"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": {
"version": "2.0.0",
@ -3558,11 +3558,6 @@
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -3771,9 +3766,9 @@
"dev": true
},
"futoin-hkdf": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.4.2.tgz",
"integrity": "sha512-2BggwLEJOTfXzKq4Tl2bIT37p0IqqKkblH4e0cMp2sXTdmwg/ADBKMxvxaEytYYcgdxgng8+acsi3WgMVUl6CQ=="
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.1.tgz",
"integrity": "sha512-g5d0Qp7ks55hYmYmfqn4Nz18XH49lcCR+vvIvHT92xXnsJaGZmY1EtWQWilJ6BQp57heCIXM/rRo+AFep8hGgg=="
},
"get-caller-file": {
"version": "2.0.5",
@ -3946,21 +3941,6 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
@ -4811,22 +4791,22 @@
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8="
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"lodash.isboolean": {
"version": "3.0.3",
"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": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M="
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"lodash.isnumber": {
"version": "3.0.3",
"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": {
"version": "4.0.6",
@ -4836,7 +4816,7 @@
"lodash.isstring": {
"version": "4.0.1",
"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": {
"version": "4.1.2",
@ -4853,7 +4833,7 @@
"lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"lodash.sortby": {
"version": "4.7.0",
@ -6086,9 +6066,9 @@
}
},
"next-auth": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-3.29.0.tgz",
"integrity": "sha512-B//4QTv/1Of0D+roZ82URmI6L2JSbkKgeaKI7Mdrioq8lAzp9ff8NdmouvZL/7zwrPe2cUyM6MLYlasfuI3ZIQ==",
"version": "3.29.3",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-3.29.3.tgz",
"integrity": "sha512-OoG5y8oFV7MWF2VVs20AfdF41ndoXtPBFIlLfCHbrvFWHfPGsjnyAnhDxyJZX91Taknd4MD3zrCGOlBJKrLU7A==",
"requires": {
"@babel/runtime": "^7.14.0",
"@next-auth/prisma-legacy-adapter": "0.1.2",
@ -6302,9 +6282,9 @@
"integrity": "sha1-VWD8abweqQ46THhB8jPSAHU3hQM="
},
"nodemailer": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.6.5.tgz",
"integrity": "sha512-C/v856DBijUzHcHIgGpQoTrfsH3suKIRAGliIzCstatM2cAa+MYX3LuyCrABiO/cdJTxgBBHXxV1ztiqUwst5A=="
"version": "6.7.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.5.tgz",
"integrity": "sha512-6VtMpwhsrixq1HDYSBBHvW0GwiWawE75dS3oal48VqRhUvKJNnKnJo2RI/bCVQubj1vgrgscMNW4DHaD6xtMCg=="
},
"nofilter": {
"version": "3.0.3",
@ -6355,7 +6335,7 @@
"oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="
},
"object-assign": {
"version": "4.1.1",
@ -6615,11 +6595,6 @@
"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": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz",
@ -7097,14 +7072,14 @@
}
},
"preact": {
"version": "10.5.14",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.5.14.tgz",
"integrity": "sha512-KojoltCrshZ099ksUZ2OQKfbH66uquFoxHSbnwKbTJHeQNvx42EmC7wQVWNuDt6vC5s3nudRHFtKbpY4ijKlaQ=="
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.7.3.tgz",
"integrity": "sha512-giqJXP8VbtA1tyGa3f1n9wiN7PrHtONrDyE3T+ifjr/tTkg+2N4d/6sjC9WyJKv8wM7rOYDveqy5ZoFmYlwo4w=="
},
"preact-render-to-string": {
"version": "5.1.19",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.1.19.tgz",
"integrity": "sha512-bj8sn/oytIKO6RtOGSS/1+5CrQyRSC99eLUnEVbqUa6MzJX5dYh7wu9bmT0d6lm/Vea21k9KhCQwvr2sYN3rrQ==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.0.tgz",
"integrity": "sha512-+RGwSW78Cl+NsZRUbFW1MGB++didsfqRk+IyRVTaqy+3OjtpKK/6HgBtfszUX0YXMfo41k2iaQSseAHGKEwrbg==",
"requires": {
"pretty-format": "^3.8.0"
}
@ -7123,7 +7098,7 @@
"pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha1-v77VbV6ad2ZF9LH/eqGjrE+jw4U="
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
},
"prisma": {
"version": "2.25.0",
@ -7800,7 +7775,7 @@
"resolve-from": {
"version": "2.0.0",
"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": {
"version": "5.7.1",
@ -9148,9 +9123,9 @@
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
},
"typeorm": {
"version": "0.2.37",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.37.tgz",
"integrity": "sha512-7rkW0yCgFC24I5T0f3S/twmLSuccPh1SQmxET/oDWn2sSDVzbyWdnItSdKy27CdJGTlKHYtUVeOcMYw5LRsXVw==",
"version": "0.2.45",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.45.tgz",
"integrity": "sha512-c0rCO8VMJ3ER7JQ73xfk0zDnVv0WDjpsP6Q1m6CVKul7DB9iVdWLRjPzc8v2eaeBuomsbZ2+gTaYr8k1gm3bYA==",
"requires": {
"@sqltools/formatter": "^1.2.2",
"app-root-path": "^3.0.0",
@ -9165,8 +9140,8 @@
"reflect-metadata": "^0.1.13",
"sha.js": "^2.4.11",
"tslib": "^2.1.0",
"uuid": "^8.3.2",
"xml2js": "^0.4.23",
"yargonaut": "^1.1.4",
"yargs": "^17.0.1",
"zen-observable-ts": "^1.0.0"
},
@ -9237,18 +9212,23 @@
}
},
"yargs": {
"version": "17.2.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.2.1.tgz",
"integrity": "sha512-XfR8du6ua4K6uLGm5S6fA+FIJom/MdJcFNVY8geLlp2v8GYbOXD4EB1tPNZsRn4vBzKGMgb5DRZMeWuFc2GO8Q==",
"version": "17.5.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz",
"integrity": "sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==",
"requires": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"string-width": "^4.2.3",
"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",
"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": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",

View File

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

View File

@ -1,8 +1,9 @@
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
import Adapters from 'next-auth/adapters'
import { PrismaLegacyAdapter } from '../../../lib/prisma-adapter'
import prisma from '../../../api/models'
import nodemailer from 'nodemailer'
import { getSession } from 'next-auth/client'
export default (req, res) => NextAuth(req, res, options)
@ -19,7 +20,10 @@ const options = {
async jwt (token, user, account, profile, isNewUser) {
// Add additional session params
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
@ -44,7 +48,7 @@ const options = {
},
async session (session, token) {
// we need to add additional session params here
session.user.id = token.id
session.user.id = Number(token.id)
return session
}
},
@ -65,9 +69,18 @@ const options = {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
if (lnauth.pubkey === pubkey) {
let user = await prisma.user.findUnique({ where: { pubkey } })
const session = await getSession({ req })
if (!user) {
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), pubkey } })
// 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 } })
}
} else if (session && session.user?.id !== user.id) {
throw new Error('account not linked')
}
await prisma.lnAuth.delete({ where: { k1 } })
return user
}
@ -108,7 +121,7 @@ const options = {
}
})
],
adapter: Adapters.Prisma.Adapter({ prisma }),
adapter: PrismaLegacyAdapter({ prisma }),
secret: process.env.NEXTAUTH_SECRET,
session: { jwt: true },
jwt: {

View File

@ -1,21 +1,31 @@
import { Checkbox, Form, Input, SubmitButton } from '../components/form'
import * as Yup from 'yup'
import { Alert, Button, InputGroup } from 'react-bootstrap'
import { useMe } from '../components/me'
import { Alert, Button, InputGroup, Modal } from 'react-bootstrap'
import LayoutCenter from '../components/layout-center'
import { useState } from 'react'
import { gql, useMutation } from '@apollo/client'
import { gql, useMutation, useQuery } from '@apollo/client'
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({
tipDefault: Yup.number().typeError('must be a number').required('required')
.positive('must be positive').integer('must be whole')
})
export default function Settings () {
const me = useMe()
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
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 [setSettings] = useMutation(
gql`
@ -29,75 +39,260 @@ export default function Settings () {
}`
)
const { data } = useQuery(SETTINGS)
if (data) {
({ settings } = data)
}
return (
<LayoutCenter>
<h2 className='mb-5 text-left'>settings</h2>
<Form
initial={{
tipDefault: me?.tipDefault || 21,
noteItemSats: me?.noteItemSats,
noteEarning: me?.noteEarning,
noteAllDescendants: me?.noteAllDescendants,
noteMentions: me?.noteMentions,
noteDeposits: me?.noteDeposits,
noteInvites: me?.noteInvites,
noteJobIndicator: me?.noteJobIndicator
}}
schema={SettingsSchema}
onSubmit={async ({ tipDefault, ...values }) => {
await setSettings({ variables: { tipDefault: Number(tipDefault), ...values } })
setSuccess('settings saved')
}}
>
{success && <Alert variant='info' onClose={() => setSuccess(undefined)} dismissible>{success}</Alert>}
<Input
label='tip default'
name='tipDefault'
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div className='form-label'>notify me when ...</div>
<Checkbox
label='I stack sats from posts and comments'
name='noteItemSats'
groupClassName='mb-0'
/>
<Checkbox
label='I get a daily airdrop'
name='noteEarning'
groupClassName='mb-0'
/>
<Checkbox
label='someone replies to someone who replied to me'
name='noteAllDescendants'
groupClassName='mb-0'
/>
<Checkbox
label='my invite links are redeemed'
name='noteInvites'
groupClassName='mb-0'
/>
<Checkbox
label='sats are deposited in my account'
name='noteDeposits'
groupClassName='mb-0'
/>
<Checkbox
label='someone mentions me'
name='noteMentions'
groupClassName='mb-0'
/>
<Checkbox
label='there is a new job'
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'>
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
<div className='py-3 w-100'>
<h2 className='mb-2 text-left'>settings</h2>
<Form
initial={{
tipDefault: settings?.tipDefault || 21,
noteItemSats: settings?.noteItemSats,
noteEarning: settings?.noteEarning,
noteAllDescendants: settings?.noteAllDescendants,
noteMentions: settings?.noteMentions,
noteDeposits: settings?.noteDeposits,
noteInvites: settings?.noteInvites,
noteJobIndicator: settings?.noteJobIndicator
}}
schema={SettingsSchema}
onSubmit={async ({ tipDefault, ...values }) => {
await setSettings({ variables: { tipDefault: Number(tipDefault), ...values } })
setSuccess('settings saved')
}}
>
{success && <Alert variant='info' onClose={() => setSuccess(undefined)} dismissible>{success}</Alert>}
<Input
label='tip default'
name='tipDefault'
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div className='form-label'>notify me when ...</div>
<Checkbox
label='I stack sats from posts and comments'
name='noteItemSats'
groupClassName='mb-0'
/>
<Checkbox
label='I get a daily airdrop'
name='noteEarning'
groupClassName='mb-0'
/>
<Checkbox
label='someone replies to someone who replied to me'
name='noteAllDescendants'
groupClassName='mb-0'
/>
<Checkbox
label='my invite links are redeemed'
name='noteInvites'
groupClassName='mb-0'
/>
<Checkbox
label='sats are deposited in my account'
name='noteDeposits'
groupClassName='mb-0'
/>
<Checkbox
label='someone mentions me'
name='noteMentions'
groupClassName='mb-0'
/>
<Checkbox
label='there is a new job'
name='noteJobIndicator'
/>
<div className='d-flex'>
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
</div>
</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>
</Form>
</div>
</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>
)
}