diff --git a/components/form.js b/components/form.js
index b34af25f..5b5b12d2 100644
--- a/components/form.js
+++ b/components/form.js
@@ -486,7 +486,7 @@ function FormGroup ({ className, label, children }) {
function InputInner ({
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
- innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength,
+ innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
...props
}) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
@@ -574,7 +574,7 @@ function InputInner ({
onKeyDown={onKeyDownInner}
onChange={onChangeInner}
onBlur={onBlurInner}
- isInvalid={invalid}
+ isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{(isClient && clear && field.value && !props.readOnly) &&
@@ -1241,5 +1241,118 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
)
}
+export function MultiInput ({
+ name, label, groupClassName, length = 4, charLength = 1, upperCase, showSequence,
+ onChange, autoFocus, hideError, inputType = 'text',
+ ...props
+}) {
+ const [inputs, setInputs] = useState(new Array(length).fill(''))
+ const inputRefs = useRef(new Array(length).fill(null))
+ const [, meta, helpers] = useField({ name })
+
+ useEffect(() => {
+ autoFocus && inputRefs.current[0].focus() // focus the first input if autoFocus is true
+ }, [autoFocus])
+
+ const updateInputs = useCallback((newInputs) => {
+ setInputs(newInputs)
+ const combinedValue = newInputs.join('') // join the inputs to get the value
+ helpers.setValue(combinedValue) // set the value to the formik field
+ onChange?.(combinedValue)
+ }, [onChange, helpers])
+
+ const handleChange = useCallback((formik, e, index) => { // formik is not used but it's required to get the value
+ const value = e.target.value.slice(-charLength)
+ const processedValue = upperCase ? value.toUpperCase() : value // convert the input to uppercase if upperCase is tru
+
+ const newInputs = [...inputs]
+ newInputs[index] = processedValue
+ updateInputs(newInputs)
+
+ // focus the next input if the current input is filled
+ if (processedValue.length === charLength && index < length - 1) {
+ inputRefs.current[index + 1].focus()
+ }
+ }, [inputs, charLength, upperCase, onChange, length])
+
+ const handlePaste = useCallback((e) => {
+ e.preventDefault()
+ const pastedValues = e.clipboardData.getData('text').slice(0, length)
+ const processedValues = upperCase ? pastedValues.toUpperCase() : pastedValues
+ const chars = processedValues.split('')
+
+ const newInputs = [...inputs]
+ chars.forEach((char, i) => {
+ newInputs[i] = char.slice(0, charLength)
+ })
+
+ updateInputs(newInputs)
+ inputRefs.current[length - 1]?.focus() // simulating the paste by focusing the last input
+ }, [inputs, length, charLength, upperCase, updateInputs])
+
+ const handleKeyDown = useCallback((e, index) => {
+ switch (e.key) {
+ case 'Backspace': {
+ e.preventDefault()
+ const newInputs = [...inputs]
+ // if current input is empty move focus to the previous input else clear the current input
+ const targetIndex = inputs[index] === '' && index > 0 ? index - 1 : index
+ newInputs[targetIndex] = ''
+ updateInputs(newInputs)
+ inputRefs.current[targetIndex]?.focus()
+ break
+ }
+ case 'ArrowLeft': {
+ if (index > 0) { // focus the previous input if it's not the first input
+ e.preventDefault()
+ inputRefs.current[index - 1]?.focus()
+ }
+ break
+ }
+ case 'ArrowRight': {
+ if (index < length - 1) { // focus the next input if it's not the last input
+ e.preventDefault()
+ inputRefs.current[index + 1]?.focus()
+ }
+ break
+ }
+ }
+ }, [inputs, length, updateInputs])
+
+ return (
+
+
+ {inputs.map((value, index) => (
+ { inputRefs.current[index] = el }}
+ onChange={(formik, e) => handleChange(formik, e, index)}
+ onKeyDown={e => handleKeyDown(e, index)}
+ onPaste={e => handlePaste(e, index)}
+ style={{
+ textAlign: 'center',
+ maxWidth: `${charLength * 44}px` // adjusts the max width of the input based on the charLength
+ }}
+ prepend={showSequence && {index + 1}} // show the index of the input
+ hideError
+ {...props}
+ />
+ ))}
+
+
+ {hideError && meta.touched && meta.error && ( // custom error message is showed if hideError is true
+
+ {meta.error}
+
+ )}
+
+
+ )
+}
+
export const ClientInput = Client(Input)
export const ClientCheckbox = Client(Checkbox)
diff --git a/components/login.js b/components/login.js
index 3f43f8a0..3d3d1d84 100644
--- a/components/login.js
+++ b/components/login.js
@@ -20,6 +20,7 @@ export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
}}
schema={emailSchema}
onSubmit={async ({ email }) => {
+ window.sessionStorage.setItem('callback', JSON.stringify({ email, callbackUrl }))
signIn('email', { email, callbackUrl, multiAuth })
}}
>
@@ -41,7 +42,7 @@ const authErrorMessages = {
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.',
+ Callback: '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.',
diff --git a/lib/constants.js b/lib/constants.js
index a2e2f28f..48cf0cda 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -197,3 +197,5 @@ export const ZAP_UNDO_DELAY_MS = 5_000
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000
+
+export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
diff --git a/lib/validate.js b/lib/validate.js
index 12bce77a..7002b179 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -382,6 +382,10 @@ export const emailSchema = object({
email: string().email('email is no good').required('required')
})
+export const emailTokenSchema = object({
+ token: string().required('required').trim().matches(/^[0-9a-z]{6}$/i, 'must be 6 alphanumeric characters')
+})
+
export const urlSchema = object({
url: string().url().required('required')
})
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index f2dcfde3..f3773a67 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -1,4 +1,4 @@
-import { createHash } from 'node:crypto'
+import { createHash, randomBytes } from 'node:crypto'
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import GitHubProvider from 'next-auth/providers/github'
@@ -15,6 +15,7 @@ import { notifyReferral } from '@/lib/webPush'
import { hashEmail } from '@/lib/crypto'
import * as cookie from 'cookie'
import { multiAuthMiddleware } from '@/pages/api/graphql'
+import { BECH32_CHARSET } from '@/lib/constants'
/**
* Stores userIds in user table
@@ -272,6 +273,8 @@ const getProviders = res => [
EmailProvider({
server: process.env.LOGIN_EMAIL_SERVER,
from: process.env.LOGIN_EMAIL_FROM,
+ maxAge: 5 * 60, // expires in 5 minutes
+ generateVerificationToken: generateRandomString,
sendVerificationRequest
})
]
@@ -321,6 +324,40 @@ export const getAuthOptions = (req, res) => ({
user.email = email
}
return user
+ },
+ useVerificationToken: async ({ identifier, token }) => {
+ // we need to find the most recent verification request for this email/identifier
+ const verificationRequest = await prisma.verificationToken.findFirst({
+ where: {
+ identifier,
+ attempts: {
+ lt: 2 // count starts at 0
+ }
+ },
+ orderBy: {
+ createdAt: 'desc'
+ }
+ })
+
+ if (!verificationRequest) throw new Error('No verification request found')
+
+ if (verificationRequest.token === token) { // if correct delete the token and continue
+ await prisma.verificationToken.delete({
+ where: { id: verificationRequest.id }
+ })
+ return verificationRequest
+ }
+
+ await prisma.verificationToken.update({
+ where: { id: verificationRequest.id },
+ data: { attempts: { increment: 1 } }
+ })
+
+ await prisma.verificationToken.deleteMany({
+ where: { id: verificationRequest.id, attempts: { gte: 2 } }
+ })
+
+ return null
}
},
session: {
@@ -366,9 +403,22 @@ export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req, res))
}
+function generateRandomString (length = 6, charset = BECH32_CHARSET) {
+ const bytes = randomBytes(length)
+ let result = ''
+
+ // Map each byte to a character in the charset
+ for (let i = 0; i < length; i++) {
+ result += charset[bytes[i] % charset.length]
+ }
+
+ return result
+}
+
async function sendVerificationRequest ({
identifier: email,
url,
+ token,
provider
}) {
let user = await prisma.user.findUnique({
@@ -397,8 +447,8 @@ async function sendVerificationRequest ({
to: email,
from,
subject: `login to ${site}`,
- text: text({ url, site, email }),
- html: user ? html({ url, site, email }) : newUserHtml({ url, site, email })
+ text: text({ url, token, site, email }),
+ html: user ? html({ url, token, site, email }) : newUserHtml({ url, token, site, email })
},
(error) => {
if (error) {
@@ -411,7 +461,7 @@ async function sendVerificationRequest ({
}
// Email HTML body
-const html = ({ url, site, email }) => {
+const html = ({ url, token, site, email }) => {
// Insert invisible space into domains and email address to prevent both the
// email address and the domain from being turned into a hyperlink by email
// clients like Outlook and Apple mail, as this is confusing because it seems
@@ -423,8 +473,6 @@ const html = ({ url, site, email }) => {
const backgroundColor = '#f5f5f5'
const textColor = '#212529'
const mainBackgroundColor = '#ffffff'
- const buttonBackgroundColor = '#FADA5E'
- const buttonTextColor = '#212529'
// Uses tables for layout and inline CSS due to email client limitations
return `
@@ -439,26 +487,32 @@ const html = ({ url, site, email }) => {
- login as ${escapedEmail}
+ login with ${escapedEmail}
|
- login |
+
+ copy this magic code
+ |
+ |
+
+ ${token}
+ |
|
-
- Or copy and paste this link: ${url}
+ |
+ Expires in 5 minutes
|
-
- If you did not request this email you can safely ignore it.
+ |
+ If you did not request this email you can safely ignore it.
|
@@ -467,28 +521,21 @@ const html = ({ url, site, email }) => {
}
// Email text body –fallback for email clients that don't render HTML
-const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n`
+const text = ({ url, token, site }) => `Sign in to ${site}\ncopy this code: ${token}\n\n\nExpires in 5 minutes`
-const newUserHtml = ({ url, site, email }) => {
+const newUserHtml = ({ url, token, site, email }) => {
const escapedEmail = `${email.replace(/\./g, '.')}`
- const replaceCb = (path) => {
- const urlObj = new URL(url)
- urlObj.searchParams.set('callbackUrl', path)
- return urlObj.href
- }
-
- const dailyUrl = replaceCb('/daily')
- const guideUrl = replaceCb('/guide')
- const faqUrl = replaceCb('/faq')
- const topUrl = replaceCb('/top/stackers/forever')
- const postUrl = replaceCb('/post')
+ const dailyUrl = new URL('/daily', process.env.NEXT_PUBLIC_URL).href
+ const guideUrl = new URL('/guide', process.env.NEXT_PUBLIC_URL).href
+ const faqUrl = new URL('/faq', process.env.NEXT_PUBLIC_URL).href
+ const topUrl = new URL('/top/stackers/forever', process.env.NEXT_PUBLIC_URL).href
+ const postUrl = new URL('/post', process.env.NEXT_PUBLIC_URL).href
// Some simple styling options
const backgroundColor = '#f5f5f5'
const textColor = '#212529'
const mainBackgroundColor = '#ffffff'
- const buttonBackgroundColor = '#FADA5E'
return `
@@ -606,7 +653,7 @@ const newUserHtml = ({ url, site, email }) => {
- If you know how Stacker News works, click the login button below.
+ If you know how Stacker News works, copy the magic code below.
|
@@ -635,25 +682,27 @@ const newUserHtml = ({ url, site, email }) => {
- login as ${escapedEmail}
+ login with ${escapedEmail}
|
-
-
+
+
-
-
- login
-
+ |
+ copy this magic code
+ |
+ |
+
+ ${token}
|
|
-
- Or copy and paste this link: ${url}
+ |
+ Expires in 5 minutes
|
@@ -707,7 +756,7 @@ const newUserHtml = ({ url, site, email }) => {
- Zap, Stacker News
+ Yeehaw, Stacker News
|
@@ -731,7 +780,7 @@ const newUserHtml = ({ url, site, email }) => {
- P.S. Stacker News loves you!
+ P.S. We're thrilled you're joinin' the posse!
|
diff --git a/pages/auth/error.js b/pages/auth/error.js
index c95a8ae2..5df283bf 100644
--- a/pages/auth/error.js
+++ b/pages/auth/error.js
@@ -1,7 +1,6 @@
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'
@@ -27,20 +26,15 @@ export default function AuthError ({ error }) {
return (
- This magic link has expired.
- Get another by logging in.
+ Incorrect magic code
)
diff --git a/pages/email.js b/pages/email.js
index c3bba919..23b177bf 100644
--- a/pages/email.js
+++ b/pages/email.js
@@ -1,11 +1,32 @@
import Image from 'react-bootstrap/Image'
import { StaticLayout } from '@/components/layout'
import { getGetServerSideProps } from '@/api/ssrApollo'
+import { useRouter } from 'next/router'
+import { useState, useEffect, useCallback } from 'react'
+import { Form, SubmitButton, MultiInput } from '@/components/form'
+import { emailTokenSchema } from '@/lib/validate'
// force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null })
export default function Email () {
+ const router = useRouter()
+ const [callback, setCallback] = useState(null) // callback.email, callback.callbackUrl
+
+ useEffect(() => {
+ setCallback(JSON.parse(window.sessionStorage.getItem('callback')))
+ }, [])
+
+ // build and push the final callback URL
+ const pushCallback = useCallback((token) => {
+ const params = new URLSearchParams()
+ if (callback.callbackUrl) params.set('callbackUrl', callback.callbackUrl)
+ params.set('token', token)
+ params.set('email', callback.email)
+ const url = `/api/auth/callback/email?${params.toString()}`
+ router.push(url)
+ }, [callback, router])
+
return (
@@ -14,8 +35,36 @@ export default function Email () {
Check your email
- A sign in link has been sent to your email address
+ a magic code has been sent to {callback ? callback.email : 'your email address'}
+ pushCallback(token)} disabled={!callback} />
)
}
+
+export const MagicCodeForm = ({ onSubmit, disabled }) => {
+ return (
+
+ )
+}
diff --git a/pages/settings/index.js b/pages/settings/index.js
index 4f1e912d..5ad2f813 100644
--- a/pages/settings/index.js
+++ b/pages/settings/index.js
@@ -858,7 +858,7 @@ function AuthMethods ({ methods, apiKeyEnabled }) {
)
- :
+ :
} else if (provider === 'lightning') {
return (
|