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.
-
- )}
-
-
-
-
- Change email address
-
- Your current email is {userEmail}. To change it, enter a new
- email address and your password. You will receive a confirmation email at the
- new address.
-
- {submittedEmail && (
-
- A confirmation email has been sent to {submittedEmail}. Please check your
- inbox and click the link to complete the email change.
-
- )}
-
-
-
+
+ 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`
*