+
@@ -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
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+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 };
}),