diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..972944bd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "search.useIgnoreFiles": true +} \ No newline at end of file diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 595ee8d6..10602964 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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 } }) }, diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 32fc324f..1214eb57 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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! } ` diff --git a/components/lightning-auth.js b/components/lightning-auth.js new file mode 100644 index 00000000..734e6ce6 --- /dev/null +++ b/components/lightning-auth.js @@ -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 ( + <> + + Does my wallet support lnurl-auth? + + + + ) +} + +export function LightningAuth ({ callbackUrl }) { + // query for challenge + const [createAuth, { data, error }] = useMutation(gql` + mutation createAuth { + createAuth { + k1 + encodedUrl + } + }`) + + useEffect(createAuth, []) + + if (error) return
error
+ + if (!data) { + return + } + + return +} diff --git a/components/login-button.js b/components/login-button.js new file mode 100644 index 00000000..88a77cc4 --- /dev/null +++ b/components/login-button.js @@ -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 ( + + ) +} diff --git a/components/login.js b/components/login.js index 6244c817..cbae1f64 100644 --- a/components/login.js +++ b/components/login.js @@ -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 ( +
{ + signIn('email', { email, callbackUrl }) + }} + > + + Login with Email +
+ ) +} + 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 }) { ) })}
or
-
{ - signIn('email', { email, callbackUrl }) - }} - > - - Login with Email -
+ )} ) } - -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 ( - <> - - Does my wallet support lnurl-auth? - - - - ) -} - -export function LightningAuth ({ callbackUrl }) { - // query for challenge - const [createAuth, { data, error }] = useMutation(gql` - mutation createAuth { - createAuth { - k1 - encodedUrl - } - }`) - - useEffect(createAuth, []) - - if (error) return
error
- - if (!data) { - return - } - - return -} diff --git a/components/modal-button.js b/components/modal-button.js index 9b71c77b..cb06457d 100644 --- a/components/modal-button.js +++ b/components/modal-button.js @@ -12,7 +12,7 @@ export default function ModalButton ({ children, clicker }) { >
setShow(false)}>X
- {children} + {show && children}
setShow(true)}>{clicker}
diff --git a/fragments/users.js b/fragments/users.js index 1b9fcb61..e0301264 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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!) { diff --git a/lib/prisma-adapter.js b/lib/prisma-adapter.js new file mode 100644 index 00000000..442533f3 --- /dev/null +++ b/lib/prisma-adapter.js @@ -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) + } + }) + } + + } + } + + } +} diff --git a/package-lock.json b/package-lock.json index e0f1df6f..9e6a558e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index de7c6083..53ae7bb5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index d5ae4509..e104714a 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -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: { diff --git a/pages/settings.js b/pages/settings.js index 831c57d6..7105beae 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -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 ( -

settings

-
{ - await setSettings({ variables: { tipDefault: Number(tipDefault), ...values } }) - setSuccess('settings saved') - }} - > - {success && setSuccess(undefined)} dismissible>{success}} - sats} - /> -
notify me when ...
- - - - - - - -
saturday newsletter
- -
- save +
+

settings

+ { + await setSettings({ variables: { tipDefault: Number(tipDefault), ...values } }) + setSuccess('settings saved') + }} + > + {success && setSuccess(undefined)} dismissible>{success}} + sats} + /> +
notify me when ...
+ + + + + + + +
+ save +
+ +
+
saturday newsletter
+ + {settings?.authMethods && }
- +
) } + +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 ( + <> + setObstacle(null)} + > +
setObstacle(null)}>X
+ + 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 +
+ If I logout, even accidentally, I will never be able to access my account again +
+
{ + await unlinkAuth({ variables: { authType: obstacle } }) + router.push('/settings') + setObstacle(null) + }} + > + + do it +
+
+
+
auth methods
+ {methods.lightning + ? { + await unlink('lightning') + } + } + /> + : ( + }> +
+ +
+
)} + { + if (methods.twitter) { + await unlink('twitter') + } else { + signIn('twitter') + } + } + } + /> + { + if (methods.github) { + await unlink('github') + } else { + signIn('github') + } + } + } + /> + {methods.email + ? ( +
+ + +
+ ) + :
} + + ) +} + +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 ( +
{ + // add email to user's account + // then call signIn + const { data } = await linkUnverifiedEmail({ variables: { email } }) + if (data.linkUnverifiedEmail) { + signIn('email', { email, callbackUrl }) + } + }} + > +
+ + Link Email +
+
+ ) +}