1
0
Fork 0

feat(backend): Implement email change flow

This commit is contained in:
joostdecock 2023-02-26 13:16:40 +01:00
parent 6a3a14a6bd
commit 4c3d3a5019
14 changed files with 202 additions and 47 deletions

View file

@ -432,10 +432,12 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) {
const isUnitTest = this.isUnitTest(body) const isUnitTest = this.isUnitTest(body)
if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) { if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) {
// Email change (requires confirmation) // Email change (requires confirmation)
const check = randomString()
this.confirmation = await this.Confirmation.create({ this.confirmation = await this.Confirmation.create({
type: 'emailchange', type: 'emailchange',
data: { data: {
language: this.record.language, language: this.record.language,
check,
email: { email: {
current: this.clear.email, current: this.clear.email,
new: body.email, new: body.email,
@ -451,13 +453,20 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) {
to: body.email, to: body.email,
cc: this.clear.email, cc: this.clear.email,
replacements: { replacements: {
actionUrl: i18nUrl(this.language, `/confirm/emailchange/${this.Confirmation.record.id}`), actionUrl: i18nUrl(
whyUrl: i18nUrl(this.language, `/docs/faq/email/why-emailchange`), this.record.language,
supportUrl: i18nUrl(this.language, `/patrons/join`), `/confirm/emailchange/${this.Confirmation.record.id}/${check}`
),
whyUrl: i18nUrl(this.record.language, `/docs/faq/email/why-emailchange`),
supportUrl: i18nUrl(this.record.language, `/patrons/join`),
}, },
}) })
} }
} else if (typeof body.confirmation === 'string' && body.confirm === 'emailchange') { } else if (
typeof body.confirmation === 'string' &&
body.confirm === 'emailchange' &&
typeof body.check === 'string'
) {
// Handle email change confirmation // Handle email change confirmation
await this.Confirmation.read({ id: body.confirmation }) await this.Confirmation.read({ id: body.confirmation })
@ -472,7 +481,11 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) {
} }
const data = this.Confirmation.clear.data const data = this.Confirmation.clear.data
if (data.email.current === this.clear.email && typeof data.email.new === 'string') { if (
data.check === body.check &&
data.email.current === this.clear.email &&
typeof data.email.new === 'string'
) {
await this.unguardedUpdate({ await this.unguardedUpdate({
email: this.encrypt(data.email.new), email: this.encrypt(data.email.new),
ehash: hash(clean(data.email.new)), ehash: hash(clean(data.email.new)),

View file

@ -1,4 +1,4 @@
subject: Email change subject here FIXME subject: "[FreeSewing] Confirm your new E-mail address"
heading: Does this new E-mail address work? heading: Does this new E-mail address work?
lead: 'To confirm your new E-mail address, click the big black rectangle below:' lead: 'To confirm your new E-mail address, click the big black rectangle below:'
text-lead: 'To confirm your new E-mail address, click the link below:' text-lead: 'To confirm your new E-mail address, click the link below:'

View file

@ -6,7 +6,7 @@ import es from '../../../../public/locales/es/emailchange.json' assert { type: '
import fr from '../../../../public/locales/fr/emailchange.json' assert { type: 'json' } import fr from '../../../../public/locales/fr/emailchange.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/emailchange.json' assert { type: 'json' } import nl from '../../../../public/locales/nl/emailchange.json' assert { type: 'json' }
export const emailChange = { export const emailchange = {
html: wrap.html(` html: wrap.html(`
${headingRow.html} ${headingRow.html}
${lead1Row.html} ${lead1Row.html}

View file

@ -1,11 +1,11 @@
import { emailChange, translations as emailChangeTranslations } from './emailchange/index.mjs' import { emailchange, translations as emailchangeTranslations } from './emailchange/index.mjs'
import { goodbye, translations as goodbyeTranslations } from './goodbye/index.mjs' import { goodbye, translations as goodbyeTranslations } from './goodbye/index.mjs'
import { loginLink, translations as loginLinkTranslations } from './loginlink/index.mjs' import { loginlink, translations as loginlinkTranslations } from './loginlink/index.mjs'
import { newsletterSub, translations as newsletterSubTranslations } from './newslettersub/index.mjs' import { newslettersub, translations as newslettersubTranslations } from './newslettersub/index.mjs'
import { passwordReset, translations as passwordResetTranslations } from './passwordreset/index.mjs' import { passwordreset, translations as passwordresetTranslations } from './passwordreset/index.mjs'
import { signup, translations as signupTranslations } from './signup/index.mjs' import { signup, translations as signupTranslations } from './signup/index.mjs'
import { signupAea, translations as signupAeaTranslations } from './signup-aea/index.mjs' import { signupaea, translations as signupaeaTranslations } from './signup-aea/index.mjs'
import { signupAed, translations as signupAedTranslations } from './signup-aed/index.mjs' import { signupaed, translations as signupaedTranslations } from './signup-aed/index.mjs'
// Shared translations // Shared translations
import en from '../../../public/locales/en/shared.json' assert { type: 'json' } import en from '../../../public/locales/en/shared.json' assert { type: 'json' }
import de from '../../../public/locales/de/shared.json' assert { type: 'json' } import de from '../../../public/locales/de/shared.json' assert { type: 'json' }
@ -13,25 +13,28 @@ import es from '../../../public/locales/es/shared.json' assert { type: 'json' }
import fr from '../../../public/locales/fr/shared.json' assert { type: 'json' } import fr from '../../../public/locales/fr/shared.json' assert { type: 'json' }
import nl from '../../../public/locales/nl/shared.json' assert { type: 'json' } import nl from '../../../public/locales/nl/shared.json' assert { type: 'json' }
/*
* Everything is kept lowercase here because these key names are used in URLS
*/
export const templates = { export const templates = {
emailChange, emailchange,
goodbye, goodbye,
loginLink, loginlink,
newsletterSub, newslettersub,
passwordReset, passwordreset,
signup, signup,
'signup-aea': signupAea, 'signup-aea': signupaea,
'signup-aed': signupAed, 'signup-aed': signupaed,
} }
export const translations = { export const translations = {
emailChange: emailChangeTranslations, emailchange: emailchangeTranslations,
goodbye: goodbyeTranslations, goodbye: goodbyeTranslations,
loginLink: loginLinkTranslations, loginlink: loginlinkTranslations,
newsletterSub: newsletterSubTranslations, newslettersub: newslettersubTranslations,
passwordReset: passwordResetTranslations, passwordreset: passwordresetTranslations,
signup: signupTranslations, signup: signupTranslations,
'signup-aea': signupAeaTranslations, 'signup-aea': signupaeaTranslations,
'signup-aed': signupAedTranslations, 'signup-aed': signupaedTranslations,
shared: { en, de, es, fr, nl }, shared: { en, de, es, fr, nl },
} }

View file

@ -6,7 +6,7 @@ import es from '../../../../public/locales/es/loginlink.json' assert { type: 'js
import fr from '../../../../public/locales/fr/loginlink.json' assert { type: 'json' } import fr from '../../../../public/locales/fr/loginlink.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/loginlink.json' assert { type: 'json' } import nl from '../../../../public/locales/nl/loginlink.json' assert { type: 'json' }
export const loginLink = { export const loginlink = {
html: wrap.html(` html: wrap.html(`
${headingRow} ${headingRow}
<tr> <tr>

View file

@ -6,7 +6,7 @@ import es from '../../../../public/locales/es/newslettersub.json' assert { type:
import fr from '../../../../public/locales/fr/newslettersub.json' assert { type: 'json' } import fr from '../../../../public/locales/fr/newslettersub.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/newslettersub.json' assert { type: 'json' } import nl from '../../../../public/locales/nl/newslettersub.json' assert { type: 'json' }
export const newsletterSub = { export const newslettersub = {
html: wrap.html(` html: wrap.html(`
${headingRow.html} ${headingRow.html}
${lead1Row.html} ${lead1Row.html}

View file

@ -6,7 +6,7 @@ import es from '../../../../public/locales/es/passwordreset.json' assert { type:
import fr from '../../../../public/locales/fr/passwordreset.json' assert { type: 'json' } import fr from '../../../../public/locales/fr/passwordreset.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/passwordreset.json' assert { type: 'json' } import nl from '../../../../public/locales/nl/passwordreset.json' assert { type: 'json' }
export const passwordReset = { export const passwordreset = {
html: wrap.html(` html: wrap.html(`
${headingRow.html} ${headingRow.html}
<tr> <tr>

View file

@ -7,7 +7,7 @@ import fr from '../../../../public/locales/fr/signup-aea.json' assert { type: 'j
import nl from '../../../../public/locales/nl/signup-aea.json' assert { type: 'json' } import nl from '../../../../public/locales/nl/signup-aea.json' assert { type: 'json' }
// aea = Account Exists and is Active // aea = Account Exists and is Active
export const signupAea = { export const signupaea = {
html: wrap.html(` html: wrap.html(`
${headingRow.html} ${headingRow.html}
${preLeadRow.html} ${preLeadRow.html}

View file

@ -7,7 +7,7 @@ import fr from '../../../../public/locales/fr/signup-aed.json' assert { type: 'j
import nl from '../../../../public/locales/nl/signup-aed.json' assert { type: 'json' } import nl from '../../../../public/locales/nl/signup-aed.json' assert { type: 'json' }
// aed = Account Exists but is Disabled // aed = Account Exists but is Disabled
export const signupAed = { export const signupaed = {
html: wrap.html(` html: wrap.html(`
${headingRow.html} ${headingRow.html}
${preLeadRow.html} ${preLeadRow.html}

View file

@ -19,13 +19,21 @@ export const mailer = (config) => ({
* If you want to use another way to send email, change the mailer * If you want to use another way to send email, change the mailer
* assignment above to point to another method to deliver email * assignment above to point to another method to deliver email
*/ */
async function sendEmailViaAwsSes(config, { template, to, language = 'en', replacements = {} }) { async function sendEmailViaAwsSes(
config,
{ template, to, cc = false, language = 'en', replacements = {} }
) {
// Make sure we have what it takes // Make sure we have what it takes
if (!template || !to || typeof templates[template] === 'undefined') { if (!template || !to || typeof templates[template] === 'undefined') {
log.warn(`Tried to email invalid template: ${template}`) log.warn(`Tried to email invalid template: ${template}`)
return false return false
} }
if (!cc) cc = []
if (typeof cc === 'string') cc = [cc]
if (Array.isArray(config.aws.ses.cc) && config.aws.ses.cc.length > 0)
cc = [...new Set([...cc, ...config.aws.ses.cc])]
log.info(`Emailing template ${template} to ${to}`) log.info(`Emailing template ${template} to ${to}`)
// Load template // Load template
@ -62,7 +70,7 @@ async function sendEmailViaAwsSes(config, { template, to, language = 'en', repla
}, },
Destination: { Destination: {
ToAddresses: [to], ToAddresses: [to],
CcAddresses: config.aws.ses.cc || [], CcAddresses: cc,
BccAddresses: config.aws.ses.bcc || [], BccAddresses: config.aws.ses.bcc || [],
}, },
FeedbackForwardingEmailAddress: config.aws.ses.feedback, FeedbackForwardingEmailAddress: config.aws.ses.feedback,

View file

@ -13,9 +13,9 @@ yourProfile: Your profile
yourPatterns: Your patterns yourPatterns: Your patterns
yourSets: Your measurement sets yourSets: Your measurement sets
logout: Log out logout: Log out
politeOhCrap: Oh fiddlesticks
bio: Bio bio: Bio
email: Email address email: E-mail address
github: Github username github: Github username
img: Profile image img: Profile image
username: Username username: Username
@ -124,6 +124,10 @@ usernameNotAvailable: Username is not available
# email # email
emailTitle: Where can we reach you in case we have a good reason for it (like when you forgot your password)? emailTitle: Where can we reach you in case we have a good reason for it (like when you forgot your password)?
oneMoreThing: One more thing
oneMomentPlease: One moment please
emailChangeConfirmation: We have sent an E-mail to your new address to confirm this change.
vagueError: Something went wrong, and we're not certain how to handle it. Please try again, or involve a human being for assistance.
# github # github
githubTitle: Enter your github username here to receive updates when you report a problem through this website. githubTitle: Enter your github username here to receive updates when you report a problem through this website.

View file

@ -7,19 +7,22 @@ import { useToast } from 'site/hooks/useToast.mjs'
import { validateEmail, validateTld } from 'site/utils.mjs' import { validateEmail, validateTld } from 'site/utils.mjs'
// Components // Components
import { BackToAccountButton } from './shared.mjs' import { BackToAccountButton } from './shared.mjs'
import { Popout } from 'shared/components/popout.mjs'
export const ns = ['account'] export const ns = ['account', 'toast']
export const EmailSettings = ({ app, title = false }) => { export const EmailSettings = ({ app, title = false }) => {
const backend = useBackend(app) const backend = useBackend(app)
const { t } = useTranslation(ns) const { t } = useTranslation(ns)
const toast = useToast() const toast = useToast()
const [email, setEmail] = useState(app.account.email) const [email, setEmail] = useState(app.account.email)
const [changed, setChanged] = useState(false)
const save = async () => { const save = async () => {
const result = await backend.updateAccount({ email }) const result = await backend.updateAccount({ email })
if (result) toast.for.settingsSaved() if (result) toast.for.settingsSaved()
else toast.for.backendError() else toast.for.backendError()
setChanged(true)
} }
const valid = (validateEmail(email) && validateTld(email)) || false const valid = (validateEmail(email) && validateTld(email)) || false
@ -27,17 +30,30 @@ export const EmailSettings = ({ app, title = false }) => {
return ( return (
<> <>
{title ? <h2 className="text-4xl">{t('emailTitle')}</h2> : null} {title ? <h2 className="text-4xl">{t('emailTitle')}</h2> : null}
<div className="flex flex-row items-center mt-4"> {changed ? (
<input <Popout note>
value={email} <h3>{t('oneMoreThing')}</h3>
onChange={(evt) => setEmail(evt.target.value)} <p>{t('emailChangeConfirmation')}</p>
className="input w-full input-bordered flex flex-row" </Popout>
type="text" ) : (
/> <>
</div> <div className="flex flex-row items-center mt-4">
<button className="btn mt-4 btn-primary w-full" onClick={save} disabled={!valid}> <input
{t('save')} value={email}
</button> onChange={(evt) => setEmail(evt.target.value)}
className="input w-full input-bordered flex flex-row"
type="text"
/>
</div>
<button
className="btn mt-4 btn-primary w-full"
onClick={save}
disabled={!valid || email.toLowerCase() === app.account.email}
>
{t('save')}
</button>
</>
)}
<BackToAccountButton loading={app.loading} /> <BackToAccountButton loading={app.loading} />
</> </>
) )

View file

@ -69,7 +69,7 @@ export function useBackend(app) {
*/ */
backend.loadConfirmation = async ({ id, check }) => { backend.loadConfirmation = async ({ id, check }) => {
const result = await api.get(`/confirmations/${id}/${check}`) const result = await api.get(`/confirmations/${id}/${check}`)
if (result && result.status === 201 && result.data) return result.data if (result && result.status === 200 && result.data) return result.data
return null return null
} }

View file

@ -0,0 +1,111 @@
// Hooks
import { useEffect, useState } from 'react'
import { useApp } from 'site/hooks/useApp.mjs'
import { useBackend } from 'site/hooks/useBackend.mjs'
import { useToast } from 'site/hooks/useToast.mjs'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
// Dependencies
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import Link from 'next/link'
// Components
import { PageWrapper, ns as pageNs } from 'site/components/wrappers/page.mjs'
import { BareLayout } from 'site/components/layouts/bare.mjs'
import { Spinner } from 'shared/components/spinner.mjs'
import { Robot } from 'shared/components/robot/index.mjs'
import { BackToAccountButton } from 'site/components/account/shared.mjs'
import { HelpIcon } from 'shared/components/icons.mjs'
// Translation namespaces used on this page
const ns = Array.from(new Set([...pageNs, 'account']))
const ConfirmSignUpPage = (props) => {
const app = useApp(props)
const backend = useBackend(app)
const toast = useToast()
const { t } = useTranslation(ns)
const router = useRouter()
const [error, setError] = useState(false)
// Get confirmation ID and check from url
const [id, check] = router.asPath.slice(1).split('/').slice(2)
useEffect(() => {
// Async inside useEffect requires this approach
const confirmEmail = async () => {
app.startLoading()
const result = await backend.loadConfirmation({ id, check })
if (result?.result === 'success' && result.confirmation) {
const changed = await backend.updateAccount({
confirm: 'emailchange',
confirmation: result.confirmation.id,
check: result.confirmation.check,
})
if (changed) {
app.stopLoading()
setReady(true)
setError(false)
toast.for.settingsSaved()
router.push('/account')
} else {
app.stopLoading()
setReady(true)
setError(true)
}
} else {
app.stopLoading()
setReady(true)
setError(true)
}
}
// Call async methods
if (app.token) confirmEmail()
}, [id, check, app.token])
// Short-circuit errors
if (error)
return (
<PageWrapper app={app} title={t('account:politeOhCrap')} layout={BareLayout} footer={false}>
<div className="max-w-md flex flex-col items-center m-auto justify-center h-screen text-center">
<h1 className="text-center">{t('account:politeOhCrap')}</h1>
<Robot pose="ohno" className="w-48" embed />
<p className="mt-4">{t('account:vagueError')}</p>
<div className="flex flex-row gap-4 items-center mt-4">
<BackToAccountButton />
<Link className="btn btn-primary mt-4 pr-6" href="/support">
<HelpIcon className="w-6 h-6 mr-4" /> {t('contactSupport')}
</Link>
</div>
</div>
</PageWrapper>
)
return (
<PageWrapper app={app} title={t('account:oneMomentPlease')} layout={BareLayout} footer={false}>
<div className="max-w-md flex flex-col items-center m-auto justify-center h-screen text-center">
<h1 className="text-center">{t('account:oneMomentPlease')}</h1>
<p className="text-center">
<Spinner />
</p>
</div>
</PageWrapper>
)
}
export default ConfirmSignUpPage
export async function getStaticProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ns)),
},
}
}
export async function getStaticPaths() {
return {
paths: [],
fallback: true,
}
}