feat(backend): Implement email change flow
This commit is contained in:
parent
6a3a14a6bd
commit
4c3d3a5019
14 changed files with 202 additions and 47 deletions
|
@ -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)),
|
||||
|
|
|
@ -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:'
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
111
sites/org/pages/confirm/emailchange/[...confirmation].mjs
Normal file
111
sites/org/pages/confirm/emailchange/[...confirmation].mjs
Normal 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,
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue