diff --git a/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx b/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx index 55398802b4..f54b1c437c 100644 --- a/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx +++ b/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx @@ -1,197 +1,28 @@ -import React, { useState } from 'react'; +import React from 'react'; -import { Button, Callout, Card } from '@blueprintjs/core'; -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; +import { AnchorButton, Card } from '@blueprintjs/core'; -import { apiFetch } from 'client/utils/apiFetch'; -import { InputField } from 'components'; - -import './accountSecuritySettings.scss'; - -const stripHTTPError = (error: string) => { - return error.replace(/^HTTP Error \d+: /, ''); +type Props = { + userEmail: string; + accountUrl: string; }; -const AccountSecuritySettings = ({ userEmail }: { userEmail: string }) => { - const [currentPassword, setCurrentPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordIsLoading, setPasswordIsLoading] = useState(false); - const [passwordError, setPasswordError] = useState(); - const [passwordSuccess, setPasswordSuccess] = useState(false); - - const [newEmail, setNewEmail] = useState(''); - const [emailPassword, setEmailPassword] = useState(''); - const [emailIsLoading, setEmailIsLoading] = useState(false); - const [emailError, setEmailError] = useState(); - const [submittedEmail, setSubmittedEmail] = useState(); - - const handlePasswordChange = (evt: React.FormEvent) => { - evt.preventDefault(); - - if (newPassword !== confirmPassword) { - setPasswordError('New passwords do not match'); - return; - } - - if (newPassword.length < 8) { - setPasswordError('Password must be at least 8 characters'); - return; - } - - setPasswordIsLoading(true); - setPasswordError(undefined); - setPasswordSuccess(false); - - apiFetch('/api/account/password', { - method: 'PUT', - body: JSON.stringify({ - currentPassword: SHA3(currentPassword).toString(encHex), - newPassword: SHA3(newPassword).toString(encHex), - }), - }) - .then(() => { - setPasswordIsLoading(false); - setPasswordSuccess(true); - setCurrentPassword(''); - setNewPassword(''); - setConfirmPassword(''); - }) - .catch((err) => { - setPasswordIsLoading(false); - setPasswordError(stripHTTPError(err.message) || 'Failed to change password'); - }); - }; - - const handleEmailChange = (evt: React.FormEvent) => { - evt.preventDefault(); - - if (!newEmail || !emailPassword) { - setEmailError('Please enter both email and password'); - return; - } - - const submittedEmailValue = newEmail.toLowerCase().trim(); - - setEmailIsLoading(true); - setEmailError(undefined); - setSubmittedEmail(undefined); - - apiFetch('/api/account/email', { - method: 'POST', - body: JSON.stringify({ - newEmail: submittedEmailValue, - password: SHA3(emailPassword).toString(encHex), - }), - }) - .then(() => { - setEmailIsLoading(false); - setSubmittedEmail(submittedEmailValue); - setNewEmail(''); - setEmailPassword(''); - }) - .catch((err) => { - setEmailIsLoading(false); - setEmailError(stripHTTPError(err.message) || 'Failed to initiate email change'); - }); - }; - - const isPasswordFormValid = - currentPassword && newPassword && confirmPassword && newPassword === confirmPassword; - - const isEmailFormValid = newEmail && emailPassword; - +const AccountSecuritySettings = ({ userEmail, accountUrl }: Props) => { return ( -
- -
Change password
-

Enter your current password and choose a new password for your account.

- {passwordSuccess && ( - - Your password has been successfully changed. - - )} -
- setCurrentPassword(e.target.value)} - /> - setNewPassword(e.target.value)} - helperText="Must be at least 8 characters" - /> - setConfirmPassword(e.target.value)} - /> - -
+ +
Email & password
+

+ Your email address is {userEmail}. Your login details, including + your email address and password, are managed through your Knowledge Futures (KF) + account. +

+ +
); }; diff --git a/client/components/AccountSecuritySettings/accountSecuritySettings.scss b/client/components/AccountSecuritySettings/accountSecuritySettings.scss deleted file mode 100644 index fd7614fd68..0000000000 --- a/client/components/AccountSecuritySettings/accountSecuritySettings.scss +++ /dev/null @@ -1,10 +0,0 @@ -.account-security-settings { - .success-message { - margin-bottom: 1em; - } - - form { - max-width: 500px; - } -} - diff --git a/client/containers/App/paths.ts b/client/containers/App/paths.ts index e7625cb62d..f90fdd0403 100644 --- a/client/containers/App/paths.ts +++ b/client/containers/App/paths.ts @@ -23,7 +23,6 @@ import { DashboardSettings, DashboardSubmissions, DashboardSubmissionWorkflow, - EmailChange, Explore, HubData, HubDirectory, @@ -199,11 +198,6 @@ export default (viewData, locationData, chunkName) => { hideNav: true, hideFooter: true, }, - EmailChange: { - ActiveComponent: EmailChange, - hideNav: true, - hideFooter: true, - }, Pricing: { ActiveComponent: Pricing, hideNav: true, diff --git a/client/containers/EmailChange/EmailChange.tsx b/client/containers/EmailChange/EmailChange.tsx deleted file mode 100644 index 0a0aa76a79..0000000000 --- a/client/containers/EmailChange/EmailChange.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; - -import { AnchorButton, Spinner } from '@blueprintjs/core'; - -import { apiFetch } from 'client/utils/apiFetch'; -import { GridWrapper } from 'components'; - -import './emailChange.scss'; - -type Props = { - emailChangeData: { - tokenIsValid: boolean; - token: string; - newEmail: string; - }; -}; - -const EmailChange = (props: Props) => { - const { emailChangeData } = props; - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(); - const [success, setSuccess] = useState(false); - - const handleEmailChange = useCallback(() => { - setIsLoading(true); - setError(undefined); - - return apiFetch('/api/account/email', { - method: 'PUT', - body: JSON.stringify({ - token: emailChangeData.token, - }), - }) - .then(() => { - setIsLoading(false); - setSuccess(true); - }) - .catch((err) => { - setIsLoading(false); - setError(err.message || 'Failed to change email'); - }); - }, [emailChangeData.token]); - - useEffect(() => { - if (emailChangeData.tokenIsValid && emailChangeData.newEmail) { - handleEmailChange(); - } - }, [emailChangeData.tokenIsValid, emailChangeData.newEmail, handleEmailChange]); - - return ( -
- - {!emailChangeData.tokenIsValid && ( -
-
- โœ• -
-

Invalid Link

-

- This email change link is invalid or has expired. Please request a new - one from your{' '} - privacy settings. -

-
- )} - - {emailChangeData.tokenIsValid && isLoading && ( -
-
- -
-

Confirming Email Change

-

- Please wait while we update your email address... -

-
- )} - - {emailChangeData.tokenIsValid && error && ( -
-
- โœ• -
-

Change Failed

-

- {error}. Try requesting a new email change from your{' '} - privacy settings. -

-
- )} - - {success && ( -
-
- โœ“ -
-

Email Change Successful

-

- Your email has been successfully changed to{' '} - {emailChangeData.newEmail} -

- -
- )} -
-
- ); -}; - -export default EmailChange; diff --git a/client/containers/EmailChange/emailChange.scss b/client/containers/EmailChange/emailChange.scss deleted file mode 100644 index b970d4b0df..0000000000 --- a/client/containers/EmailChange/emailChange.scss +++ /dev/null @@ -1,84 +0,0 @@ -#email-change-container { - padding: 4em 0; - min-height: 60vh; - display: flex; - align-items: center; - - .email-change-content { - text-align: center; - padding: 3em 2em; - max-width: 500px; - margin: 0 auto; - - .icon-container { - margin: 0 auto 1.5em; - width: 80px; - height: 80px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - - &.success { - background: linear-gradient(135deg, #48aff0 0%, #2b95d6 100%); - box-shadow: 0 4px 12px rgba(72, 175, 240, 0.3); - - .icon-wrapper { - color: white; - font-size: 48px; - font-weight: bold; - line-height: 1; - } - } - - &.error { - background: linear-gradient(135deg, #f55656 0%, #db3737 100%); - box-shadow: 0 4px 12px rgba(245, 86, 86, 0.3); - - .icon-wrapper { - color: white; - font-size: 48px; - font-weight: bold; - line-height: 1; - } - } - - &.loading { - background: linear-gradient(135deg, #8abbff 0%, #4c90f0 100%); - box-shadow: 0 4px 12px rgba(138, 187, 255, 0.3); - } - } - - h1 { - font-size: 28px; - font-weight: 600; - margin-bottom: 0.75em; - color: #1c2127; - } - - .description { - font-size: 16px; - line-height: 1.6; - color: #5c7080; - margin-bottom: 2em; - - strong { - color: #1c2127; - font-weight: 600; - } - - a { - color: #2b95d6; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } - - .action-button { - margin-top: 1em; - } - } -} diff --git a/client/containers/EmailChange/index.ts b/client/containers/EmailChange/index.ts deleted file mode 100644 index 7c1c7e199c..0000000000 --- a/client/containers/EmailChange/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EmailChange'; diff --git a/client/containers/Legal/DeleteAccount.tsx b/client/containers/Legal/DeleteAccount.tsx index eabdcf1c10..30d6c7769e 100644 --- a/client/containers/Legal/DeleteAccount.tsx +++ b/client/containers/Legal/DeleteAccount.tsx @@ -73,7 +73,7 @@ const DeleteAccount = () => {

{isLoadingAudit && } {audit && audit.soleAdminCommunities.length > 0 && ( - + <>

You cannot delete your account because you are the only admin of{' '} {audit.soleAdminCommunities.length}{' '} @@ -100,7 +100,7 @@ const DeleteAccount = () => { , or delete {audit.soleAdminCommunities.length === 1 ? 'it' : 'them'}{' '} first.

-
+ )} {audit && audit.soleManagerHubs.length > 0 && ( diff --git a/client/containers/Legal/Legal.tsx b/client/containers/Legal/Legal.tsx index e67b7e0b58..9fd593a2fa 100644 --- a/client/containers/Legal/Legal.tsx +++ b/client/containers/Legal/Legal.tsx @@ -25,6 +25,7 @@ type Props = { integrations: Integration[]; userNotificationPreferences?: UserNotificationPreferences; userEmail: string; + accountUrl: string; accountExports?: { id: string; createdAt: string; @@ -98,6 +99,7 @@ const Legal = (props: Props) => { isLoggedIn={!!loginData.id} integrations={props.integrations} userEmail={props.userEmail} + accountUrl={props.accountUrl} accountExports={props.accountExports} adminCommunities={props.adminCommunities} userNotificationPreferences={userNotificationPreferences} diff --git a/client/containers/Legal/PrivacySettings.tsx b/client/containers/Legal/PrivacySettings.tsx index 573b794b5e..f9629bdfa0 100644 --- a/client/containers/Legal/PrivacySettings.tsx +++ b/client/containers/Legal/PrivacySettings.tsx @@ -17,6 +17,7 @@ import ExportAccountData from './ExportAccountData'; type PrivacySettingsProps = { integrations: types.Integration[]; userEmail: string; + accountUrl: string; isLoggedIn: boolean; accountExports?: { id: string; @@ -150,7 +151,10 @@ const PrivacySettings = (props: PrivacySettingsProps) => { )} - + )} diff --git a/client/containers/index.ts b/client/containers/index.ts index 1690cc1295..2b7005ea35 100644 --- a/client/containers/index.ts +++ b/client/containers/index.ts @@ -24,7 +24,6 @@ export { default as DashboardReviews } from './DashboardReviews/DashboardReviews export { default as DashboardSettings } from './DashboardSettings/DashboardSettings'; export { default as DashboardSubmissions } from './DashboardSubmissions/DashboardSubmissions'; export { default as DashboardSubmissionWorkflow } from './DashboardSubmissionWorkflow/DashboardSubmissionWorkflow'; -export { default as EmailChange } from './EmailChange/EmailChange'; export { default as Explore } from './Explore/Explore'; export { default as HubData } from './HubData/HubData'; export { default as HubDirectory } from './HubDirectory/HubDirectory'; diff --git a/server/kf/auth.ts b/server/kf/auth.ts index 59be35e744..373c520139 100644 --- a/server/kf/auth.ts +++ b/server/kf/auth.ts @@ -15,6 +15,7 @@ export { generateCodeChallenge, generateCodeVerifier, initOidc, + OIDC_ACCOUNT_URL, OIDC_CLIENT_ID, OIDC_ISSUER_URL, type OIDCOrg as KFOrg, diff --git a/server/kf/oidc.server.ts b/server/kf/oidc.server.ts index 734e472507..0544a1e423 100644 --- a/server/kf/oidc.server.ts +++ b/server/kf/oidc.server.ts @@ -29,6 +29,10 @@ const OIDC_ORGS_CLAIM = process.env.OIDC_ORGS_CLAIM ?? 'https://knowledgefutures const APP_URL = process.env.APP_URL ?? 'http://localhost:9876'; const REDIRECT_URI = `${APP_URL}/auth/callback`; +// Browser-facing URL of the KF Account app, where users manage their +// profile, email, and password. Distinct from the OIDC issuer. +const OIDC_ACCOUNT_URL = process.env.OIDC_ACCOUNT_URL ?? 'http://localhost:3001'; + // --- OIDC Discovery --- interface OIDCDiscovery { @@ -275,6 +279,7 @@ export { OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ORGS_CLAIM, + OIDC_ACCOUNT_URL, APP_URL, REDIRECT_URI, }; diff --git a/server/routes/legal.tsx b/server/routes/legal.tsx index de94f644b8..98dd7c1f7f 100644 --- a/server/routes/legal.tsx +++ b/server/routes/legal.tsx @@ -5,6 +5,7 @@ import { Router } from 'express'; import { Legal } from 'containers'; import { getAdminCommunitiesForUser } from 'server/community/queries'; import Html from 'server/Html'; +import { OIDC_ACCOUNT_URL } from 'server/kf/auth'; import { getAccountExports } from 'server/user/account'; import { getOrCreateUserNotificationPreferences } from 'server/userNotificationPreferences/queries'; import { handleErrors } from 'server/utils/errors'; @@ -62,6 +63,7 @@ router.get('/legal/:tab', async (req, res, next) => { integrations, userNotificationPreferences, userEmail: isSettingsTab ? req.user?.email : undefined, + accountUrl: OIDC_ACCOUNT_URL, accountExports: isSettingsTab ? accountExports : undefined, adminCommunities: isSettingsTab ? adminCommunities : undefined, }} diff --git a/server/user/__tests__/email.test.ts b/server/user/__tests__/email.test.ts deleted file mode 100644 index 00714781f2..0000000000 --- a/server/user/__tests__/email.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; -import { vi } from 'vitest'; - -import { EmailChangeToken, User } from 'server/models'; -import { transporter } from 'server/utils/email/transport'; -import { login, modelize, setup, teardown } from 'stubstub'; - -const uuid = crypto.randomUUID(); -const email1 = `${uuid}@example.com`; -const email2 = `${uuid}2@example.com`; - -const models = modelize` - User user { - email: ${email1} - password: "password" - } - User otherUser { - email: ${email2} - password: "password" - } -`; - -setup(beforeAll, async () => { - await models.resolve(); - - // mock transporter so we don't actually send emails in tests - vi.spyOn(transporter, 'sendMail').mockImplementation( - () => Promise.resolve({ messageId: 'test-id' }) as any, - ); -}); - -beforeEach(async () => { - // clean up any email change tokens before each test to avoid interference - await EmailChangeToken.destroy({ where: {} }); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -teardown(afterAll); - -describe('/api/account/email', () => { - describe('POST /api/account/email - initiate email change', () => { - it('allows a user to request an email change', async () => { - const { user } = models; - const agent = await login(user); - const newEmail = 'newemail@example.com'; - - const response = await agent - .post('/api/account/email') - .send({ - newEmail, - password: SHA3('password').toString(encHex), - }) - .expect(200); - - expect(response.body.success).toBe(true); - - // verify token was created - const token = await EmailChangeToken.findOne({ where: { userId: user.id } }); - expect(token).toBeTruthy(); - expect(token?.newEmail).toBe(newEmail); - expect(token?.usedAt).toBeNull(); - }); - - it('invalidates old tokens when requesting a new email change', async () => { - const { user } = models; - const agent = await login(user); - - await agent - .post('/api/account/email') - .send({ - newEmail: 'first@example.com', - password: SHA3('password').toString(encHex), - }) - .expect(200); - - const firstToken = await EmailChangeToken.findOne({ - where: { userId: user.id }, - order: [['createdAt', 'ASC']], - }); - expect(firstToken?.usedAt).toBeNull(); - - // create second token - await agent - .post('/api/account/email') - .send({ - newEmail: 'second@example.com', - password: SHA3('password').toString(encHex), - }) - .expect(200); - - // verify first token is now marked as used - await firstToken?.reload(); - expect(firstToken?.usedAt).toBeDefined(); - - // verify second token is not used - const secondToken = await EmailChangeToken.findOne({ - where: { userId: user.id, usedAt: null }, - }); - expect(secondToken).toBeTruthy(); - expect(secondToken?.newEmail).toBe('second@example.com'); - }); - - it('rejects email change with incorrect password', async () => { - const { user } = models; - const agent = await login(user); - - await agent - .post('/api/account/email') - .send({ - newEmail: 'newemail@example.com', - password: SHA3('wrongpassword').toString(encHex), - }) - .expect(403); - }); - - it('rejects email change to an already-used email', async () => { - const { user, otherUser } = models; - const agent = await login(user); - - await agent - .post('/api/account/email') - .send({ - newEmail: otherUser.email, - password: SHA3('password').toString(encHex), - }) - .expect(400); - }); - - it('requires authentication to request email change', async () => { - const agent = await login(); - - await agent - .post('/api/account/email') - .send({ - newEmail: 'newemail@example.com', - password: SHA3('password').toString(encHex), - }) - .expect(403); - }); - }); - - // this is a bit finicky and requirs the tests to be in the correct order, bc changing email makes logging in again require a different email - describe('PUT /api/account/email - complete email change', () => { - it('rejects invalid token', async () => { - const { user } = models; - const agent = await login(user); - - await agent.put('/api/account/email').send({ token: 'invalid-token' }).expect(400); - }); - - it('rejects expired token', async () => { - const { user } = models; - const agent = await login(user); - - // create an expired token directly - const expiredToken = await EmailChangeToken.create({ - userId: user.id, - newEmail: 'expired@example.com', - token: 'expired-token-hash', - expiresAt: new Date(Date.now() - 1000), - usedAt: null, - }); - - await agent - .put('/api/account/email') - .send({ token: expiredToken.token }) - .expect((res) => res.body.message === 'Email change link has expired') - .expect(400); - }); - - it('rejects changing to an email that another user already has', async () => { - const { user, otherUser } = models; - const agent = await login(otherUser); - - // create a token for changing to otherUser's email - const token = await EmailChangeToken.create({ - userId: otherUser.id, - newEmail: user.email, - token: 'collision-token', - expiresAt: new Date(Date.now() + 1000 * 60 * 60), - usedAt: null, - }); - - await agent.put('/api/account/email').send({ token: token.token }).expect(400); - }); - - it('allows completing email change with valid token', async () => { - const { user } = models; - const agent = await login(user); - - const newEmail = `${crypto.randomUUID()}-completed@example.com`; - - await agent - .post('/api/account/email') - .send({ - newEmail, - password: SHA3('password').toString(encHex), - }) - .expect(200); - - const token = await EmailChangeToken.findOne({ - where: { userId: user.id, usedAt: null }, - }); - expect(token).toBeTruthy(); - - // complete email change - const response = await agent - .put('/api/account/email') - .send({ token: token?.token }) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.newEmail).toBe(newEmail); - - // verify email was changed - const updatedUser = await User.findOne({ where: { id: user.id } }); - expect(updatedUser?.email).toBe(newEmail); - - // verify token is marked as used - await token?.reload(); - expect(token?.usedAt).not.toBeNull(); - }); - - it('rejects already-used token', async () => { - const { otherUser } = models; - const agent = await login(otherUser); - const newEmail = `${crypto.randomUUID()}-usedtoken@example.com`; - - await agent - .post('/api/account/email') - .send({ newEmail, password: SHA3('password').toString(encHex) }) - .expect(200); - - const token = await EmailChangeToken.findOne({ - where: { userId: otherUser.id }, - order: [['createdAt', 'DESC']], - }); - - await agent.put('/api/account/email').send({ token: token?.token }).expect(200); - - // try to use same token again - await agent.put('/api/account/email').send({ token: token?.token }).expect(400); - }); - }); -}); diff --git a/server/user/__tests__/password.test.ts b/server/user/__tests__/password.test.ts deleted file mode 100644 index b6eeb40aca..0000000000 --- a/server/user/__tests__/password.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; - -import { User } from 'server/models'; -import { login, modelize, setup, teardown } from 'stubstub'; - -const email = `${crypto.randomUUID()}@example.com`; - -const models = modelize` - User user { - email: ${email} - password: "password" - } -`; - -setup(beforeAll, models.resolve); -teardown(afterAll); - -describe('/api/account/password', () => { - it('rejects password change with incorrect current password', async () => { - const { user } = models; - const agent = await login(user); - - await agent - .put('/api/account/password') - .send({ - currentPassword: SHA3('wrongpassword').toString(encHex), - newPassword: SHA3('newpassword123').toString(encHex), - }) - .expect(403); - }); - - it('requires authentication to change password', async () => { - const agent = await login(); - - await agent - .put('/api/account/password') - .send({ - currentPassword: SHA3('anypassword').toString(encHex), - newPassword: SHA3('newpassword123').toString(encHex), - }) - .expect(403); - }); - - it('allows a user to change their password', async () => { - const { user } = models; - const agent = await login(user); - const oldPassword = 'password'; - const newPassword = 'newpassword123'; - - await agent - .put('/api/account/password') - .send({ - currentPassword: SHA3(oldPassword).toString(encHex), - newPassword: SHA3(newPassword).toString(encHex), - }) - .expect(200); - - // verify the password was actually changed by trying to log in with new password - const updatedUser = await User.findOne({ where: { id: user.id } }); - expect(updatedUser).toBeTruthy(); - }); -}); diff --git a/server/user/account.ts b/server/user/account.ts index 7995b9c89a..2c763c62ad 100644 --- a/server/user/account.ts +++ b/server/user/account.ts @@ -1,14 +1,11 @@ import { initServer } from '@ts-rest/express'; import { Op } from 'sequelize'; -import { promisify } from 'util'; -import { EmailChangeToken, User, WorkerTask } from 'server/models'; +import { User, WorkerTask } from 'server/models'; import { authenticate } from 'server/utils/authenticate'; -import { sendEmailChangeEmail } from 'server/utils/email'; import { logout } from 'server/utils/logout'; import { addWorkerTask } from 'server/utils/workers'; import { contract } from 'utils/api/contract'; -import { generateHash } from 'utils/hashes'; import { destroyUser, getUserDeletionAudit } from './destroyUser'; @@ -18,169 +15,6 @@ const ONE_DAY = 1000 * 60 * 60 * 24; const MAX_DAILY_ACCOUNT_EXPORTS = 2; export const accountServer = s.router(contract.account, { - changePassword: async ({ req, res, body }) => { - const userId = req.user?.id; - - if (!userId) { - return { - status: 403, - body: { message: 'Must be logged in to change password' }, - }; - } - - const userData = await User.findOne({ where: { id: userId } }); - - if (!userData) { - return { - status: 403, - body: { message: 'User not found' }, - }; - } - - try { - await authenticate(userData, body.currentPassword); - } catch (_error) { - return { status: 403, body: { message: 'Current password is incorrect' } }; - } - - try { - const setPassword = promisify(userData.setPassword.bind(userData)); - const updatedUser = await setPassword(body.newPassword); - - await User.update( - { - hash: updatedUser?.dataValues.hash, - salt: updatedUser?.dataValues.salt, - passwordDigest: 'sha512', - }, - { where: { id: userId } }, - ); - - // force logout after password change - logout(req, res); - - return { status: 200, body: { success: true } }; - } catch (_error) { - return { status: 403, body: { message: 'Failed to change password' } }; - } - }, - - initiateEmailChange: async ({ req, body }) => { - const userId = req.user?.id; - - if (!userId) { - return { - status: 403, - body: { message: 'Must be logged in to change email' }, - }; - } - - const newEmail = body.newEmail.toLowerCase().trim(); - - const existingUser = await User.findOne({ where: { email: newEmail } }); - if (existingUser) { - return { - status: 400, - body: { message: 'An account with this email already exists' }, - }; - } - - const userData = await User.findOne({ where: { id: userId } }); - - if (!userData) { - return { - status: 403, - body: { message: 'User not found' }, - }; - } - - try { - await authenticate(userData, body.password); - } catch (_error) { - return { status: 403, body: { message: 'Password is incorrect' } }; - } - - try { - const token = generateHash(); - const expiresAt = new Date(Date.now() + ONE_DAY); - - // invalidate any existing unused tokens for this user - await EmailChangeToken.update( - { usedAt: new Date() }, - { - where: { - userId, - usedAt: null, - }, - }, - ); - - // create new email change token - await EmailChangeToken.create({ - userId, - newEmail, - token, - expiresAt, - usedAt: null, - }); - - await sendEmailChangeEmail({ - toEmail: newEmail, - changeUrl: `https://${req.hostname}/email-change/${token}`, - }); - - return { status: 200, body: { success: true } }; - } catch (_error) { - return { status: 400, body: { message: 'Failed to initiate email change' } }; - } - }, - - completeEmailChange: async ({ req, res, body }) => { - const { token } = body; - const currentTime = Date.now(); - - const emailChangeToken = await EmailChangeToken.findOne({ - where: { token, usedAt: null }, - }); - - if (!emailChangeToken) { - return { - status: 400, - body: { message: 'Invalid or expired email change link' }, - }; - } - - if (Number(emailChangeToken.expiresAt) < currentTime) { - return { - status: 400, - body: { message: 'Email change link has expired' }, - }; - } - - const newEmail = emailChangeToken.newEmail.toLowerCase().trim(); - const userId = emailChangeToken.userId; - - const existingUser = await User.findOne({ where: { email: newEmail } }); - if (existingUser && existingUser.id !== userId) { - return { - status: 400, - body: { message: 'An account with this email already exists' }, - }; - } - - await EmailChangeToken.update( - { usedAt: new Date() }, - { where: { id: emailChangeToken.id } }, - ); - - await User.update({ email: newEmail }, { where: { id: userId } }); - - // force logout after email change - logout(req, res); - - return { status: 200, body: { success: true, newEmail } }; - }, - deletionAudit: async ({ req }) => { const userId = req.user?.id; diff --git a/server/utils/email/reset.ts b/server/utils/email/reset.ts index 9fbb02c4e7..eb8331a293 100644 --- a/server/utils/email/reset.ts +++ b/server/utils/email/reset.ts @@ -21,21 +21,3 @@ export const sendPasswordResetEmail = ({ toEmail, resetUrl }) => { replyTo: 'hello@pubpub.org', }); }; - -export const sendEmailChangeEmail = ({ toEmail, changeUrl }) => { - return sendEmail({ - to: [toEmail], - subject: 'Confirm Email Change ยท PubPub', - text: stripIndent(` - We've received a request to change your email address to this email. Follow the link below to confirm this change. - - ${changeUrl} - - If you did not request this change, please ignore this email. - - Sincerely, - PubPub Support - `), - replyTo: 'hello@pubpub.org', - }); -}; diff --git a/utils/api/contracts/account.ts b/utils/api/contracts/account.ts index 5f49adf65c..280a7196d0 100644 --- a/utils/api/contracts/account.ts +++ b/utils/api/contracts/account.ts @@ -5,74 +5,7 @@ import { z } from 'zod'; extendZodWithOpenApi(z); -const passwordChangeSchema = z - .object({ - currentPassword: z.string().openapi({ - description: 'The SHA3 hash of the users current password', - }), - newPassword: z.string().openapi({ - description: 'The SHA3 hash of the users new password', - }), - }) - .openapi({ - description: - 'Password validation (minimum length, etc) should be done client-side before hashing', - }); - -const emailChangeInitiateSchema = z.object({ - newEmail: z.string().email('Must be a valid email address'), - password: z.string().openapi({ - description: 'The SHA3 hash of the users password', - }), -}); - -const emailChangeCompleteSchema = z.object({ - token: z.string().min(1, 'Token is required'), -}); - -const successResponseSchema = z.object({ - success: z.boolean(), -}); - -const emailChangeCompleteResponseSchema = successResponseSchema.extend({ - newEmail: z.string().email(), -}); - export const accountRouter = { - changePassword: { - method: 'PUT', - path: '/api/account/password', - summary: 'Change password', - description: 'Change the current users password', - body: passwordChangeSchema, - responses: { - 200: successResponseSchema, - 403: z.object({ message: z.string() }), - }, - }, - initiateEmailChange: { - method: 'POST', - path: '/api/account/email', - summary: 'Initiate email change', - description: 'Request an email change for the current user', - body: emailChangeInitiateSchema, - responses: { - 200: successResponseSchema, - 400: z.object({ message: z.string() }), - 403: z.object({ message: z.string() }), - }, - }, - completeEmailChange: { - method: 'PUT', - path: '/api/account/email', - summary: 'Complete email change', - description: 'Complete an email change using the token from the confirmation email', - body: emailChangeCompleteSchema, - responses: { - 200: emailChangeCompleteResponseSchema, - 400: z.object({ message: z.string() }), - }, - }, /** * `GET /api/account/deletionAudit` *