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

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 nl from '../../../../public/locales/nl/emailchange.json' assert { type: 'json' }
export const emailChange = {
export const emailchange = {
html: wrap.html(`
${headingRow.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 { loginLink, translations as loginLinkTranslations } from './loginlink/index.mjs'
import { newsletterSub, translations as newsletterSubTranslations } from './newslettersub/index.mjs'
import { passwordReset, translations as passwordResetTranslations } from './passwordreset/index.mjs'
import { loginlink, translations as loginlinkTranslations } from './loginlink/index.mjs'
import { newslettersub, translations as newslettersubTranslations } from './newslettersub/index.mjs'
import { passwordreset, translations as passwordresetTranslations } from './passwordreset/index.mjs'
import { signup, translations as signupTranslations } from './signup/index.mjs'
import { signupAea, translations as signupAeaTranslations } from './signup-aea/index.mjs'
import { signupAed, translations as signupAedTranslations } from './signup-aed/index.mjs'
import { signupaea, translations as signupaeaTranslations } from './signup-aea/index.mjs'
import { signupaed, translations as signupaedTranslations } from './signup-aed/index.mjs'
// Shared translations
import en from '../../../public/locales/en/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 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 = {
emailChange,
emailchange,
goodbye,
loginLink,
newsletterSub,
passwordReset,
loginlink,
newslettersub,
passwordreset,
signup,
'signup-aea': signupAea,
'signup-aed': signupAed,
'signup-aea': signupaea,
'signup-aed': signupaed,
}
export const translations = {
emailChange: emailChangeTranslations,
emailchange: emailchangeTranslations,
goodbye: goodbyeTranslations,
loginLink: loginLinkTranslations,
newsletterSub: newsletterSubTranslations,
passwordReset: passwordResetTranslations,
loginlink: loginlinkTranslations,
newslettersub: newslettersubTranslations,
passwordreset: passwordresetTranslations,
signup: signupTranslations,
'signup-aea': signupAeaTranslations,
'signup-aed': signupAedTranslations,
'signup-aea': signupaeaTranslations,
'signup-aed': signupaedTranslations,
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 nl from '../../../../public/locales/nl/loginlink.json' assert { type: 'json' }
export const loginLink = {
export const loginlink = {
html: wrap.html(`
${headingRow}
<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 nl from '../../../../public/locales/nl/newslettersub.json' assert { type: 'json' }
export const newsletterSub = {
export const newslettersub = {
html: wrap.html(`
${headingRow.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 nl from '../../../../public/locales/nl/passwordreset.json' assert { type: 'json' }
export const passwordReset = {
export const passwordreset = {
html: wrap.html(`
${headingRow.html}
<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' }
// aea = Account Exists and is Active
export const signupAea = {
export const signupaea = {
html: wrap.html(`
${headingRow.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' }
// aed = Account Exists but is Disabled
export const signupAed = {
export const signupaed = {
html: wrap.html(`
${headingRow.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
* 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
if (!template || !to || typeof templates[template] === 'undefined') {
log.warn(`Tried to email invalid template: ${template}`)
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}`)
// Load template
@ -62,7 +70,7 @@ async function sendEmailViaAwsSes(config, { template, to, language = 'en', repla
},
Destination: {
ToAddresses: [to],
CcAddresses: config.aws.ses.cc || [],
CcAddresses: cc,
BccAddresses: config.aws.ses.bcc || [],
},
FeedbackForwardingEmailAddress: config.aws.ses.feedback,

View file

@ -13,9 +13,9 @@ yourProfile: Your profile
yourPatterns: Your patterns
yourSets: Your measurement sets
logout: Log out
politeOhCrap: Oh fiddlesticks
bio: Bio
email: Email address
email: E-mail address
github: Github username
img: Profile image
username: Username
@ -124,6 +124,10 @@ usernameNotAvailable: Username is not available
# email
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
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'
// Components
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 }) => {
const backend = useBackend(app)
const { t } = useTranslation(ns)
const toast = useToast()
const [email, setEmail] = useState(app.account.email)
const [changed, setChanged] = useState(false)
const save = async () => {
const result = await backend.updateAccount({ email })
if (result) toast.for.settingsSaved()
else toast.for.backendError()
setChanged(true)
}
const valid = (validateEmail(email) && validateTld(email)) || false
@ -27,6 +30,13 @@ export const EmailSettings = ({ app, title = false }) => {
return (
<>
{title ? <h2 className="text-4xl">{t('emailTitle')}</h2> : null}
{changed ? (
<Popout note>
<h3>{t('oneMoreThing')}</h3>
<p>{t('emailChangeConfirmation')}</p>
</Popout>
) : (
<>
<div className="flex flex-row items-center mt-4">
<input
value={email}
@ -35,9 +45,15 @@ export const EmailSettings = ({ app, title = false }) => {
type="text"
/>
</div>
<button className="btn mt-4 btn-primary w-full" onClick={save} disabled={!valid}>
<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} />
</>
)

View file

@ -69,7 +69,7 @@ export function useBackend(app) {
*/
backend.loadConfirmation = async ({ 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
}

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,
}
}