diff --git a/apps/website/src/__tests__/pages/__snapshots__/account.test.tsx.snap b/apps/website/src/__tests__/pages/__snapshots__/account.test.tsx.snap index 63fc2a7f8..607d0d968 100644 --- a/apps/website/src/__tests__/pages/__snapshots__/account.test.tsx.snap +++ b/apps/website/src/__tests__/pages/__snapshots__/account.test.tsx.snap @@ -173,15 +173,26 @@ exports[`AccountSettingsPage > should render account settings page correctly 1`]

- Password + Account settings

- + + + diff --git a/apps/website/src/components/settings/PasswordSection.test.tsx b/apps/website/src/components/settings/AccountSettingsSection.test.tsx similarity index 89% rename from apps/website/src/components/settings/PasswordSection.test.tsx rename to apps/website/src/components/settings/AccountSettingsSection.test.tsx index bb2a1d188..d7df8b087 100644 --- a/apps/website/src/components/settings/PasswordSection.test.tsx +++ b/apps/website/src/components/settings/AccountSettingsSection.test.tsx @@ -6,9 +6,9 @@ import { TRPCError } from '@trpc/server'; import { describe, expect, test } from 'vitest'; import { server, trpcMsw } from '../../__tests__/trpcMswSetup'; import { TrpcProvider } from '../../__tests__/trpcProvider'; -import PasswordSection from './PasswordSection'; +import AccountSettingsSection from './AccountSettingsSection'; -describe('PasswordSection - User Journeys', () => { +describe('AccountSettingsSection - User Journeys', () => { // Test data const validPasswords = { current: 'MyCurrentPassword123!', @@ -41,7 +41,7 @@ describe('PasswordSection - User Journeys', () => { test('User can successfully change their password', async () => { server.use(trpcMsw.users.changePassword.mutation(() => ({ message: 'Password updated successfully' }))); - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); // User clicks "Change Password" button openPasswordModal(); @@ -67,7 +67,7 @@ describe('PasswordSection - User Journeys', () => { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Incorrect password' }); })); - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); // User opens modal and fills form openPasswordModal(); @@ -93,7 +93,7 @@ describe('PasswordSection - User Journeys', () => { }); test('User sees validation errors for invalid inputs', async () => { - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); openPasswordModal(); @@ -127,7 +127,7 @@ describe('PasswordSection - User Journeys', () => { }); test('User can cancel password change', async () => { - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); openPasswordModal(); @@ -151,7 +151,7 @@ describe('PasswordSection - User Journeys', () => { test('User can submit form with Enter key', async () => { server.use(trpcMsw.users.changePassword.mutation(() => ({ message: 'Password updated successfully' }))); - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); openPasswordModal(); fillPasswordForm(validPasswords.current, validPasswords.new, validPasswords.new); @@ -173,7 +173,7 @@ describe('PasswordSection - User Journeys', () => { }); test('User sees helpful password hint', () => { - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); openPasswordModal(); @@ -192,7 +192,7 @@ describe('PasswordSection - User Journeys', () => { server.use(trpcMsw.users.changePassword.mutation(() => promise)); - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); openPasswordModal(); fillPasswordForm(validPasswords.current, validPasswords.new, validPasswords.new); @@ -217,7 +217,7 @@ describe('PasswordSection - User Journeys', () => { }); test('User can close modal with Escape key', async () => { - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); openPasswordModal(); @@ -248,7 +248,7 @@ describe('PasswordSection - User Journeys', () => { }); })); - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); // User opens modal and fills form openPasswordModal(); @@ -273,7 +273,7 @@ describe('PasswordSection - User Journeys', () => { }); })); - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); // User opens modal and fills form openPasswordModal(); @@ -290,6 +290,15 @@ describe('PasswordSection - User Journeys', () => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); + test('User can open the change email modal from the account settings section', () => { + render(, { wrapper: TrpcProvider }); + + fireEvent.click(screen.getByRole('button', { name: /change email/i })); + + expect(screen.getByRole('heading', { level: 2, name: 'Change email' })).toBeInTheDocument(); + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); + }); + test('User sees fallback error message when backend error has no details', async () => { server.use(trpcMsw.users.changePassword.mutation(() => { throw new TRPCError({ @@ -298,7 +307,7 @@ describe('PasswordSection - User Journeys', () => { }); })); - render(, { wrapper: TrpcProvider }); + render(, { wrapper: TrpcProvider }); // User opens modal and fills form openPasswordModal(); diff --git a/apps/website/src/components/settings/PasswordSection.tsx b/apps/website/src/components/settings/AccountSettingsSection.tsx similarity index 85% rename from apps/website/src/components/settings/PasswordSection.tsx rename to apps/website/src/components/settings/AccountSettingsSection.tsx index 38fa5f643..9c91670b1 100644 --- a/apps/website/src/components/settings/PasswordSection.tsx +++ b/apps/website/src/components/settings/AccountSettingsSection.tsx @@ -9,8 +9,10 @@ import { TRPCClientError } from '@trpc/client'; import { useEffect, useRef, useState } from 'react'; import { changePasswordWithConfirmSchema } from '../../lib/schemas/user/changePassword.schema'; import { trpc } from '../../utils/trpc'; +import ChangeEmailModal from './ChangeEmailModal'; -const PasswordSection = () => { +const AccountSettingsSection = () => { + const [showChangeEmailModal, setShowChangeEmailModal] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [passwordUpdateSuccess, setPasswordUpdateSuccess] = useState(false); @@ -26,16 +28,25 @@ const PasswordSection = () => { return ( <> - {/* Password Section */} + {/* Account Settings Section */}
-

Password

- setShowChangePasswordModal(true)} - aria-label="Change password" - > - Change Password - +

Account settings

+
+ setShowChangeEmailModal(true)} + aria-label="Change email" + > + Change email + + setShowChangePasswordModal(true)} + aria-label="Change password" + > + Change password + +
{passwordUpdateSuccess && (

{ )}

+ {/* Change Email Modal */} + + {/* Change Password Modal */} -
+ +
+
{!errors.new && (

Password must be at least 8 characters @@ -258,7 +276,7 @@ const ChangePasswordModal = ({ }} onKeyDown={handleKeyDown} placeholder="Confirm new password" - label="Confirm New Password*" + label="Confirm new password*" aria-label="Confirm new password" aria-describedby={ errors.confirm ? 'confirm-password-error' : undefined @@ -299,7 +317,7 @@ const ChangePasswordModal = ({ Updating... ) : ( - 'Update Password' + 'Update password' )}

@@ -308,4 +326,4 @@ const ChangePasswordModal = ({ ); }; -export default PasswordSection; +export default AccountSettingsSection; diff --git a/apps/website/src/components/settings/ChangeEmailModal.test.tsx b/apps/website/src/components/settings/ChangeEmailModal.test.tsx new file mode 100644 index 000000000..791aa97ce --- /dev/null +++ b/apps/website/src/components/settings/ChangeEmailModal.test.tsx @@ -0,0 +1,203 @@ +import '@testing-library/jest-dom'; +import { + fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import { userTable } from '@bluedot/db'; +import { + beforeEach, describe, expect, test, vi, +} from 'vitest'; +import { + createCaller, createTrpcDbProvider, seedLoggedInUser, setupTestDb, testAuthContextLoggedIn, testDb, +} from '../../__tests__/dbTestUtils'; +import { sendEmailChangeRequestedNotice, sendEmailChangeVerification } from '../../lib/api/customerio'; +import { adminRequest } from '../../lib/api/keycloak'; +import { resetEmailChangeRateLimits } from '../../server/routers/users'; +import env from '../../lib/api/env'; +import ChangeEmailModal from './ChangeEmailModal'; + +vi.mock('../../lib/api/customerio', () => ({ + sendEmailChangeVerification: vi.fn(), + sendEmailChangeRequestedNotice: vi.fn(), + updateCustomerIoEmail: vi.fn(), +})); + +vi.mock('../../lib/api/keycloak', async () => ({ + ...await vi.importActual('../../lib/api/keycloak'), + adminRequest: vi.fn(), +})); + +setupTestDb(); + +const mutableEnv = env as { EMAIL_CHANGE_TOKEN_SECRET?: string }; + +beforeEach(async () => { + vi.resetAllMocks(); + vi.mocked(adminRequest).mockResolvedValue([]); + vi.mocked(sendEmailChangeRequestedNotice).mockResolvedValue(undefined); + resetEmailChangeRateLimits(); + mutableEnv.EMAIL_CHANGE_TOKEN_SECRET = 'test-secret'; + await seedLoggedInUser(); +}); + +const renderModal = (setIsOpen = vi.fn()) => { + const utils = render( + , + { wrapper: createTrpcDbProvider(testAuthContextLoggedIn) }, + ); + return { ...utils, setIsOpen }; +}; + +const typeEmail = (value: string) => { + fireEvent.change(screen.getByLabelText(/new email/i), { target: { value } }); +}; + +const submit = () => { + fireEvent.click(screen.getByRole('button', { name: /send confirmation link/i })); +}; + +const successView = () => screen.queryByText(/valid for 48 hours/i); +const genericErrorView = () => screen.queryByRole('heading', { name: /^Error:/ }); + +describe('ChangeEmailModal', () => { + test('User requests a change and sees the confirmation-sent view only after the server responds', async () => { + renderModal(); + + typeEmail(' New@Example.com '); + submit(); + + expect(await screen.findByText('new@example.com')).toBeInTheDocument(); + expect(successView()).toBeInTheDocument(); + expect(screen.queryByLabelText(/new email/i)).not.toBeInTheDocument(); + + expect(sendEmailChangeVerification).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendEmailChangeVerification).mock.calls[0]![0]).toMatchObject({ + oldEmail: 'test@example.com', + newEmail: 'new@example.com', + }); + expect((await testDb.get(userTable, { id: 'test-user' })).email).toBe('test@example.com'); + }); + + test('Done closes the modal after a successful request', async () => { + const { setIsOpen } = renderModal(); + + typeEmail('new@example.com'); + submit(); + + fireEvent.click(await screen.findByRole('button', { name: 'Done' })); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); + + test('User sees a validation error for a malformed email, and nothing is sent', async () => { + renderModal(); + + typeEmail('not-an-email'); + submit(); + + expect(await screen.findByRole('alert')).toHaveTextContent('Please enter a valid email address'); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + + typeEmail('valid@example.com'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('User sees an in-flight state while the request runs, and no success view yet', async () => { + let resolveSend: () => void; + vi.mocked(sendEmailChangeVerification).mockReturnValue(new Promise((resolve) => { + resolveSend = () => resolve(); + })); + + renderModal(); + typeEmail('new@example.com'); + submit(); + + await waitFor(() => { + expect(screen.getByText('Sending...')).toBeInTheDocument(); + }); + expect(screen.getByLabelText(/new email/i)).toBeDisabled(); + expect(screen.getByRole('button', { name: /cancel email change/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /send confirmation link/i })).toBeDisabled(); + expect(successView()).not.toBeInTheDocument(); + + resolveSend!(); + expect(await screen.findByText('new@example.com')).toBeInTheDocument(); + }); + + test('User sees a friendly inline message when the email belongs to another account', async () => { + await testDb.insert(userTable, { id: 'other-user', email: 'taken@example.com' }); + renderModal(); + + typeEmail('Taken@Example.com'); + submit(); + + expect(await screen.findByRole('alert')).toHaveTextContent('That email address is already linked to another BlueDot account.'); + expect(genericErrorView()).not.toBeInTheDocument(); + expect(successView()).not.toBeInTheDocument(); + expect(screen.getByLabelText(/new email/i)).toBeEnabled(); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('Any other server rejection surfaces through the generic error view', async () => { + renderModal(); + + typeEmail('test@example.com'); + submit(); + + expect(await screen.findByRole('heading', { name: /New email is the same as the current email/ })).toBeInTheDocument(); + expect(successView()).not.toBeInTheDocument(); + }); + + test('A rate limited user sees the wait-time message in the generic error view', async () => { + const caller = createCaller(testAuthContextLoggedIn); + await caller.users.requestOwnEmailChange({ newEmail: 'one@example.com' }); + await caller.users.requestOwnEmailChange({ newEmail: 'two@example.com' }); + await caller.users.requestOwnEmailChange({ newEmail: 'three@example.com' }); + vi.mocked(sendEmailChangeVerification).mockClear(); + renderModal(); + + typeEmail('new@example.com'); + submit(); + + expect(await screen.findByRole('heading', { name: /tried to change your email 3 times in the last 30 minutes.*try again in about \d+ minutes/ })).toBeInTheDocument(); + expect(successView()).not.toBeInTheDocument(); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('An unexpected failure surfaces through the generic error view, and the form stays usable', async () => { + vi.mocked(sendEmailChangeVerification).mockRejectedValue(new Error('customer.io is down')); + renderModal(); + + typeEmail('new@example.com'); + submit(); + + expect(await screen.findByRole('heading', { name: /^Error:/ })).toBeInTheDocument(); + expect(successView()).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send confirmation link/i })).toBeEnabled(); + }); + + test('User can retry after a failure, and the form is clean when the modal is reopened', async () => { + await testDb.insert(userTable, { id: 'other-user', email: 'taken@example.com' }); + + const setIsOpen = vi.fn(); + const { rerender } = render( + , + { wrapper: createTrpcDbProvider(testAuthContextLoggedIn) }, + ); + + typeEmail('taken@example.com'); + submit(); + await screen.findByRole('alert'); + + typeEmail('new@example.com'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + submit(); + expect(await screen.findByText('new@example.com')).toBeInTheDocument(); + + rerender(); + rerender(); + + expect(screen.getByLabelText(/new email/i)).toHaveValue(''); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(successView()).not.toBeInTheDocument(); + }); +}); diff --git a/apps/website/src/components/settings/ChangeEmailModal.tsx b/apps/website/src/components/settings/ChangeEmailModal.tsx new file mode 100644 index 000000000..17fbc56ca --- /dev/null +++ b/apps/website/src/components/settings/ChangeEmailModal.tsx @@ -0,0 +1,153 @@ +import { + CTALinkOrButton, + ErrorSection, + Input, + Modal, + P, + ProgressDots, +} from '@bluedot/ui'; +import { TRPCClientError } from '@trpc/client'; +import { useEffect, useState } from 'react'; +import { newEmailSchema } from '../../lib/schemas/user/changeEmail.schema'; +import { trpc } from '../../utils/trpc'; + +const EMAIL_TAKEN_MESSAGE = 'That email address is already linked to another BlueDot account.'; + +const isEmailTakenError = (error: unknown) => error instanceof TRPCClientError && error.data?.code === 'CONFLICT'; + +type ChangeEmailModalProps = { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +}; + +const ChangeEmailModal = ({ isOpen, setIsOpen }: ChangeEmailModalProps) => { + const [newEmail, setNewEmail] = useState(''); + const [validationError, setValidationError] = useState(''); + + const requestEmailChange = trpc.users.requestOwnEmailChange.useMutation(); + const { reset: resetMutation } = requestEmailChange; + + useEffect(() => { + if (isOpen) { + setNewEmail(''); + setValidationError(''); + resetMutation(); + } + }, [isOpen, resetMutation]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (requestEmailChange.isPending) { + return; + } + + const result = newEmailSchema.safeParse(newEmail); + if (!result.success) { + setValidationError(result.error.issues[0]?.message ?? 'Please enter a valid email address'); + return; + } + + setValidationError(''); + requestEmailChange.mutate({ newEmail: result.data }); + }; + + const emailTaken = isEmailTakenError(requestEmailChange.error); + const inlineError = validationError || (emailTaken ? EMAIL_TAKEN_MESSAGE : ''); + + return ( + +
+
+ {requestEmailChange.isSuccess ? ( +
+

+ We've sent a confirmation link to {requestEmailChange.data.sentTo}. +

+

+ Click the link in that email to finish updating your email address. + Until then, you'll keep signing in with your current email. The link is valid for 48 hours. +

+
+ setIsOpen(false)} + > + Done + +
+
+ ) : ( +
+ {requestEmailChange.error && !emailTaken && } +

+ We'll send a confirmation link to your new email address. + Your email won't change until you click it. +

+
+ { + setNewEmail(e.target.value); + if (validationError) { + setValidationError(''); + } + + if (requestEmailChange.isError) { + resetMutation(); + } + }} + placeholder="Enter new email address" + label="New email*" + aria-label="New email address" + aria-describedby={inlineError ? 'new-email-error' : undefined} + aria-invalid={!!inlineError} + disabled={requestEmailChange.isPending} + /> + {inlineError && ( + + )} +
+ +
+ setIsOpen(false)} + disabled={requestEmailChange.isPending} + aria-label="Cancel email change" + > + Cancel + + + {requestEmailChange.isPending ? ( + + + Sending... + + ) : ( + 'Send confirmation link' + )} + +
+ + )} +
+ + ); +}; + +export default ChangeEmailModal; diff --git a/apps/website/src/lib/api/customerio.test.ts b/apps/website/src/lib/api/customerio.test.ts index d10415bf5..27372e62e 100644 --- a/apps/website/src/lib/api/customerio.test.ts +++ b/apps/website/src/lib/api/customerio.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; -import { sendEmailChangeVerification, updateCustomerIoEmail } from './customerio'; +import { sendEmailChangeRequestedNotice, sendEmailChangeVerification, updateCustomerIoEmail } from './customerio'; import env from './env'; vi.mock('./env', () => ({ @@ -385,3 +385,65 @@ describe('sendEmailChangeVerification', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe('sendEmailChangeRequestedNotice', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('sends an inline transactional email to the old address, keyed to the old-email profile', async () => { + const fetchMock = vi.fn(async () => jsonResponse(200, { delivery_id: 'd1' })); + vi.stubGlobal('fetch', fetchMock); + + await sendEmailChangeRequestedNotice({ oldEmail: ' Old@Example.COM ', newEmail: ' New@Example.com ' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]! as unknown as [string, { method: string; body: string }]; + expect(url).toBe('https://api-eu.customer.io/v1/send/email'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body) as { to: string; identifiers: { email: string }; send_to_unsubscribed: boolean; body: string; subject: string }; + expect(body.to).toBe('old@example.com'); + expect(body.identifiers).toEqual({ email: 'old@example.com' }); + expect(body.send_to_unsubscribed).toBe(true); + expect(body.body).toContain('old@example.com'); + expect(body.body).toContain('new@example.com'); + expect(body.subject).toBeTruthy(); + }); + + test('escapes the emails it interpolates into the body', async () => { + const fetchMock = vi.fn(async () => jsonResponse(200, { delivery_id: 'd1' })); + vi.stubGlobal('fetch', fetchMock); + + await sendEmailChangeRequestedNotice({ oldEmail: '"old"@example.com', newEmail: '"new"@example.com' }); + + const [, init] = fetchMock.mock.calls[0]! as unknown as [string, { body: string }]; + const body = JSON.parse(init.body) as { to: string; body: string }; + expect(body.to).toBe('"old"@example.com'); + expect(body.body).not.toContain(''); + expect(body.body).not.toContain(''); + expect(body.body).toContain('"<b>old</b>"@example.com'); + }); + + test('throws when the send fails', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(400, { meta: { error: 'bad' } }))); + + await expect(sendEmailChangeRequestedNotice({ oldEmail: 'old@example.com', newEmail: 'new@example.com' })) + .rejects.toThrow('HTTP 400'); + }); + + test('throws when the App API key is not configured', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const mutableEnv = env as { CIO_APP_API_KEY?: string }; + mutableEnv.CIO_APP_API_KEY = undefined; + + try { + await expect(sendEmailChangeRequestedNotice({ oldEmail: 'old@example.com', newEmail: 'new@example.com' })) + .rejects.toThrow('not configured'); + } finally { + mutableEnv.CIO_APP_API_KEY = 'fake-app-key'; + } + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/website/src/lib/api/customerio.ts b/apps/website/src/lib/api/customerio.ts index 0126df03b..87c39a3de 100644 --- a/apps/website/src/lib/api/customerio.ts +++ b/apps/website/src/lib/api/customerio.ts @@ -152,3 +152,26 @@ export async function sendEmailChangeVerification({ oldEmail, newEmail, confirmU throw new Error(`customer.io verification email send failed: HTTP ${res.status}`); } } + +export async function sendEmailChangeRequestedNotice({ oldEmail, newEmail }: { oldEmail: string; newEmail: string }): Promise { + const safeOldEmail = escapeHtml(normaliseEmail(oldEmail)); + const safeNewEmail = escapeHtml(normaliseEmail(newEmail)); + const res = await fetch(`${CIO_API_BASE}/send/email`, { + method: 'POST', + headers: { ...appHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: normaliseEmail(oldEmail), + identifiers: { email: normaliseEmail(oldEmail) }, + send_to_unsubscribed: true, + from: 'BlueDot Impact ', + subject: 'Email change requested on your account', + body: [ + `

A request was made to change the email on your BlueDot Impact account from ${safeOldEmail} to ${safeNewEmail}.

`, + '

If this was not you, reply to this email so we can secure your account.

', + ].join('\n'), + }), + }); + if (!res.ok) { + throw new Error(`customer.io email change notice send failed: HTTP ${res.status}`); + } +} diff --git a/apps/website/src/lib/schemas/user/changeEmail.schema.ts b/apps/website/src/lib/schemas/user/changeEmail.schema.ts new file mode 100644 index 000000000..e03739c4e --- /dev/null +++ b/apps/website/src/lib/schemas/user/changeEmail.schema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const newEmailSchema = z.string().trim().toLowerCase().email('Please enter a valid email address'); diff --git a/apps/website/src/pages/account.tsx b/apps/website/src/pages/account.tsx index 03c0fbab4..4a66dbab9 100644 --- a/apps/website/src/pages/account.tsx +++ b/apps/website/src/pages/account.tsx @@ -6,7 +6,7 @@ import { useState, useEffect } from 'react'; import { ROUTES } from '../lib/routes'; import MyBlueDotLayout from '../components/my-bluedot/MyBlueDotLayout'; import ProfileNameEditor from '../components/settings/ProfileNameEditor'; -import PasswordSection from '../components/settings/PasswordSection'; +import AccountSettingsSection from '../components/settings/AccountSettingsSection'; import { trpc } from '../utils/trpc'; const CURRENT_ROUTE = ROUTES.account; @@ -51,7 +51,7 @@ const AccountSettingsPage = () => {
- + {/* Welcome Modal */} diff --git a/apps/website/src/server/routers/users.test.ts b/apps/website/src/server/routers/users.test.ts index 844b9b2ef..06b979516 100644 --- a/apps/website/src/server/routers/users.test.ts +++ b/apps/website/src/server/routers/users.test.ts @@ -1,11 +1,13 @@ import { userTable } from '@bluedot/db'; +import type { TRPCError } from '@trpc/server'; import { loginPresets } from '@bluedot/ui/src/Login'; import { slackAlert } from '@bluedot/utils/src/slackNotifications'; import db from '../../lib/api/db'; import { afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; -import { sendEmailChangeVerification, updateCustomerIoEmail } from '../../lib/api/customerio'; +import { sendEmailChangeRequestedNotice, sendEmailChangeVerification, updateCustomerIoEmail } from '../../lib/api/customerio'; +import { resetEmailChangeRateLimits } from './users'; import { createEmailChangeToken, verifyEmailChangeToken } from '../../lib/api/emailChangeToken'; import { adminRequest, unlinkStaleGoogleIdentities, updateKeycloakEmail, updateKeycloakPassword, verifyKeycloakPassword, @@ -28,6 +30,7 @@ vi.mock('../../lib/api/keycloak', () => ({ vi.mock('../../lib/api/customerio', () => ({ updateCustomerIoEmail: vi.fn(), sendEmailChangeVerification: vi.fn(), + sendEmailChangeRequestedNotice: vi.fn(), })); vi.mock('@bluedot/utils/src/slackNotifications', () => ({ @@ -52,6 +55,7 @@ const mutableEnv = env as { EMAIL_CHANGE_TOKEN_SECRET?: string }; beforeEach(() => { vi.clearAllMocks(); + resetEmailChangeRateLimits(); mutableEnv.EMAIL_CHANGE_TOKEN_SECRET = 'test-secret'; vi.mocked(verifyKeycloakPassword).mockReset(); vi.mocked(updateKeycloakPassword).mockReset(); @@ -68,6 +72,7 @@ beforeEach(() => { vi.mocked(adminRequest).mockResolvedValue([]); vi.mocked(updateCustomerIoEmail).mockResolvedValue(undefined); vi.mocked(sendEmailChangeVerification).mockResolvedValue(undefined); + vi.mocked(sendEmailChangeRequestedNotice).mockResolvedValue(undefined); vi.mocked(unlinkStaleGoogleIdentities).mockResolvedValue({ hasPassword: true, hasGoogleLogin: false }); }); @@ -584,6 +589,222 @@ describe('users.requestEmailChange', () => { }); }); +describe('users.requestOwnEmailChange', () => { + test('rejects unauthenticated callers', async () => { + await expect(anonCaller().users.requestOwnEmailChange({ newEmail: 'new@example.com' })) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('rejects callers with no user row', async () => { + await expect(createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: 'new@example.com' })) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('blocks the request while impersonating another user', async () => { + await seedLoggedInUser(); + + await expect(createCaller({ + ...testAuthContextLoggedIn, + auth: { ...testAuthContextLoggedIn.auth! }, + impersonation: { adminEmail: 'admin@example.com', adminSub: 'admin-sub', targetEmail: 'test@example.com' }, + }).users.requestOwnEmailChange({ newEmail: 'new@example.com' })) + .rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('impersonating') }); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('rejects malformed emails', async () => { + await seedLoggedInUser(); + + await expect(createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: 'not-an-email' })) + .rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('rejects a new email equal to the current one, case-insensitively', async () => { + await seedLoggedInUser(); + + await expect(createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: ' TEST@Example.com ' })) + .rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('same as the current email') }); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('rejects a new email already held by another user, case-insensitively', async () => { + await seedLoggedInUser(); + await testDb.insert(userTable, { + id: 'other-id', email: 'taken@example.com', name: 'Other', keycloakIdentifier: 'other-sub', + }); + + await expect(createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: 'Taken@Example.com' })) + .rejects.toMatchObject({ code: 'CONFLICT' }); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('sends a verification email for the caller\'s own account, and changes nothing', async () => { + await seedLoggedInUser(); + + const result = await createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: ' New@Example.com ' }); + + expect(result).toEqual({ sentTo: 'new@example.com' }); + expect(updateKeycloakEmail).not.toHaveBeenCalled(); + expect(updateCustomerIoEmail).not.toHaveBeenCalled(); + expect((await testDb.get(userTable, { id: 'test-user' })).email).toBe('test@example.com'); + + expect(sendEmailChangeVerification).toHaveBeenCalledTimes(1); + const call = vi.mocked(sendEmailChangeVerification).mock.calls[0]![0]; + expect(call.oldEmail).toBe('test@example.com'); + expect(call.newEmail).toBe('new@example.com'); + expect(call.confirmUrl).toContain(`${ROUTES.confirmEmailChange.url}?token=`); + + expect(sendEmailChangeRequestedNotice).toHaveBeenCalledWith({ oldEmail: 'test@example.com', newEmail: 'new@example.com' }); + }); + + test('mints a token bound to the caller, never to another user who happens to be seeded', async () => { + await seedLoggedInUser(); + await testDb.insert(userTable, { + id: 'other-id', email: 'other@example.com', name: 'Other', keycloakIdentifier: 'other-sub', + }); + + await createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: 'new@example.com' }); + + const { confirmUrl } = vi.mocked(sendEmailChangeVerification).mock.calls[0]![0]; + const payload = await verifyEmailChangeToken(decodeURIComponent(confirmUrl.split('token=')[1]!)); + expect(payload).toMatchObject({ userId: 'test-user', oldEmail: 'test@example.com', newEmail: 'new@example.com' }); + }); + + test('the token it mints is accepted by confirmEmailChange', async () => { + await seedLoggedInUser(); + + await createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: 'new@example.com' }); + const { confirmUrl } = vi.mocked(sendEmailChangeVerification).mock.calls[0]![0]; + const token = decodeURIComponent(confirmUrl.split('token=')[1]!); + + const result = await anonCaller().users.confirmEmailChange({ token }); + + expect(result).toEqual({ newEmail: 'new@example.com', loginMethods: { hasPassword: true, hasGoogleLogin: false } }); + expect(updateKeycloakEmail).toHaveBeenCalledWith('test-sub', 'new@example.com'); + expect((await testDb.get(userTable, { id: 'test-user' })).email).toBe('new@example.com'); + }); + + test('the flow works when the stored email is not normalised', async () => { + await testDb.insert(userTable, { id: 'test-user', email: ' MiXeD.Case@Example.COM ', keycloakIdentifier: 'test-sub' }); + + await createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: 'new@example.com' }); + const { confirmUrl } = vi.mocked(sendEmailChangeVerification).mock.calls[0]![0]; + const token = decodeURIComponent(confirmUrl.split('token=')[1]!); + + const result = await anonCaller().users.confirmEmailChange({ token }); + + expect(result).toMatchObject({ newEmail: 'new@example.com' }); + expect((await testDb.get(userTable, { id: 'test-user' })).email).toBe('new@example.com'); + }); + + test('surfaces a send failure to the user', async () => { + await seedLoggedInUser(); + vi.mocked(sendEmailChangeVerification).mockRejectedValue(new Error('send failed')); + + await expect(createCaller(testAuthContextLoggedIn).users.requestOwnEmailChange({ newEmail: 'new@example.com' })) + .rejects.toMatchObject({ message: expect.stringContaining('send failed') }); + }); +}); + +describe('users.requestOwnEmailChange rate limiting', () => { + const ownCaller = () => createCaller(testAuthContextLoggedIn); + + const attemptOwnEmailChange = (newEmail: string): Promise => ownCaller() + .users.requestOwnEmailChange({ newEmail }) + .then(() => 'SENT') + .catch((error: unknown) => (error as TRPCError).code); + + test('allows three attempts per window, then rejects with the wait time and sends nothing', async () => { + await seedLoggedInUser(); + + expect(await attemptOwnEmailChange('one@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('two@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('three@example.com')).toBe('SENT'); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(Date.now() + 20 * 60 * 1000); + vi.mocked(sendEmailChangeVerification).mockClear(); + + await expect(ownCaller().users.requestOwnEmailChange({ newEmail: 'four@example.com' })) + .rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + message: expect.stringContaining('You\'ve tried to change your email 3 times in the last 30 minutes. If you\'re waiting for a confirmation email, check your spam folder, or try again in about 10 minutes.'), + }); + expect(sendEmailChangeVerification).not.toHaveBeenCalled(); + }); + + test('failed attempts count toward the limit', async () => { + await seedLoggedInUser(); + await testDb.insert(userTable, { + id: 'holder-id', email: 'taken@example.com', name: 'Holder', keycloakIdentifier: 'holder-sub', + }); + + expect(await attemptOwnEmailChange('taken@example.com')).toBe('CONFLICT'); + expect(await attemptOwnEmailChange('taken@example.com')).toBe('CONFLICT'); + expect(await attemptOwnEmailChange('taken@example.com')).toBe('CONFLICT'); + expect(await attemptOwnEmailChange('new@example.com')).toBe('TOO_MANY_REQUESTS'); + }); + + test('attempts that fail on our own infra are refunded and do not count toward the limit', async () => { + await seedLoggedInUser(); + vi.mocked(sendEmailChangeVerification).mockRejectedValue(new Error('customer.io is down')); + + expect(await attemptOwnEmailChange('new@example.com')).toBe('INTERNAL_SERVER_ERROR'); + expect(await attemptOwnEmailChange('new@example.com')).toBe('INTERNAL_SERVER_ERROR'); + expect(await attemptOwnEmailChange('new@example.com')).toBe('INTERNAL_SERVER_ERROR'); + + vi.mocked(sendEmailChangeVerification).mockResolvedValue(undefined); + expect(await attemptOwnEmailChange('new@example.com')).toBe('SENT'); + }); + + test('a blocked request is allowed again once the window has passed', async () => { + await seedLoggedInUser(); + + expect(await attemptOwnEmailChange('one@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('two@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('three@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('four@example.com')).toBe('TOO_MANY_REQUESTS'); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(Date.now() + 31 * 60 * 1000); + + expect(await attemptOwnEmailChange('four@example.com')).toBe('SENT'); + }); + + test('limits each user separately', async () => { + await seedLoggedInUser(); + await testDb.insert(userTable, { + id: 'other-id', email: 'other@example.com', name: 'Other', keycloakIdentifier: 'other-sub', + }); + + expect(await attemptOwnEmailChange('one@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('two@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('three@example.com')).toBe('SENT'); + expect(await attemptOwnEmailChange('four@example.com')).toBe('TOO_MANY_REQUESTS'); + + await expect(callerAs('other-sub').users.requestOwnEmailChange({ newEmail: 'four@example.com' })) + .resolves.toEqual({ sentTo: 'four@example.com' }); + }); + + test('does not limit the admin requestEmailChange procedure', async () => { + await seedAdmin(); + await seedTarget(); + const admin = callerAs('admin-sub'); + + await admin.users.requestEmailChange({ userId: 'target-id', newEmail: 'one@example.com' }); + await admin.users.requestEmailChange({ userId: 'target-id', newEmail: 'two@example.com' }); + await admin.users.requestEmailChange({ userId: 'target-id', newEmail: 'three@example.com' }); + await admin.users.requestEmailChange({ userId: 'target-id', newEmail: 'four@example.com' }); + + await expect(admin.users.requestEmailChange({ userId: 'target-id', newEmail: 'five@example.com' })) + .resolves.toEqual({ sentTo: 'five@example.com' }); + expect(sendEmailChangeVerification).toHaveBeenCalledTimes(5); + }); +}); + describe('users.confirmEmailChange', () => { test('rejects an invalid token', async () => { await expect(anonCaller().users.confirmEmailChange({ token: 'garbage' })) diff --git a/apps/website/src/server/routers/users.ts b/apps/website/src/server/routers/users.ts index fb2d5e3ae..29190ca7d 100644 --- a/apps/website/src/server/routers/users.ts +++ b/apps/website/src/server/routers/users.ts @@ -6,12 +6,14 @@ import { slackAlert } from '@bluedot/utils/src/slackNotifications'; import z from 'zod'; import db from '../../lib/api/db'; import env from '../../lib/api/env'; -import { sendEmailChangeVerification, updateCustomerIoEmail } from '../../lib/api/customerio'; +import { sendEmailChangeRequestedNotice, sendEmailChangeVerification, updateCustomerIoEmail } from '../../lib/api/customerio'; import { createEmailChangeToken, verifyEmailChangeToken } from '../../lib/api/emailChangeToken'; import { adminRequest, type LoginMethods, unlinkStaleGoogleIdentities, updateKeycloakEmail, updateKeycloakPassword, verifyKeycloakPassword, } from '../../lib/api/keycloak'; import { normaliseEmail } from '../../lib/api/utils'; +import { ONE_MINUTE_MS } from '../../lib/constants'; +import { newEmailSchema } from '../../lib/schemas/user/changeEmail.schema'; import { changePasswordSchema } from '../../lib/schemas/user/changePassword.schema'; import { updateNameSchema } from '../../lib/schemas/user/me.schema'; import { ROUTES } from '../../lib/routes'; @@ -35,6 +37,54 @@ const getEmailChangeConfirmUrl = (token: string) => { return `${siteUrl}${ROUTES.confirmEmailChange.url}?token=${encodeURIComponent(token)}`; }; +async function sendEmailChangeConfirmation( + user: { id: string; email: string; keycloakIdentifier: string | null }, + newEmail: string, +): Promise { + if (!user.keycloakIdentifier) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'User has no linked login account' }); + } + + if (normaliseEmail(user.email) === newEmail) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'New email is the same as the current email' }); + } + + if (await isEmailTaken(newEmail, user.id, user.keycloakIdentifier)) { + throw new TRPCError({ code: 'CONFLICT', message: 'Another user already has this email' }); + } + + const token = await createEmailChangeToken({ userId: user.id, oldEmail: user.email, newEmail }); + await sendEmailChangeVerification({ oldEmail: user.email, newEmail, confirmUrl: getEmailChangeConfirmUrl(token) }); +} + +const RATE_LIMIT_WINDOW_MINUTES = 30; +const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MINUTES * ONE_MINUTE_MS; +const RATE_LIMIT_MAX_ATTEMPTS = 3; + +const emailChangeAttemptsByUserId = new Map(); + +const assertWithinEmailChangeRateLimit = (userId: string): void => { + const now = Date.now(); + const recent = (emailChangeAttemptsByUserId.get(userId) ?? []).filter((at) => now - at < RATE_LIMIT_WINDOW_MS); + emailChangeAttemptsByUserId.set(userId, recent); + + if (recent.length >= RATE_LIMIT_MAX_ATTEMPTS) { + const minutesLeft = Math.max(1, Math.ceil((Math.min(...recent) + RATE_LIMIT_WINDOW_MS - now) / ONE_MINUTE_MS)); + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: `You've tried to change your email ${RATE_LIMIT_MAX_ATTEMPTS} times in the last ${RATE_LIMIT_WINDOW_MINUTES} minutes. If you're waiting for a confirmation email, check your spam folder, or try again in about ${minutesLeft} minute${minutesLeft === 1 ? '' : 's'}.`, + }); + } +}; + +const recordEmailChangeAttempt = (userId: string): void => { + emailChangeAttemptsByUserId.set(userId, [...(emailChangeAttemptsByUserId.get(userId) ?? []), Date.now()]); +}; + +export const resetEmailChangeRateLimits = (): void => { + emailChangeAttemptsByUserId.clear(); +}; + // By the time this runs the email change has already been applied in Keycloak and the user table, // and rolling that back is hard. So on failure we accept partial success: the email change stands, // and the stale Google identity is left for manual cleanup via the Slack alert. @@ -178,7 +228,7 @@ export const usersRouter = router({ requestEmailChange: adminProcedure .input(z.object({ userId: z.string().min(1), - newEmail: z.string().trim().toLowerCase().email(), + newEmail: newEmailSchema, })) .mutation(async ({ input, ctx }) => { const user = await db.getFirst(userTable, { filter: { id: input.userId } }); @@ -186,22 +236,39 @@ export const usersRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' }); } - if (!user.keycloakIdentifier) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'User has no linked login account' }); - } + await sendEmailChangeConfirmation(user, input.newEmail); - if (normaliseEmail(user.email) === input.newEmail) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'New email is the same as the current email' }); + logger.info(`[EmailChange] admin ${impersonationRealIdentity(ctx).sub} requested email change for user ${user.id}: ${user.email} -> ${input.newEmail}`); + + return { sentTo: input.newEmail }; + }), + + requestOwnEmailChange: protectedProcedure + .input(z.object({ newEmail: newEmailSchema })) + .mutation(async ({ input, ctx }) => { + if (ctx.impersonation) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot change email when impersonating another user' }); } - if (await isEmailTaken(input.newEmail, user.id, user.keycloakIdentifier)) { - throw new TRPCError({ code: 'CONFLICT', message: 'Another user already has this email' }); + const user = await getUserFromAuthOrThrow(ctx.auth); + + assertWithinEmailChangeRateLimit(user.id); + + try { + await sendEmailChangeConfirmation(user, input.newEmail); + recordEmailChangeAttempt(user.id); + } catch (error) { + if (error instanceof TRPCError) { + recordEmailChangeAttempt(user.id); + } + + throw error; } - const token = await createEmailChangeToken({ userId: user.id, oldEmail: user.email, newEmail: input.newEmail }); - await sendEmailChangeVerification({ oldEmail: user.email, newEmail: input.newEmail, confirmUrl: getEmailChangeConfirmUrl(token) }); + sendEmailChangeRequestedNotice({ oldEmail: user.email, newEmail: input.newEmail }) + .catch((error: unknown) => slackAlert(env, [`[EmailChange] courtesy notice to the old email failed for user ${user.id}: ${error instanceof Error ? error.message : String(error)}`])); - logger.info(`[EmailChange] admin ${impersonationRealIdentity(ctx).sub} requested email change for user ${user.id}: ${user.email} -> ${input.newEmail}`); + logger.info(`[EmailChange] user ${user.id} requested their own email change: ${user.email} -> ${input.newEmail}`); return { sentTo: input.newEmail }; }),