Skip to content

Commit

Permalink
Login with magic code (#1818)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: k00b <[email protected]>
  • Loading branch information
3 people authored Feb 4, 2025
1 parent 89187db commit be7c702
Show file tree
Hide file tree
Showing 10 changed files with 268 additions and 52 deletions.
117 changes: 115 additions & 2 deletions components/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -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 ClientCheckbox = Client(Checkbox)
3 changes: 2 additions & 1 deletion components/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}}
>
Expand All @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 4 additions & 0 deletions lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down
Loading

0 comments on commit be7c702

Please sign in to comment.