diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 07b4ab3954f..7ade5654456 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -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)), diff --git a/sites/backend/src/templates/email/emailchange/emailchange.en.yaml b/sites/backend/src/templates/email/emailchange/emailchange.en.yaml index ee7be3539ed..5d9931e6140 100644 --- a/sites/backend/src/templates/email/emailchange/emailchange.en.yaml +++ b/sites/backend/src/templates/email/emailchange/emailchange.en.yaml @@ -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:' diff --git a/sites/backend/src/templates/email/emailchange/index.mjs b/sites/backend/src/templates/email/emailchange/index.mjs index 1bacef59459..5ccc98d311f 100644 --- a/sites/backend/src/templates/email/emailchange/index.mjs +++ b/sites/backend/src/templates/email/emailchange/index.mjs @@ -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} diff --git a/sites/backend/src/templates/email/index.mjs b/sites/backend/src/templates/email/index.mjs index d504e76c8fc..4bb3913f87c 100644 --- a/sites/backend/src/templates/email/index.mjs +++ b/sites/backend/src/templates/email/index.mjs @@ -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 }, } diff --git a/sites/backend/src/templates/email/loginlink/index.mjs b/sites/backend/src/templates/email/loginlink/index.mjs index caf593fb200..897e33e9032 100644 --- a/sites/backend/src/templates/email/loginlink/index.mjs +++ b/sites/backend/src/templates/email/loginlink/index.mjs @@ -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} diff --git a/sites/backend/src/templates/email/newslettersub/index.mjs b/sites/backend/src/templates/email/newslettersub/index.mjs index 663ac7fe29a..0c2eb10aaa9 100644 --- a/sites/backend/src/templates/email/newslettersub/index.mjs +++ b/sites/backend/src/templates/email/newslettersub/index.mjs @@ -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} diff --git a/sites/backend/src/templates/email/passwordreset/index.mjs b/sites/backend/src/templates/email/passwordreset/index.mjs index 312dd444869..d91befd3d5f 100644 --- a/sites/backend/src/templates/email/passwordreset/index.mjs +++ b/sites/backend/src/templates/email/passwordreset/index.mjs @@ -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} diff --git a/sites/backend/src/templates/email/signup-aea/index.mjs b/sites/backend/src/templates/email/signup-aea/index.mjs index bce323c2933..7e49af81b2c 100644 --- a/sites/backend/src/templates/email/signup-aea/index.mjs +++ b/sites/backend/src/templates/email/signup-aea/index.mjs @@ -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} diff --git a/sites/backend/src/templates/email/signup-aed/index.mjs b/sites/backend/src/templates/email/signup-aed/index.mjs index 95f126f2817..9921668c794 100644 --- a/sites/backend/src/templates/email/signup-aed/index.mjs +++ b/sites/backend/src/templates/email/signup-aed/index.mjs @@ -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} diff --git a/sites/backend/src/utils/email.mjs b/sites/backend/src/utils/email.mjs index 406e4d17ec3..f3de8ad07ff 100644 --- a/sites/backend/src/utils/email.mjs +++ b/sites/backend/src/utils/email.mjs @@ -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, diff --git a/sites/org/components/account/account.en.yaml b/sites/org/components/account/account.en.yaml index 95bff71709d..03d3188f894 100644 --- a/sites/org/components/account/account.en.yaml +++ b/sites/org/components/account/account.en.yaml @@ -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. diff --git a/sites/org/components/account/email.mjs b/sites/org/components/account/email.mjs index 2322f126822..09814a602a3 100644 --- a/sites/org/components/account/email.mjs +++ b/sites/org/components/account/email.mjs @@ -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,17 +30,30 @@ export const EmailSettings = ({ app, title = false }) => { return ( <> {title ?

{t('emailTitle')}

: null} -
- setEmail(evt.target.value)} - className="input w-full input-bordered flex flex-row" - type="text" - /> -
- + {changed ? ( + +

{t('oneMoreThing')}

+

{t('emailChangeConfirmation')}

+
+ ) : ( + <> +
+ setEmail(evt.target.value)} + className="input w-full input-bordered flex flex-row" + type="text" + /> +
+ + + )} ) diff --git a/sites/org/hooks/useBackend.mjs b/sites/org/hooks/useBackend.mjs index aef3bb48923..bd566799952 100644 --- a/sites/org/hooks/useBackend.mjs +++ b/sites/org/hooks/useBackend.mjs @@ -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 } diff --git a/sites/org/pages/confirm/emailchange/[...confirmation].mjs b/sites/org/pages/confirm/emailchange/[...confirmation].mjs new file mode 100644 index 00000000000..ff3b4f22161 --- /dev/null +++ b/sites/org/pages/confirm/emailchange/[...confirmation].mjs @@ -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 ( + +
+

{t('account:politeOhCrap')}

+ +

{t('account:vagueError')}

+
+ + + {t('contactSupport')} + +
+
+
+ ) + + return ( + +
+

{t('account:oneMomentPlease')}

+

+ +

+
+
+ ) +} + +export default ConfirmSignUpPage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, ns)), + }, + } +} + +export async function getStaticPaths() { + return { + paths: [], + fallback: true, + } +}