upgrade to next-auth 4 (bonus: improve error pages)

This commit is contained in:
keyan 2023-07-29 14:38:20 -05:00
parent de089aa429
commit 5232b59625
35 changed files with 643 additions and 1374 deletions

View File

@ -847,7 +847,7 @@ export default {
return await models.user.findUnique({ where: { id: item.fwdUserId } })
},
comments: async (item, { sort }, { me, models }) => {
if (item.comments) return item.comments
if (typeof item.comments !== 'undefined') return item.comments
if (item.ncomments === 0) return []
return comments(me, models, item.id, sort || defaultCommentSort(item.pinId, item.bioId, item.createdAt))

View File

@ -73,7 +73,7 @@ async function authMethods (user, args, { models, me }) {
}
})
const oauth = accounts.map(a => a.providerId)
const oauth = accounts.map(a => a.provider)
return {
lightning: !!user.pubkey,
@ -87,7 +87,7 @@ async function authMethods (user, args, { models, me }) {
export default {
Query: {
me: async (parent, { skipUpdate }, { models, me }) => {
if (!me) {
if (!me?.id) {
return null
}
@ -518,7 +518,7 @@ export default {
let user
if (authType === 'twitter' || authType === 'github') {
user = await models.user.findUnique({ where: { id: me.id } })
const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } })
const account = await models.account.findFirst({ where: { userId: me.id, provider: authType } })
if (!account) {
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
}

View File

@ -1,7 +1,6 @@
import { ApolloClient, InMemoryCache } from '@apollo/client'
import { SchemaLink } from '@apollo/client/link/schema'
import { makeExecutableSchema } from '@graphql-tools/schema'
import { getSession } from 'next-auth/client'
import resolvers from './resolvers'
import typeDefs from './typeDefs'
import models from './models'
@ -11,9 +10,11 @@ import lnd from './lnd'
import search from './search'
import { ME } from '../fragments/users'
import { PRICE } from '../fragments/price'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '../pages/api/auth/[...nextauth]'
export default async function getSSRApolloClient (req, me = null) {
const session = req && await getSession({ req })
export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req))
const client = new ApolloClient({
ssrMode: true,
link: new SchemaLink({
@ -54,7 +55,7 @@ export default async function getSSRApolloClient (req, me = null) {
}
export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notFoundFunc, requireVar) {
return async function ({ req, query: params }) {
return async function ({ req, res, query: params }) {
const { nodata, ...realParams } = params
// we want to use client-side cache
if (nodata) return { props: { } }
@ -63,7 +64,7 @@ export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notF
const vars = { ...realParams, ...variables }
const query = typeof queryOrFunc === 'function' ? queryOrFunc(vars) : queryOrFunc
const client = await getSSRApolloClient(req)
const client = await getSSRApolloClient({ req, res })
const { data: { me } } = await client.query({
query: ME,

View File

@ -1,6 +1,6 @@
import { Component } from 'react'
import { StaticLayout } from './layout'
import styles from '../styles/404.module.css'
import styles from '../styles/error.module.css'
import Image from 'react-bootstrap/Image'
class ErrorBoundary extends Component {
@ -27,8 +27,8 @@ class ErrorBoundary extends Component {
// You can render any custom fallback UI
return (
<StaticLayout>
<Image width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
<h1 className={styles.fourZeroFour} style={{ fontSize: '48px' }}>something went wrong</h1>
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
<h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1>
</StaticLayout>
)
}

View File

@ -9,7 +9,7 @@ import NavDropdown from 'react-bootstrap/NavDropdown'
import Price from './price'
import { useMe } from './me'
import Head from 'next/head'
import { signOut } from 'next-auth/client'
import { signOut } from 'next-auth/react'
import { useCallback, useEffect, useState } from 'react'
import { randInRange } from '../lib/rand'
import { abbrNum } from '../lib/format'

View File

@ -1,5 +1,5 @@
import { gql, useMutation, useQuery } from '@apollo/client'
import { signIn } from 'next-auth/client'
import { signIn } from 'next-auth/react'
import { useEffect } from 'react'
import Col from 'react-bootstrap/Col'
import Container from 'react-bootstrap/Container'
@ -20,9 +20,11 @@ function QrAuth ({ k1, encodedUrl, slashtagUrl, callbackUrl }) {
}`
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
if (data && data.lnAuth.pubkey) {
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })
}
useEffect(() => {
if (data?.lnAuth?.pubkey) {
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })
}
}, [data?.lnAuth])
// output pubkey and k1
return (

View File

@ -1,4 +1,4 @@
import { signIn } from 'next-auth/client'
import { signIn } from 'next-auth/react'
import styles from './login.module.css'
import { Form, Input, SubmitButton } from '../components/form'
import { useState } from 'react'
@ -33,16 +33,15 @@ export function EmailLoginForm ({ text, callbackUrl }) {
export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) {
const errors = {
Signin: 'Try signing with a different account.',
OAuthSignin: 'Try signing with a different account.',
OAuthCallback: 'Try signing with a different account.',
OAuthCreateAccount: 'Try signing with a different account.',
EmailCreateAccount: 'Try signing with a different account.',
Callback: 'Try signing with a different account.',
OAuthAccountNotLinked: 'To confirm your identity, sign in with the same account you used originally.',
EmailSignin: 'Check your email address.',
CredentialsSignin: 'Auth failed',
default: 'Unable to sign in.'
OAuthSignin: 'Error constructing OAuth URL. Try again or choose a different method.',
OAuthCallback: 'Error handling OAuth response. Try again or choose a different method.',
OAuthCreateAccount: 'Could not create OAuth account. Try again or choose a different method.',
EmailCreateAccount: 'Could not create Email account. Try again or choose a different method.',
Callback: 'Error in callback handler. Try again or choose a different method.',
OAuthAccountNotLinked: 'This auth method is linked to another account. To link to this account first unlink the other account.',
EmailSignin: 'Failed to send email. Make sure you entered your email address correctly.',
CredentialsSignin: 'Auth failed. Try again or choose a different method.',
default: 'Auth failed. Try again or choose a different method.'
}
const [errorMessage, setErrorMessage] = useState(error && (errors[error] ?? errors.default))
@ -70,7 +69,7 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
switch (provider.name) {
case 'Email':
return (
<div className='w-100' key={provider.name}>
<div className='w-100' key={provider.id}>
<div className='mt-2 text-center text-muted fw-bold'>or</div>
<EmailLoginForm text={text} callbackUrl={callbackUrl} />
</div>
@ -80,8 +79,8 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
return (
<LoginButton
className={`mt-2 ${styles.providerButton}`}
key={provider.name}
type={provider.name.toLowerCase()}
key={provider.id}
type={provider.id.toLowerCase()}
onClick={() => router.push({
pathname: router.pathname,
query: { callbackUrl: router.query.callbackUrl, type: provider.name.toLowerCase() }
@ -93,8 +92,8 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
return (
<LoginButton
className={`mt-2 ${styles.providerButton}`}
key={provider.name}
type={provider.name.toLowerCase()}
key={provider.id}
type={provider.id.toLowerCase()}
onClick={() => signIn(provider.id, { callbackUrl })}
text={`${text || 'Login'} with`}
/>

View File

@ -126,7 +126,7 @@ function EarnNotification ({ n }) {
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
<div className='ms-2'>
<div className='fw-bold text-boost'>
you stacked {n.earnedSats} sats in rewards<small className='text-muted ms-1'>{timeSince(new Date(n.sortTime))}</small>
you stacked {n.earnedSats} sats in rewards<small className='text-muted ms-1 fw-normal'>{timeSince(new Date(n.sortTime))}</small>
</div>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
@ -166,7 +166,7 @@ function InvoicePaid ({ n }) {
<NotificationLayout href={`/invoices/${n.invoice.id}`}>
<div className='fw-bold text-info ms-2 py-1'>
<Check className='fill-info me-1' />{n.earnedSats} sats were deposited in your account
<small className='text-muted ms-1'>{timeSince(new Date(n.sortTime))}</small>
<small className='text-muted ms-1 fw-normal'>{timeSince(new Date(n.sortTime))}</small>
</div>
</NotificationLayout>
)
@ -177,7 +177,7 @@ function Referral ({ n }) {
<NotificationLayout>
<small className='fw-bold text-secondary ms-2'>
someone joined via one of your <Link href='/referrals/month' className='text-reset'>referral links</Link>
<small className='text-muted ms-1'>{timeSince(new Date(n.sortTime))}</small>
<small className='text-muted ms-1 fw-normal'>{timeSince(new Date(n.sortTime))}</small>
</small>
</NotificationLayout>
)

View File

@ -5,7 +5,7 @@ import { timeLeft } from '../lib/time'
import { useMe } from './me'
import styles from './poll.module.css'
import Check from '../svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/client'
import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip'
import { useShowModal } from './modal'
import FundError from './fund-error'

View File

@ -35,7 +35,7 @@ const UpvotePopover = ({ target, show, handleClose }) => {
>
<Popover id='popover-basic'>
<Popover.Body className='d-flex justify-content-between alert-dismissible' as='h3'>Zapping
<button type='button' className='close' onClick={handleClose}><span aria-hidden='true'>×</span><span className='sr-only'>Close alert</span></button>
<button type='button' className='close' onClick={handleClose}><span aria-hidden='true'>×</span><span className='visually-hidden-focusable'>Close alert</span></button>
</Popover.Body>
<Popover.Body>
<div className='mb-2'>Press the bolt again to zap {me?.tipDefault || 1} more sat{me?.tipDefault > 1 ? 's' : ''}.</div>
@ -54,7 +54,7 @@ const TipPopover = ({ target, show, handleClose }) => (
>
<Popover id='popover-basic'>
<Popover.Body className='d-flex justify-content-between alert-dismissible' as='h3'>Press and hold
<button type='button' className='close' onClick={handleClose}><span aria-hidden='true'>×</span><span className='sr-only'>Close alert</span></button>
<button type='button' className='close' onClick={handleClose}><span aria-hidden='true'>×</span><span className='visually-hidden-focusable'>Close alert</span></button>
</Popover.Body>
<Popover.Body>
<div className='mb-2'>Press and hold bolt to zap a custom amount.</div>

View File

@ -12,8 +12,12 @@ function isFirstPage (cursor, existingThings) {
}
}
const SSR = typeof window === 'undefined'
const defaultFetchPolicy = SSR ? 'cache-only' : 'cache-first'
const defaultNextFetchPolicy = SSR ? 'cache-only' : 'cache-first'
export default function getApolloClient () {
if (typeof window === 'undefined') {
if (SSR) {
return getClient(`${process.env.SELF_URL}/api/graphql`)
} else {
global.apolloClient ||= getClient('/api/graphql')
@ -24,7 +28,7 @@ export default function getApolloClient () {
function getClient (uri) {
return new ApolloClient({
link: new HttpLink({ uri }),
ssrMode: typeof window === 'undefined',
ssrMode: SSR,
cache: new InMemoryCache({
freezeResults: true,
typePolicies: {
@ -143,13 +147,13 @@ function getClient (uri) {
assumeImmutableResults: true,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-first',
fetchPolicy: defaultFetchPolicy,
nextFetchPolicy: defaultNextFetchPolicy,
canonizeResults: true
},
query: {
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-first',
fetchPolicy: defaultFetchPolicy,
nextFetchPolicy: defaultNextFetchPolicy,
canonizeResults: true
}
}

View File

@ -56,7 +56,7 @@ function generateRssFeed (items, sub = null) {
export default function getGetRssServerSideProps (query, variables = null) {
return async function ({ req, res, query: params }) {
const emptyProps = { props: {} } // to avoid server side warnings
const client = await getSSRApolloClient(req)
const client = await getSSRApolloClient({ req, res })
const { error, data: { items: { items } } } = await client.query({
query, variables: { ...params, ...variables }
})

1274
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"@apollo/client": "^3.7.17",
"@apollo/server": "^4.8.1",
"@as-integrations/next": "^2.0.1",
"@auth/prisma-adapter": "^1.0.1",
"@graphql-tools/schema": "^10.0.0",
"@noble/curves": "^1.1.0",
"@opensearch-project/opensearch": "^2.3.1",
@ -44,11 +45,12 @@
"mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm": "^3.0.0",
"next": "^13.4.12",
"next-auth": "^3.29.10",
"next-auth": "^4.22.3",
"next-plausible": "^3.10.1",
"next-seo": "^6.1.0",
"nextjs-progressbar": "0.0.16",
"node-s3-url-encode": "^0.0.4",
"nodemailer": "^6.9.4",
"nostr": "^0.2.8",
"opentimestamps": "^0.4.9",
"page-metadata-parser": "^1.1.4",
@ -79,6 +81,7 @@
"web-push": "^3.6.2",
"webln": "^0.3.2",
"webpack": "^5.88.2",
"workbox-navigation-preload": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-recipes": "^7.0.0",
"workbox-routing": "^7.0.0",

View File

@ -1,12 +1,5 @@
import Image from 'react-bootstrap/Image'
import { StaticLayout } from '../components/layout'
import styles from '../styles/404.module.css'
import Error from './_error'
export default function fourZeroFour () {
return (
<StaticLayout>
<Image width='500' height='376' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/maze.gif`} fluid />
<h1 className={styles.fourZeroFour}><span>404</span><span className={styles.notFound}>page not found</span></h1>
</StaticLayout>
)
export default function Custom404 () {
return <Error statusCode={404} />
}

View File

@ -1,12 +1,5 @@
import Image from 'react-bootstrap/Image'
import { StaticLayout } from '../components/layout'
import styles from '../styles/404.module.css'
import Error from './_error'
export default function fiveHundo () {
return (
<StaticLayout>
<Image width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/falling.gif`} fluid />
<h1 className={styles.fourZeroFour}><span>500</span><span className={styles.notFound}>server error</span></h1>
</StaticLayout>
)
export default function Custom500 () {
return <Error statusCode={500} />
}

View File

@ -1,6 +1,5 @@
import '../styles/globals.scss'
import { ApolloProvider, gql } from '@apollo/client'
import { Provider } from 'next-auth/client'
import { MeProvider } from '../components/me'
import PlausibleProvider from 'next-plausible'
import getApolloClient from '../lib/apollo'
@ -28,7 +27,7 @@ function writeQuery (client, apollo, data) {
}
}
function MyApp ({ Component, pageProps: { session, ...props } }) {
function MyApp ({ Component, pageProps: { ...props } }) {
const client = getApolloClient()
const router = useRouter()
@ -80,21 +79,19 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
</Head>
<ErrorBoundary>
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
<Provider session={session}>
<ApolloProvider client={client}>
<MeProvider me={me}>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ShowModalProvider>
<Component ssrData={ssrData} {...otherProps} />
</ShowModalProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</MeProvider>
</ApolloProvider>
</Provider>
<ApolloProvider client={client}>
<MeProvider me={me}>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ShowModalProvider>
<Component ssrData={ssrData} {...otherProps} />
</ShowModalProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</MeProvider>
</ApolloProvider>
</PlausibleProvider>
</ErrorBoundary>
</>

77
pages/_error.js Normal file
View File

@ -0,0 +1,77 @@
import Image from 'react-bootstrap/Image'
import { StaticLayout } from '../components/layout'
import styles from '../styles/error.module.css'
const statusDescribe = {
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non-Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
300: 'Multiple Choices',
301: 'Moved Permanently',
302: 'Found',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
306: 'Unused',
307: 'Temporary Redirect',
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Required',
413: 'Request Entry Too Large',
414: 'Request-URI Too Long',
415: 'Unsupported Media Type',
416: 'Requested Range Not Satisfiable',
417: 'Expectation Failed',
418: 'I\'m a teapot',
429: 'Too Many Requests',
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
505: 'HTTP Version Not Supported'
}
function ErrorImage ({ statusCode }) {
if (statusCode === 404) {
return <Image className='rounded-1 shadow-sm' width='500' height='376' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/maze.gif`} fluid />
}
if (statusCode >= 500) {
return <Image className='rounded-1 shadow-sm' width='300' height='225' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/falling.gif`} fluid />
}
if (statusCode >= 400) {
return <Image className='rounded-1 shadow-sm' width='500' height='381' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/forbidden.gif`} fluid />
}
if (statusCode >= 300) {
return <Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
}
return <Image className='rounded-1 shadow-sm' width='500' height='376' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/falling.gif`} fluid />
}
export default function Error ({ statusCode }) {
return (
<StaticLayout>
<ErrorImage statusCode={statusCode} />
<h1 className={styles.status}><span>{statusCode}</span><span className={styles.describe}>{statusDescribe[statusCode].toUpperCase()}</span></h1>
</StaticLayout>
)
}
Error.getInitialProps = ({ res, err }) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404
return { statusCode }
}

View File

@ -1,40 +1,16 @@
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
import { PrismaLegacyAdapter } from '../../../lib/prisma-adapter'
import CredentialsProvider from 'next-auth/providers/credentials'
import GitHubProvider from 'next-auth/providers/github'
import TwitterProvider from 'next-auth/providers/twitter'
import EmailProvider from 'next-auth/providers/email'
import prisma from '../../../api/models'
import nodemailer from 'nodemailer'
import { getSession } from 'next-auth/client'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { getToken } from 'next-auth/jwt'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
// node v16 and next don't give us parsed headers like v14, so we
// do our best to parse them in a similar fashion to like in v14
// getSession requires req.headers.cookie otherwise it will throw
function parsedHeaders (req) {
return req.rawHeaders.reduce(
(obj, value, index, arr) => {
if (index % 2 === 0) {
const key = value.toLowerCase()
if (typeof obj[key] === 'string') {
if (key === 'cookie') {
obj[key] = obj[key] + '; ' + arr[index + 1]
} else if (key === 'set-cookie') {
obj[key] = obj[key].push(arr[index + 1])
} else {
obj[key] = obj[key] + ', ' + arr[index + 1]
}
} else {
if (key === 'set-cookie') {
obj[key] = [arr[index + 1]]
} else {
obj[key] = arr[index + 1]
}
}
}
return obj
}, {})
}
export default (req, res) => NextAuth(req, res, {
callbacks: {
function getCallbacks (req) {
return {
/**
* @param {object} token Decrypted JSON Web Token
* @param {object} user User object (only available on sign in)
@ -43,13 +19,18 @@ export default (req, res) => NextAuth(req, res, {
* @param {boolean} isNewUser True if new user (only available on sign in)
* @return {object} JSON Web Token that will be saved
*/
async jwt (token, user, account, profile, isNewUser) {
// Add additional session params
if (user?.id) {
async jwt ({ token, user, account, profile, isNewUser }) {
if (user) {
// token won't have an id on it for new logins, we add it
// note: token is what's kept in the jwt
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) }
}
if (token?.id) {
// HACK token.sub is used by nextjs v4 internally and is used like a userId
// setting it here allows us to link multiple auth method to an account
// ... in v3 this linking field was token.user.id
token.sub = Number(token.id)
}
if (isNewUser) {
@ -82,149 +63,123 @@ export default (req, res) => NextAuth(req, res, {
return token
},
async session (session, token) {
// we need to add additional session params here
session.user.id = Number(token.id)
async session ({ session, token }) {
// note: this function takes the current token (result of running jwt above)
// and returns a new object session that's returned whenever get|use[Server]Session is called
session.user.id = token.id
return session
}
},
providers: [
Providers.Credentials({
id: 'lightning',
// The name to display on the sign in form (e.g. 'Sign in with...')
name: 'Lightning',
// The credentials is used to generate a suitable form on the sign in page.
// You can specify whatever fields you are expecting to be submitted.
// e.g. domain, username, password, 2FA token, etc.
credentials: {
pubkey: { label: 'publickey', type: 'text' },
k1: { label: 'k1', type: 'text' }
},
async authorize (credentials, req) {
const { k1, pubkey } = credentials
try {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
await prisma.lnAuth.delete({ where: { k1 } })
if (lnauth.pubkey === pubkey) {
let user = await prisma.user.findUnique({ where: { pubkey } })
req.headers = parsedHeaders(req)
const session = await getSession({ req })
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 } })
}
} else if (session && session.user?.id !== user.id) {
throw new Error('account not linked')
}
}
}
return user
}
} catch (error) {
console.log(error)
async function pubkeyAuth (credentials, req, pubkeyColumnName) {
const { k1, pubkey } = credentials
try {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
await prisma.lnAuth.delete({ where: { k1 } })
if (lnauth.pubkey === pubkey) {
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
const token = await getToken({ req })
if (!user) {
// if we are logged in, update rather than create
if (token?.id) {
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
} else {
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
}
} else if (token && token?.id !== user.id) {
return null
}
}),
Providers.Credentials({
id: 'slashtags',
// The name to display on the sign in form (e.g. 'Sign in with...')
name: 'Slashtags',
// The credentials is used to generate a suitable form on the sign in page.
// You can specify whatever fields you are expecting to be submitted.
// e.g. domain, username, password, 2FA token, etc.
credentials: {
pubkey: { label: 'publickey', type: 'text' },
k1: { label: 'k1', type: 'text' }
},
async authorize (credentials, req) {
const { k1, pubkey } = credentials
try {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
await prisma.lnAuth.delete({ where: { k1 } })
if (lnauth.pubkey === pubkey) {
let user = await prisma.user.findUnique({ where: { slashtagId: pubkey } })
req.headers = parsedHeaders(req)
const session = await getSession({ req })
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: { slashtagId: pubkey } })
} else {
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), slashtagId: pubkey } })
}
} else if (session && session.user?.id !== user.id) {
throw new Error('account not linked')
}
return user
}
} catch (error) {
console.log(error)
}
return user
}
} catch (error) {
console.log(error)
}
return null
return null
}
const providers = [
CredentialsProvider({
id: 'lightning',
name: 'Lightning',
credentials: {
pubkey: { label: 'publickey', type: 'text' },
k1: { label: 'k1', type: 'text' }
},
authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey')
}),
CredentialsProvider({
id: 'slashtags',
name: 'Slashtags',
credentials: {
pubkey: { label: 'publickey', type: 'text' },
k1: { label: 'k1', type: 'text' }
},
authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'slashtagId')
}),
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
authorization: {
url: 'https://github.com/login/oauth/authorize',
params: { scope: '' }
},
profile: profile => {
return {
...profile,
name: profile.login
}
}),
Providers.GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
authorization: 'https://github.com/login/oauth/authorize',
scope: '', // read-only acces to public information
profile: profile => {
return {
...profile,
name: profile.login
}
}
}),
TwitterProvider({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
profile: profile => {
return {
...profile,
name: profile.screen_name
}
}),
Providers.Twitter({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
profile: profile => {
return {
...profile,
name: profile.screen_name
}
}
}),
Providers.Email({
server: process.env.LOGIN_EMAIL_SERVER,
from: process.env.LOGIN_EMAIL_FROM,
sendVerificationRequest,
profile: profile => {
return profile
}
})
],
adapter: PrismaLegacyAdapter({ prisma }),
secret: process.env.NEXTAUTH_SECRET,
session: { jwt: true },
jwt: {
signingKey: process.env.JWT_SIGNING_PRIVATE_KEY
}
}),
EmailProvider({
server: process.env.LOGIN_EMAIL_SERVER,
from: process.env.LOGIN_EMAIL_FROM,
sendVerificationRequest
})
]
export const getAuthOptions = req => ({
callbacks: getCallbacks(req),
providers,
adapter: PrismaAdapter(prisma),
session: {
strategy: 'jwt'
},
pages: {
signIn: '/login',
verifyRequest: '/email'
verifyRequest: '/email',
error: '/auth/error'
}
})
export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req))
}
async function sendVerificationRequest ({
identifier: email,
url,
token,
baseUrl,
provider
}) {
const user = await prisma.user.findUnique({ where: { email } })
return new Promise((resolve, reject) => {
const { server, from } = provider
// Strip protocol from URL and use domain as site name
const site = baseUrl.replace(/^https?:\/\//, '')
const site = new URL(url).host
nodemailer.createTransport(server).sendMail(
{

View File

@ -4,7 +4,8 @@ import resolvers from '../../api/resolvers'
import models from '../../api/models'
import lnd from '../../api/lnd'
import typeDefs from '../../api/typeDefs'
import { getSession } from 'next-auth/client'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from './auth/[...nextauth]'
import search from '../../api/search'
import slashtags from '../../api/slashtags'
@ -21,7 +22,7 @@ const apolloServer = new ApolloServer({
return (error, result) => {
const end = process.hrtime.bigint()
const ms = (end - start) / 1000000n
if (ms > 20 && info.parentType.name !== 'User') {
if (ms > 5) {
console.log(`Field ${info.parentType.name}.${info.fieldName} took ${ms}ms`)
}
if (error) {
@ -42,8 +43,8 @@ const apolloServer = new ApolloServer({
})
export default startServerAndCreateNextHandler(apolloServer, {
context: async (req) => {
const session = await getSession({ req })
context: async (req, res) => {
const session = await getServerSession(req, res, getAuthOptions(req))
return {
models,
lnd,

View File

@ -61,7 +61,7 @@ async function doWithdrawal (query, res) {
}
// create withdrawal in gql
const client = await getSSRApolloClient(null, me)
const client = await getSSRApolloClient({ me })
const { error, data } = await client.mutate({
mutation: CREATE_WITHDRAWL,
variables: { invoice: query.pr, maxFee: 10 }

View File

@ -3,7 +3,7 @@ import { ITEM_OTS } from '../../../../fragments/items'
import stringifyCanon from 'canonical-json'
export default async function handler (req, res) {
const client = await getSSRApolloClient(req)
const client = await getSSRApolloClient({ req, res })
const { data } = await client.query({
query: ITEM_OTS,
variables: { id: req.query.id }

63
pages/auth/error.js Normal file
View File

@ -0,0 +1,63 @@
import Image from 'react-bootstrap/Image'
import { StaticLayout } from '../../components/layout'
import styles from '../../styles/error.module.css'
import LightningIcon from '../../svgs/bolt.svg'
import { useRouter } from 'next/router'
import Button from 'react-bootstrap/Button'
export function getServerSideProps ({ query }) {
return {
props: {
error: query.error
}
}
}
export default function AuthError ({ error }) {
const router = useRouter()
if (error === 'AccessDenied') {
return (
<StaticLayout>
<Image className='rounded-1 shadow-sm' width='500' height='381' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/forbidden.gif`} fluid />
<h1 className={[styles.status, styles.smaller].join(' ')}><span>ACCESS DENIED</span></h1>
</StaticLayout>
)
} else if (error === 'Verification') {
return (
<StaticLayout>
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
<h2 className='pt-4'>This magic link has expired.</h2>
<h4 className='text-muted pt-2'>Get another by logging in.</h4>
<Button
className='align-items-center my-3'
style={{ borderWidth: '2px' }}
id='login'
onClick={() => router.push('/login')}
size='lg'
>
<LightningIcon
width={24}
height={24}
className='me-2'
/>login
</Button>
</StaticLayout>
)
} else if (error === 'Configuration') {
return (
<StaticLayout>
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
<h1 className={[styles.status, styles.smaller].join(' ')}><span>configuration error</span></h1>
</StaticLayout>
)
}
return (
<StaticLayout>
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
<h1 className={[styles.status, styles.smaller].join(' ')}><span>auth error</span></h1>
</StaticLayout>
)
}

View File

@ -5,9 +5,9 @@ export default function Email () {
return (
<StaticLayout>
<div className='p-4 text-center'>
<h1>Check your email</h1>
<h4 className='pb-4'>A sign in link has been sent to your email address</h4>
<Image width='500' height='376' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/hello.gif`} fluid />
<Image className='rounded-1 shadow-sm' width='320' height='223' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/hello.gif`} fluid />
<h2 className='pt-4'>Check your email</h2>
<h4 className='text-muted pt-2'>A sign in link has been sent to your email address</h4>
</div>
</StaticLayout>
)

View File

@ -1,5 +1,6 @@
import Login from '../../components/login'
import { providers, getSession } from 'next-auth/client'
import { getProviders } from 'next-auth/react'
import { getServerSession } from 'next-auth/next'
import models from '../../api/models'
import serialize from '../../api/resolvers/serial'
import { gql } from '@apollo/client'
@ -7,11 +8,12 @@ import { INVITE_FIELDS } from '../../fragments/invites'
import getSSRApolloClient from '../../api/ssrApollo'
import Link from 'next/link'
import { CenterLayout } from '../../components/layout'
import { authOptions } from '../api/auth/[...nextauth]'
export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
const session = await getSession({ req })
const session = await getServerSession(req, res, authOptions(req))
const client = await getSSRApolloClient(req)
const client = await getSSRApolloClient({ req, res })
const { data } = await client.query({
query: gql`
${INVITE_FIELDS}
@ -38,16 +40,17 @@ export async function getServerSideProps ({ req, res, query: { id, error = null
console.log(e)
}
res.writeHead(302, {
Location: '/'
})
res.end()
return { props: {} }
return {
redirect: {
destination: '/',
permanent: false
}
}
}
return {
props: {
providers: await providers({ req, res }),
providers: await getProviders(),
callbackUrl: process.env.PUBLIC_URL + req.url,
invite: data.invite,
error

View File

@ -1,11 +1,13 @@
import { providers, getSession } from 'next-auth/client'
import { getProviders } from 'next-auth/react'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from './api/auth/[...nextauth]'
import Link from 'next/link'
import { StaticLayout } from '../components/layout'
import Login from '../components/login'
import { isExternal } from '../lib/url'
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
const session = await getSession({ req })
const session = await getServerSession(req, res, getAuthOptions(req))
// prevent open redirects. See https://github.com/stackernews/stacker.news/issues/264
// let undefined urls through without redirect ... otherwise this interferes with multiple auth linking
@ -20,17 +22,18 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
callbackUrl = '/'
}
if (session && res && callbackUrl) {
res.writeHead(302, {
Location: callbackUrl
})
res.end()
return { props: {} }
if (session && callbackUrl) {
return {
redirect: {
destination: callbackUrl,
permanent: false
}
}
}
return {
props: {
providers: await providers({ req, res }),
providers: await getProviders(),
callbackUrl,
error
}

View File

@ -7,7 +7,7 @@ import { useState } from 'react'
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 { signIn } from 'next-auth/react'
import { LightningAuth, SlashtagsAuth } from '../components/lightning-auth'
import { SETTINGS, SET_SETTINGS } from '../fragments/users'
import { useRouter } from 'next/router'
@ -359,7 +359,7 @@ function AuthMethods ({ methods }) {
// if there's only one auth method left
const links = providers.reduce((t, p) => t + (methods[p] ? 1 : 0), 0)
if (links === 1) {
showModal(onClose => (<UnlinkObstacle onClose={onClose} type={type} />))
showModal(onClose => (<UnlinkObstacle onClose={onClose} type={type} unlinkAuth={unlinkAuth} />))
} else {
await unlinkAuth({ variables: { authType: type } })
}

View File

@ -0,0 +1,45 @@
/*
Warnings:
- You are about to drop the column `compound_id` on the `accounts` table. All the data in the column will be lost.
- The `access_token_expires` column on the `accounts` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- You are about to drop the column `access_token` on the `sessions` table. All the data in the column will be lost.
- A unique constraint covering the columns `[provider_id,provider_account_id]` on the table `accounts` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[identifier,token]` on the table `verification_requests` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "accounts.compound_id_unique";
-- DropIndex
DROP INDEX "accounts.provider_account_id_index";
-- DropIndex
DROP INDEX "accounts.provider_id_index";
-- DropIndex
DROP INDEX "sessions.access_token_unique";
-- AlterTable
ALTER TABLE "accounts" DROP COLUMN "compound_id",
ADD COLUMN "id_token" TEXT,
ADD COLUMN "scope" TEXT,
ADD COLUMN "session_state" TEXT,
ADD COLUMN "token_type" TEXT;
ALTER TABLE accounts ALTER COLUMN "access_token_expires" TYPE TEXT USING CAST(extract(epoch FROM "access_token_expires") AS BIGINT)*1000;
-- AlterTable
ALTER TABLE "sessions" DROP COLUMN "access_token";
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_id_provider_account_id_key" ON "accounts"("provider_id", "provider_account_id");
-- CreateIndex
CREATE UNIQUE INDEX "verification_requests_identifier_token_key" ON "verification_requests"("identifier", "token");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -80,6 +80,8 @@ model User {
photo Upload? @relation(fields: [photoId], references: [id])
referrer User? @relation("referrals", fields: [referrerId], references: [id])
referrees User[] @relation("referrals")
Account Account[]
Session Session[]
@@index([createdAt], map: "users.created_at_index")
@@index([inviteId], map: "users.inviteId_index")
@ -426,37 +428,42 @@ model Withdrawl {
}
model Account {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
compoundId String @unique(map: "accounts.compound_id_unique") @map("compound_id")
userId Int @map("user_id")
providerType String @map("provider_type")
providerId String @map("provider_id")
providerAccountId String @map("provider_account_id")
refreshToken String? @map("refresh_token")
accessToken String? @map("access_token")
accessTokenExpires DateTime? @map("access_token_expires")
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userId Int @map("user_id")
type String @map("provider_type")
provider String @map("provider_id")
providerAccountId String @map("provider_account_id")
refresh_token String? @map("refresh_token")
access_token String? @map("access_token")
expires_at String? @map("access_token_expires")
token_type String?
scope String?
id_token String?
session_state String?
@@index([providerAccountId], map: "accounts.provider_account_id_index")
@@index([providerId], map: "accounts.provider_id_index")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId], map: "accounts.user_id_index")
@@map("accounts")
}
model Session {
id Int @id @default(autoincrement())
sessionToken String @unique(map: "sessions.session_token_unique") @map("session_token")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userId Int @map("user_id")
expires DateTime
sessionToken String @unique(map: "sessions.session_token_unique") @map("session_token")
accessToken String @unique(map: "sessions.access_token_unique") @map("access_token")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationRequest {
model VerificationToken {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -464,6 +471,7 @@ model VerificationRequest {
token String @unique(map: "verification_requests.token_unique")
expires DateTime
@@unique([identifier, token])
@@map("verification_requests")
}

BIN
public/double.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

BIN
public/forbidden.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

View File

@ -1,13 +0,0 @@
.fourZeroFour {
font-family: 'lightning';
font-size: 96px;
display: flex;
justify-content: space-evenly;
align-items: center;
margin-top: 1rem;
width: 100%;
}
.notFound {
font-size: 24px;
}

View File

@ -8,6 +8,10 @@
width: 100%;
}
.smaller {
font-size: 48px;
}
.describe {
font-size: 24px;
}

View File

@ -167,6 +167,10 @@ mark {
color: #ffffff !important;
}
.btn-outline-grey-darkmode:hover, .btn-outline-grey-darkmode:active {
color: #ffffff !important;
}
.table {
color: var(--theme-color);
background-color: var(--theme-body);

View File

@ -1,6 +1,5 @@
const PgBoss = require('pg-boss')
const dotenv = require('dotenv')
dotenv.config({ path: '..' })
require('@next/env').loadEnvConfig('..')
const { PrismaClient } = require('@prisma/client')
const { checkInvoice, checkWithdrawal } = require('./wallet')
const { repin } = require('./repin')