Login with magic code (#1818)
* fix: cannot login with email on PWA * adjust other email templates * restore manual url on new user email * no padding on button section * cleanup * generate 6-digit bechh32 token * token needs to be fed as lower case; validator case insensitive * delete token if user has failed 3 times * proposal: context-independent error page * include expiration time on email page message * add expiration time to emails * independent checkPWA function * restore token deletion if successful auth * final cleanup: remove unused function * compact useVerificationToken * email.js: magic code for non-PWA users * adjust email templates * MultiInput component; magic code via MultiInput * hotfix: revert length testing; larger width for inputs * manual bech32 token generation; no upperCase * reverting to string concatenation * layout tweaks, fix error placement * pastable inputs * small nit fixes * less ambiguous error path --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
89187db1ea
commit
be7c702602
@ -486,7 +486,7 @@ function FormGroup ({ className, label, children }) {
|
|||||||
|
|
||||||
function InputInner ({
|
function InputInner ({
|
||||||
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
|
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
|
...props
|
||||||
}) {
|
}) {
|
||||||
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
||||||
@ -574,7 +574,7 @@ function InputInner ({
|
|||||||
onKeyDown={onKeyDownInner}
|
onKeyDown={onKeyDownInner}
|
||||||
onChange={onChangeInner}
|
onChange={onChangeInner}
|
||||||
onBlur={onBlurInner}
|
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}
|
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
|
||||||
/>
|
/>
|
||||||
{(isClient && clear && field.value && !props.readOnly) &&
|
{(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 (
|
||||||
|
<FormGroup label={label} className={groupClassName}>
|
||||||
|
<div className='d-flex flex-row justify-content-center gap-2'>
|
||||||
|
{inputs.map((value, index) => (
|
||||||
|
<InputInner
|
||||||
|
inputGroupClassName='w-auto'
|
||||||
|
name={name}
|
||||||
|
key={index}
|
||||||
|
type={inputType}
|
||||||
|
value={value}
|
||||||
|
innerRef={(el) => { 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 && <InputGroup.Text>{index + 1}</InputGroup.Text>} // show the index of the input
|
||||||
|
hideError
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{hideError && meta.touched && meta.error && ( // custom error message is showed if hideError is true
|
||||||
|
<BootstrapForm.Control.Feedback type='invalid' className='d-block'>
|
||||||
|
{meta.error}
|
||||||
|
</BootstrapForm.Control.Feedback>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const ClientInput = Client(Input)
|
export const ClientInput = Client(Input)
|
||||||
export const ClientCheckbox = Client(Checkbox)
|
export const ClientCheckbox = Client(Checkbox)
|
||||||
|
@ -20,6 +20,7 @@ export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
|
|||||||
}}
|
}}
|
||||||
schema={emailSchema}
|
schema={emailSchema}
|
||||||
onSubmit={async ({ email }) => {
|
onSubmit={async ({ email }) => {
|
||||||
|
window.sessionStorage.setItem('callback', JSON.stringify({ email, callbackUrl }))
|
||||||
signIn('email', { email, callbackUrl, multiAuth })
|
signIn('email', { email, callbackUrl, multiAuth })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -41,7 +42,7 @@ const authErrorMessages = {
|
|||||||
OAuthCallback: 'Error handling OAuth response. 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.',
|
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.',
|
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.',
|
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.',
|
EmailSignin: 'Failed to send email. Make sure you entered your email address correctly.',
|
||||||
CredentialsSignin: 'Auth failed. Try again or choose a different method.',
|
CredentialsSignin: 'Auth failed. Try again or choose a different method.',
|
||||||
|
@ -197,3 +197,5 @@ export const ZAP_UNDO_DELAY_MS = 5_000
|
|||||||
|
|
||||||
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
|
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
|
||||||
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000
|
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000
|
||||||
|
|
||||||
|
export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||||
|
@ -382,6 +382,10 @@ export const emailSchema = object({
|
|||||||
email: string().email('email is no good').required('required')
|
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({
|
export const urlSchema = object({
|
||||||
url: string().url().required('required')
|
url: string().url().required('required')
|
||||||
})
|
})
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
import NextAuth from 'next-auth'
|
import NextAuth from 'next-auth'
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||||
import GitHubProvider from 'next-auth/providers/github'
|
import GitHubProvider from 'next-auth/providers/github'
|
||||||
@ -15,6 +15,7 @@ import { notifyReferral } from '@/lib/webPush'
|
|||||||
import { hashEmail } from '@/lib/crypto'
|
import { hashEmail } from '@/lib/crypto'
|
||||||
import * as cookie from 'cookie'
|
import * as cookie from 'cookie'
|
||||||
import { multiAuthMiddleware } from '@/pages/api/graphql'
|
import { multiAuthMiddleware } from '@/pages/api/graphql'
|
||||||
|
import { BECH32_CHARSET } from '@/lib/constants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores userIds in user table
|
* Stores userIds in user table
|
||||||
@ -272,6 +273,8 @@ const getProviders = res => [
|
|||||||
EmailProvider({
|
EmailProvider({
|
||||||
server: process.env.LOGIN_EMAIL_SERVER,
|
server: process.env.LOGIN_EMAIL_SERVER,
|
||||||
from: process.env.LOGIN_EMAIL_FROM,
|
from: process.env.LOGIN_EMAIL_FROM,
|
||||||
|
maxAge: 5 * 60, // expires in 5 minutes
|
||||||
|
generateVerificationToken: generateRandomString,
|
||||||
sendVerificationRequest
|
sendVerificationRequest
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
@ -321,6 +324,40 @@ export const getAuthOptions = (req, res) => ({
|
|||||||
user.email = email
|
user.email = email
|
||||||
}
|
}
|
||||||
return user
|
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: {
|
session: {
|
||||||
@ -366,9 +403,22 @@ export default async (req, res) => {
|
|||||||
await NextAuth(req, res, getAuthOptions(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 ({
|
async function sendVerificationRequest ({
|
||||||
identifier: email,
|
identifier: email,
|
||||||
url,
|
url,
|
||||||
|
token,
|
||||||
provider
|
provider
|
||||||
}) {
|
}) {
|
||||||
let user = await prisma.user.findUnique({
|
let user = await prisma.user.findUnique({
|
||||||
@ -397,8 +447,8 @@ async function sendVerificationRequest ({
|
|||||||
to: email,
|
to: email,
|
||||||
from,
|
from,
|
||||||
subject: `login to ${site}`,
|
subject: `login to ${site}`,
|
||||||
text: text({ url, site, email }),
|
text: text({ url, token, site, email }),
|
||||||
html: user ? html({ url, site, email }) : newUserHtml({ url, site, email })
|
html: user ? html({ url, token, site, email }) : newUserHtml({ url, token, site, email })
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -411,7 +461,7 @@ async function sendVerificationRequest ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email HTML body
|
// 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
|
// 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
|
// 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
|
// 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 backgroundColor = '#f5f5f5'
|
||||||
const textColor = '#212529'
|
const textColor = '#212529'
|
||||||
const mainBackgroundColor = '#ffffff'
|
const mainBackgroundColor = '#ffffff'
|
||||||
const buttonBackgroundColor = '#FADA5E'
|
|
||||||
const buttonTextColor = '#212529'
|
|
||||||
|
|
||||||
// Uses tables for layout and inline CSS due to email client limitations
|
// Uses tables for layout and inline CSS due to email client limitations
|
||||||
return `
|
return `
|
||||||
@ -439,26 +487,32 @@ const html = ({ url, site, email }) => {
|
|||||||
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
|
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||||
login as <strong>${escapedEmail}</strong>
|
login with <strong>${escapedEmail}</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 20px 0;">
|
<td align="center" style="padding: 20px 0;">
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; text-decoration: none;border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBackgroundColor}; display: inline-block; font-weight: bold;">login</a></td>
|
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||||
|
copy this magic code
|
||||||
|
</td>
|
||||||
|
<tr><td height="10px"></td></tr>
|
||||||
|
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 36px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||||
|
<strong>${token}</strong>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
|
||||||
Or copy and paste this link: <a href="#" style="text-decoration:none; color:${textColor}">${url}</a>
|
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">Expires in 5 minutes</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 10px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
|
||||||
If you did not request this email you can safely ignore it.
|
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">If you did not request this email you can safely ignore it.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@ -467,28 +521,21 @@ const html = ({ url, site, email }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email text body –fallback for email clients that don't render HTML
|
// 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 escapedEmail = `${email.replace(/\./g, '​.')}`
|
||||||
|
|
||||||
const replaceCb = (path) => {
|
const dailyUrl = new URL('/daily', process.env.NEXT_PUBLIC_URL).href
|
||||||
const urlObj = new URL(url)
|
const guideUrl = new URL('/guide', process.env.NEXT_PUBLIC_URL).href
|
||||||
urlObj.searchParams.set('callbackUrl', path)
|
const faqUrl = new URL('/faq', process.env.NEXT_PUBLIC_URL).href
|
||||||
return urlObj.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
|
||||||
|
|
||||||
const dailyUrl = replaceCb('/daily')
|
|
||||||
const guideUrl = replaceCb('/guide')
|
|
||||||
const faqUrl = replaceCb('/faq')
|
|
||||||
const topUrl = replaceCb('/top/stackers/forever')
|
|
||||||
const postUrl = replaceCb('/post')
|
|
||||||
|
|
||||||
// Some simple styling options
|
// Some simple styling options
|
||||||
const backgroundColor = '#f5f5f5'
|
const backgroundColor = '#f5f5f5'
|
||||||
const textColor = '#212529'
|
const textColor = '#212529'
|
||||||
const mainBackgroundColor = '#ffffff'
|
const mainBackgroundColor = '#ffffff'
|
||||||
const buttonBackgroundColor = '#FADA5E'
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@ -606,7 +653,7 @@ const newUserHtml = ({ url, site, email }) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you know how Stacker News works, click the login button below.</div>
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you know how Stacker News works, copy the magic code below.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -635,25 +682,27 @@ const newUserHtml = ({ url, site, email }) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:18px;line-height:1;text-align:center;color:#000000;">login as <b>${escapedEmail}</b></div>
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:18px;line-height:1;text-align:center;color:#000000;">login with <b>${escapedEmail}</b></div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:30px;word-break:break-word;">
|
<td align="center" style="padding: 20px 0;">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" bgcolor="${buttonBackgroundColor}" role="presentation" style="border:none;border-radius:5px;cursor:auto;mso-padding-alt:15px 40px;background:${buttonBackgroundColor};" valign="middle">
|
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||||
<a href="${url}" style="display:inline-block;background:${buttonBackgroundColor};color:${textColor};font-family:Helvetica, Arial, sans-serif;font-size:22px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:15px 40px;mso-padding-alt:0px;border-radius:5px;" target="_blank">
|
copy this magic code
|
||||||
<mj-text align="center" font-family="Helvetica, Arial, sans-serif" font-size="20px"><b font-family="Helvetica, Arial, sans-serif">login</b></mj-text>
|
</td>
|
||||||
</a>
|
<tr><td height="10px"></td></tr>
|
||||||
|
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 36px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||||
|
<strong>${token}</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
|
||||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:24px;text-align:center;color:#000000;">Or copy and paste this link: <a href="#" style="text-decoration:none; color:#787878">${url}</a></div>
|
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">Expires in 5 minutes</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -707,7 +756,7 @@ const newUserHtml = ({ url, site, email }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Zap,<br /> Stacker News</div>
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Yeehaw,<br /> Stacker News</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -731,7 +780,7 @@ const newUserHtml = ({ url, site, email }) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="font-size:0px;padding:0px 25px 0px 25px;word-break:break-word;">
|
<td align="center" style="font-size:0px;padding:0px 25px 0px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Arial, sans-serif;font-size:14px;line-height:28px;text-align:center;color:#55575d;">P.S. Stacker News loves you!</div>
|
<div style="font-family:Arial, sans-serif;font-size:14px;line-height:28px;text-align:center;color:#55575d;">P.S. We're thrilled you're joinin' the posse!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import Image from 'react-bootstrap/Image'
|
import Image from 'react-bootstrap/Image'
|
||||||
import { StaticLayout } from '@/components/layout'
|
import { StaticLayout } from '@/components/layout'
|
||||||
import styles from '@/styles/error.module.css'
|
import styles from '@/styles/error.module.css'
|
||||||
import LightningIcon from '@/svgs/bolt.svg'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import Button from 'react-bootstrap/Button'
|
import Button from 'react-bootstrap/Button'
|
||||||
|
|
||||||
@ -27,20 +26,15 @@ export default function AuthError ({ error }) {
|
|||||||
return (
|
return (
|
||||||
<StaticLayout>
|
<StaticLayout>
|
||||||
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
|
<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>
|
<h2 className='pt-4'>Incorrect magic code</h2>
|
||||||
<h4 className='text-muted pt-2'>Get another by logging in.</h4>
|
|
||||||
<Button
|
<Button
|
||||||
className='align-items-center my-3'
|
className='align-items-center my-3'
|
||||||
style={{ borderWidth: '2px' }}
|
style={{ borderWidth: '2px' }}
|
||||||
id='login'
|
id='login'
|
||||||
onClick={() => router.push('/login')}
|
onClick={() => router.back()}
|
||||||
size='lg'
|
size='lg'
|
||||||
>
|
>
|
||||||
<LightningIcon
|
try again
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className='me-2'
|
|
||||||
/>login
|
|
||||||
</Button>
|
</Button>
|
||||||
</StaticLayout>
|
</StaticLayout>
|
||||||
)
|
)
|
||||||
|
@ -1,11 +1,32 @@
|
|||||||
import Image from 'react-bootstrap/Image'
|
import Image from 'react-bootstrap/Image'
|
||||||
import { StaticLayout } from '@/components/layout'
|
import { StaticLayout } from '@/components/layout'
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
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
|
// force SSR to include CSP nonces
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||||
|
|
||||||
export default function Email () {
|
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 (
|
return (
|
||||||
<StaticLayout>
|
<StaticLayout>
|
||||||
<div className='p-4 text-center'>
|
<div className='p-4 text-center'>
|
||||||
@ -14,8 +35,36 @@ export default function Email () {
|
|||||||
<Image className='rounded-1 shadow-sm' width='640' height='302' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/cowboy-saloon.gif`} fluid />
|
<Image className='rounded-1 shadow-sm' width='640' height='302' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/cowboy-saloon.gif`} fluid />
|
||||||
</video>
|
</video>
|
||||||
<h2 className='pt-4'>Check your email</h2>
|
<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>
|
<h4 className='text-muted pt-2 pb-4'>a magic code has been sent to {callback ? callback.email : 'your email address'}</h4>
|
||||||
|
<MagicCodeForm onSubmit={(token) => pushCallback(token)} disabled={!callback} />
|
||||||
</div>
|
</div>
|
||||||
</StaticLayout>
|
</StaticLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MagicCodeForm = ({ onSubmit, disabled }) => {
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
initial={{
|
||||||
|
token: ''
|
||||||
|
}}
|
||||||
|
schema={emailTokenSchema}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
onSubmit(values.token.toLowerCase()) // token is displayed in uppercase but we need to check it in lowercase
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MultiInput
|
||||||
|
length={6}
|
||||||
|
charLength={1}
|
||||||
|
name='token'
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
groupClassName='d-flex flex-column justify-content-center gap-2'
|
||||||
|
inputType='text'
|
||||||
|
hideError // hide error message on every input, allow custom error message
|
||||||
|
disabled={disabled} // disable the form if no callback is provided
|
||||||
|
/>
|
||||||
|
<SubmitButton variant='primary' className='px-4' disabled={disabled}>login</SubmitButton>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -858,7 +858,7 @@ function AuthMethods ({ methods, apiKeyEnabled }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: <div key={provider} className='mt-2'><EmailLinkForm /></div>
|
: <div key={provider} className='mt-2'><EmailLinkForm callbackUrl='/settings' /></div>
|
||||||
} else if (provider === 'lightning') {
|
} else if (provider === 'lightning') {
|
||||||
return (
|
return (
|
||||||
<QRLinkButton
|
<QRLinkButton
|
||||||
@ -910,6 +910,7 @@ export function EmailLinkForm ({ callbackUrl }) {
|
|||||||
// then call signIn
|
// then call signIn
|
||||||
const { data } = await linkUnverifiedEmail({ variables: { email } })
|
const { data } = await linkUnverifiedEmail({ variables: { email } })
|
||||||
if (data.linkUnverifiedEmail) {
|
if (data.linkUnverifiedEmail) {
|
||||||
|
window.sessionStorage.setItem('callback', JSON.stringify({ email, callbackUrl }))
|
||||||
signIn('email', { email, callbackUrl })
|
signIn('email', { email, callbackUrl })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "verification_requests" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;
|
@ -1088,6 +1088,7 @@ model VerificationToken {
|
|||||||
identifier String
|
identifier String
|
||||||
token String @unique(map: "verification_requests.token_unique")
|
token String @unique(map: "verification_requests.token_unique")
|
||||||
expires DateTime
|
expires DateTime
|
||||||
|
attempts Int @default(0)
|
||||||
|
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
@@map("verification_requests")
|
@@map("verification_requests")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user