From 9fc95a9ce39fa6608c7dae26129a7bc3e5661ced Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 13:33:24 +0000 Subject: [PATCH 01/15] client/modules/User/reducers: update to ts, no-verify --- client/modules/User/{reducers.js => reducers.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/modules/User/{reducers.js => reducers.ts} (100%) diff --git a/client/modules/User/reducers.js b/client/modules/User/reducers.ts similarity index 100% rename from client/modules/User/reducers.js rename to client/modules/User/reducers.ts From fda66326749c8ffb88cf1363148cad019bdea77c Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 14:29:43 +0000 Subject: [PATCH 02/15] client/modules/Users/reducers: update to named export and add types, wip no-verify --- client/modules/User/reducers.ts | 12 +++++++++--- client/reducers.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/client/modules/User/reducers.ts b/client/modules/User/reducers.ts index 2832190b28..f043eb8f0f 100644 --- a/client/modules/User/reducers.ts +++ b/client/modules/User/reducers.ts @@ -1,6 +1,14 @@ +import type { CookieConsentOptions, PublicUser } from '../../../common/types'; import * as ActionTypes from '../../constants'; -const user = (state = { authenticated: false }, action) => { +// User Action: +export type UserAction = { + user?: PublicUser; + cookieConsent?: CookieConsentOptions; + type: any; +}; + +export const user = (state = { authenticated: false }, action: UserAction) => { switch (action.type) { case ActionTypes.AUTH_USER: return { @@ -47,5 +55,3 @@ const user = (state = { authenticated: false }, action) => { return state; } }; - -export default user; diff --git a/client/reducers.ts b/client/reducers.ts index 2c65e555ca..47d9627f80 100644 --- a/client/reducers.ts +++ b/client/reducers.ts @@ -4,7 +4,7 @@ import ide from './modules/IDE/reducers/ide'; import { preferences } from './modules/IDE/reducers/preferences'; import project from './modules/IDE/reducers/project'; import editorAccessibility from './modules/IDE/reducers/editorAccessibility'; -import user from './modules/User/reducers'; +import { user } from './modules/User/reducers'; import sketches from './modules/IDE/reducers/projects'; import toast from './modules/IDE/reducers/toast'; import console from './modules/IDE/reducers/console'; From 9623321e2a87efe471859a1ff753bd71d33fd3f3 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 14:30:30 +0000 Subject: [PATCH 03/15] server/types/apiKey: add token? to apiKey --- server/types/apiKey.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts index 21ba15aa04..443a30e3e4 100644 --- a/server/types/apiKey.ts +++ b/server/types/apiKey.ts @@ -8,6 +8,7 @@ export interface IApiKey extends VirtualId, MongooseTimestamps { label: string; lastUsedAt?: Date; hashedKey: string; + token?: string; } /** Mongoose document object for API Key */ @@ -23,7 +24,10 @@ export interface ApiKeyDocument * and can be exposed to the client */ export interface SanitisedApiKey - extends Pick {} + extends Pick< + ApiKeyDocument, + 'id' | 'label' | 'lastUsedAt' | 'createdAt' | 'token' + > {} /** Mongoose model for API Key */ export interface ApiKeyModel extends Model {} From 353342540fabd39311da7e9a4d1b35a2f2b23c27 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 14:44:58 +0000 Subject: [PATCH 04/15] server/PublicUser: update apiKeys to be sanitised api keys --- server/types/user.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/types/user.ts b/server/types/user.ts index 6d6d53fa1f..509da313b7 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -2,7 +2,7 @@ import { Document, Model, Types } from 'mongoose'; import { VirtualId, MongooseTimestamps } from './mongoose'; import { UserPreferences, CookieConsentOptions } from './userPreferences'; import { EmailConfirmationStates } from './email'; -import { ApiKeyDocument } from './apiKey'; +import { ApiKeyDocument, SanitisedApiKey } from './apiKey'; import { Error, GenericResponseBody, RouteParam } from './express'; // -------- MONGOOSE -------- @@ -38,14 +38,16 @@ export interface PublicUser | 'email' | 'username' | 'preferences' - | 'apiKeys' | 'verified' | 'id' | 'totalSize' | 'github' | 'google' | 'cookieConsent' - > {} + > { + /** Can contain either raw ApiKeyDocuments (server side) or SanitisedApiKeys (client side) */ + apiKeys: SanitisedApiKey[]; +} /** Mongoose document object for User */ export interface UserDocument From 6bcdebff94d6eeac78167347b4f979ab56c54875 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 14:58:00 +0000 Subject: [PATCH 05/15] update UserState in root redux to include User --- client/modules/User/reducers.ts | 9 ++++++++- server/types/user.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client/modules/User/reducers.ts b/client/modules/User/reducers.ts index f043eb8f0f..d8a7141683 100644 --- a/client/modules/User/reducers.ts +++ b/client/modules/User/reducers.ts @@ -8,7 +8,14 @@ export type UserAction = { type: any; }; -export const user = (state = { authenticated: false }, action: UserAction) => { +export const user = ( + state: Partial & { + authenticated: boolean; + } = { + authenticated: false + }, + action: UserAction +) => { switch (action.type) { case ActionTypes.AUTH_USER: return { diff --git a/server/types/user.ts b/server/types/user.ts index 509da313b7..5d2eb61f34 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -22,7 +22,7 @@ export interface IUser extends VirtualId, MongooseTimestamps { tokens: { kind: string }[]; apiKeys: Types.DocumentArray; preferences: UserPreferences; - totalSize: number; + totalSize?: number; cookieConsent: CookieConsentOptions; banned: boolean; lastLoginTimestamp?: Date; @@ -44,6 +44,7 @@ export interface PublicUser | 'github' | 'google' | 'cookieConsent' + | 'totalSize' > { /** Can contain either raw ApiKeyDocuments (server side) or SanitisedApiKeys (client side) */ apiKeys: SanitisedApiKey[]; From 2c0342d4d9cf77c352ee649180bcbb4e7634f018 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 14:58:56 +0000 Subject: [PATCH 06/15] resolve remaining type-errors from PublicUser vs User apiKey differences --- client/testData/testReduxStore.ts | 6 ++++- .../user.controller/__testUtils__.ts | 5 ++-- .../user.controller/__tests__/apiKey.test.ts | 27 ++++++++++++------- .../authManagement/passwordManagement.test.ts | 26 +++++++++++------- .../authManagement/updateSettings.test.ts | 17 +++++++----- 5 files changed, 51 insertions(+), 30 deletions(-) diff --git a/client/testData/testReduxStore.ts b/client/testData/testReduxStore.ts index 33223ae950..daf663ef92 100644 --- a/client/testData/testReduxStore.ts +++ b/client/testData/testReduxStore.ts @@ -54,7 +54,11 @@ const initialTestState: RootState = { user: { email: 'happydog@example.com', username: 'happydog', - preferences: {}, + preferences: { + ...initialPrefState, + indentationAmount: 2, + isTabIndent: true + }, apiKeys: [], verified: 'sent', id: '123456789', diff --git a/server/controllers/user.controller/__testUtils__.ts b/server/controllers/user.controller/__testUtils__.ts index 9d851c49f8..1a7bf31cd6 100644 --- a/server/controllers/user.controller/__testUtils__.ts +++ b/server/controllers/user.controller/__testUtils__.ts @@ -29,7 +29,7 @@ export const mockBaseUserSanitised: PublicUser = { email: 'test@example.com', username: 'tester', preferences: mockUserPreferences, - apiKeys: ([] as unknown) as Types.DocumentArray, + apiKeys: [], verified: 'verified', id: 'abc123', totalSize: 42, @@ -42,6 +42,7 @@ export const mockBaseUserSanitised: PublicUser = { export const mockBaseUserFull: Omit = { ...mockBaseUserSanitised, name: 'test user', + apiKeys: ([] as unknown) as Types.DocumentArray, tokens: [], password: 'abweorij', resetPasswordToken: '1i14ij23', @@ -58,7 +59,7 @@ export const mockBaseUserFull: Omit = { export function createMockUser( overrides: Partial = {}, unSanitised: boolean = false -): PublicUser & Record { +): (PublicUser | UserDocument) & Record { return { ...(unSanitised ? mockBaseUserFull : mockBaseUserSanitised), ...overrides diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index 875b15b2b6..47a0b8fe8c 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -7,7 +7,11 @@ import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; -import type { ApiKeyDocument, RemoveApiKeyRequestParams } from '../../../types'; +import type { + ApiKeyDocument, + RemoveApiKeyRequestParams, + UserDocument +} from '../../../types'; import { createMockUser } from '../__testUtils__'; jest.mock('../../../models/user'); @@ -31,7 +35,7 @@ describe('user.controller > api key', () => { describe('createApiKey', () => { it("returns an error if user doesn't exist", async () => { - request.user = createMockUser({ id: '1234' }); + request.user = createMockUser({ id: '1234' }, true); User.findById = jest.fn().mockResolvedValue(null); @@ -48,7 +52,7 @@ describe('user.controller > api key', () => { }); it('returns an error if label not provided', async () => { - request.user = createMockUser({ id: '1234' }); + request.user = createMockUser({ id: '1234' }, true); request.body = {}; const user = new User(); @@ -98,7 +102,7 @@ describe('user.controller > api key', () => { describe('removeApiKey', () => { it("returns an error if user doesn't exist", async () => { - request.user = createMockUser({ id: '1234' }); + request.user = createMockUser({ id: '1234' }, true); User.findById = jest.fn().mockResolvedValue(null); @@ -115,7 +119,7 @@ describe('user.controller > api key', () => { }); it("returns an error if specified key doesn't exist", async () => { - request.user = createMockUser({ id: '1234' }); + request.user = createMockUser({ id: '1234' }, true); request.params = { keyId: 'not-a-real-key' }; const user = new User(); user.apiKeys = ([] as unknown) as Types.DocumentArray; @@ -145,11 +149,14 @@ describe('user.controller > api key', () => { apiKeys.find = Array.prototype.find; apiKeys.pull = jest.fn(); - const user = createMockUser({ - id: '1234', - apiKeys, - save: jest.fn() - }); + const user = createMockUser( + { + id: '1234', + apiKeys, + save: jest.fn() + }, + true + ) as UserDocument; request.user = user; request.params = { keyId: 'id1' }; diff --git a/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts index 248eeac4e0..b5336bffe4 100644 --- a/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts @@ -29,7 +29,7 @@ describe('user.controller > auth management > password management', () => { let response: MockResponse; let next: MockNext; let mockToken: string; - let mockUser: Partial; + let mockUser: UserDocument; const fixedTime = 100000000; beforeEach(() => { @@ -68,10 +68,13 @@ describe('user.controller > auth management > password management', () => { describe('if the user is found', () => { beforeEach(async () => { mockToken = 'mock-token'; - mockUser = createMockUser({ - email: 'test@example.com', - save: jest.fn().mockResolvedValue(null) - }); + mockUser = createMockUser( + { + email: 'test@example.com', + save: jest.fn().mockResolvedValue(null) + }, + false + ) as UserDocument; (generateToken as jest.Mock).mockResolvedValue(mockToken); User.findByEmail = jest.fn().mockResolvedValue(mockUser); @@ -143,10 +146,13 @@ describe('user.controller > auth management > password management', () => { }); it('returns unsuccessful for all other errors', async () => { mockToken = 'mock-token'; - mockUser = createMockUser({ - email: 'test@example.com', - save: jest.fn().mockResolvedValue(null) - }); + mockUser = createMockUser( + { + email: 'test@example.com', + save: jest.fn().mockResolvedValue(null) + }, + false + ) as UserDocument; (generateToken as jest.Mock).mockRejectedValue( new Error('network error') @@ -298,7 +304,7 @@ describe('user.controller > auth management > password management', () => { resetPasswordToken: 'valid-token', resetPasswordExpires: fixedTime + 10000, // still valid save: jest.fn() - }; + } as UserDocument; beforeEach(async () => { User.findOne = jest.fn().mockReturnValue({ diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts index 698dfa1aea..faf6a89297 100644 --- a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -60,13 +60,16 @@ describe('user.controller > auth management > updateSettings (email, username, p response = new MockResponse(); next = jest.fn(); - startingUser = createMockUser({ - username: OLD_USERNAME, - email: OLD_EMAIL, - password: OLD_PASSWORD, - id: '123459', - comparePassword: jest.fn().mockResolvedValue(true) - }); + startingUser = createMockUser( + { + username: OLD_USERNAME, + email: OLD_EMAIL, + password: OLD_PASSWORD, + id: '123459', + comparePassword: jest.fn().mockResolvedValue(true) + }, + false + ) as UserDocument; testUser = { ...startingUser }; // copy to avoid mutation causing false-positive tests results From 70880de60bcdd0c5652e4bf2a4b48186e2c08e8f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 15:16:28 +0000 Subject: [PATCH 07/15] client/modules/User/actions: update to ts, no-verify --- client/modules/User/{actions.js => actions.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/modules/User/{actions.js => actions.ts} (100%) diff --git a/client/modules/User/actions.js b/client/modules/User/actions.ts similarity index 100% rename from client/modules/User/actions.js rename to client/modules/User/actions.ts From 0ddb69db766fda3a8ae4595e24e404eda994e356 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 15:26:43 +0000 Subject: [PATCH 08/15] client/modules/User/actions: add types from server user request/response types with flexibility --- client/modules/User/actions.ts | 247 ++++++++++++++++++++++++--------- 1 file changed, 185 insertions(+), 62 deletions(-) diff --git a/client/modules/User/actions.ts b/client/modules/User/actions.ts index 20e6729b0d..ad47bc4d29 100644 --- a/client/modules/User/actions.ts +++ b/client/modules/User/actions.ts @@ -1,79 +1,124 @@ import { FORM_ERROR } from 'final-form'; +import type { AnyAction, Dispatch } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import * as ActionTypes from '../../constants'; import browserHistory from '../../browserHistory'; import { apiClient } from '../../utils/apiClient'; import { showErrorModal, justOpenedProject } from '../IDE/actions/ide'; import { setLanguage } from '../IDE/actions/preferences'; import { showToast, setToastText } from '../IDE/actions/toast'; +import type { + CreateApiKeyRequestBody, + CreateUserRequestBody, + Error, + PublicUser, + PublicUserOrError, + PublicUserOrErrorOrGeneric, + RemoveApiKeyRequestParams, + ResetOrUpdatePasswordRequestParams, + ResetPasswordInitiateRequestBody, + UpdateCookieConsentRequestBody, + UpdatePasswordRequestBody, + UpdateSettingsRequestBody, + UserPreferences, + VerifyEmailQuery +} from '../../../common/types'; +import { RootState } from '../../reducers'; -export function authError(error) { +export function authError(error: Error) { return { type: ActionTypes.AUTH_ERROR, payload: error }; } -export function signUpUser(formValues) { +/** + * - Method: `POST` + * - Endpoint: `/signup` + * - Authenticated: `false` + * - Id: `UserController.createUser` + * + * Description: + * - Create a new user + */ +export function signUpUser(formValues: CreateUserRequestBody) { return apiClient.post('/signup', formValues); } -export function loginUser(formValues) { +export function loginUser(formValues: { email: string; password: string }) { return apiClient.post('/login', formValues); } -export function authenticateUser(user) { +export function authenticateUser(user: PublicUser) { return { type: ActionTypes.AUTH_USER, user }; } -export function loginUserFailure(error) { +export function loginUserFailure(error: Error) { return { type: ActionTypes.AUTH_ERROR, error }; } -export function setPreferences(preferences) { +export function setPreferences(preferences: UserPreferences) { return { type: ActionTypes.SET_PREFERENCES, preferences }; } -export function validateAndLoginUser(formProps) { - return (dispatch, getState) => { +export function validateAndLoginUser(formProps: { + email: string; + password: string; +}) { + return ( + dispatch: ThunkDispatch, + getState: () => RootState + ) => { const state = getState(); const { previousPath } = state.ide; - return new Promise((resolve) => { - loginUser(formProps) - .then((response) => { - dispatch(authenticateUser(response.data)); - dispatch(setPreferences(response.data.preferences)); - dispatch( - setLanguage(response.data.preferences.language, { - persistPreference: false + return new Promise( + (resolve) => { + loginUser(formProps) + .then((response) => { + dispatch(authenticateUser(response.data)); + dispatch(setPreferences(response.data.preferences)); + dispatch( + setLanguage(response.data.preferences.language, { + persistPreference: false + }) + ); + dispatch(justOpenedProject()); + browserHistory.push(previousPath); + resolve(); + }) + .catch((error) => + resolve({ + [FORM_ERROR]: error.response.data.message }) ); - dispatch(justOpenedProject()); - browserHistory.push(previousPath); - resolve(); - }) - .catch((error) => - resolve({ - [FORM_ERROR]: error.response.data.message - }) - ); - }); + } + ); }; } -export function validateAndSignUpUser(formValues) { - return (dispatch, getState) => { +/** + * - Method: `POST` + * - Endpoint: `/signup` + * - Authenticated: `false` + * - Id: `UserController.createUser` + * + * Description: + * - Create a new user + */ +export function validateAndSignUpUser(formValues: CreateUserRequestBody) { + return (dispatch: Dispatch, getState: () => RootState) => { const state = getState(); const { previousPath } = state.ide; - return new Promise((resolve) => { + return new Promise((resolve) => { signUpUser(formValues) .then((response) => { dispatch(authenticateUser(response.data)); @@ -91,7 +136,7 @@ export function validateAndSignUpUser(formValues) { } export function getUser() { - return async (dispatch) => { + return async (dispatch: Dispatch) => { try { const response = await apiClient.get('/session'); const { data } = response; @@ -106,7 +151,7 @@ export function getUser() { preferences: data.preferences }); setLanguage(data.preferences.language, { persistPreference: false }); - } catch (error) { + } catch (error: any) { const message = error.response ? error.response.data.error || error.response.message : 'Unknown error.'; @@ -116,7 +161,7 @@ export function getUser() { } export function validateSession() { - return async (dispatch, getState) => { + return async (dispatch: Dispatch, getState: () => RootState) => { try { const response = await apiClient.get('/session'); const state = getState(); @@ -124,7 +169,7 @@ export function validateSession() { if (state.user.username !== response.data.username) { dispatch(showErrorModal('staleSession')); } - } catch (error) { + } catch (error: any) { if (error.response && error.response.status === 404) { dispatch(showErrorModal('staleSession')); } @@ -132,7 +177,7 @@ export function validateSession() { }; } -export function resetProject(dispatch) { +export function resetProject(dispatch: Dispatch) { dispatch({ type: ActionTypes.RESET_PROJECT }); @@ -143,7 +188,7 @@ export function resetProject(dispatch) { } export function logoutUser() { - return (dispatch) => { + return (dispatch: Dispatch) => { apiClient .get('/logout') .then(() => { @@ -159,9 +204,20 @@ export function logoutUser() { }; } -export function initiateResetPassword(formValues) { - return (dispatch) => - new Promise((resolve) => { +/** + * - Method: `POST` + * - Endpoint: `/reset-password` + * - Authenticated: `false` + * - Id: `UserController.resetPasswordInitiate` + * + * Description: + * - Send an Reset-Password email to the registered email account + */ +export function initiateResetPassword( + formValues: ResetPasswordInitiateRequestBody +) { + return (dispatch: Dispatch) => + new Promise((resolve) => { dispatch({ type: ActionTypes.RESET_PASSWORD_INITIATE }); @@ -179,8 +235,17 @@ export function initiateResetPassword(formValues) { }); } +/** + * - Method: `POST` + * - Endpoint: `/verify/send` + * - Authenticated: `false` + * - Id: `UserController.emailVerificationInitiate` + * + * Description: + * - Send a Confirm Email email to verify that the user owns the specified email account + */ export function initiateVerification() { - return (dispatch) => { + return (dispatch: Dispatch) => { dispatch({ type: ActionTypes.EMAIL_VERIFICATION_INITIATE }); @@ -199,8 +264,17 @@ export function initiateVerification() { }; } -export function verifyEmailConfirmation(token) { - return (dispatch) => { +/** + * - Method: `GET` + * - Endpoint: `/verify` + * - Authenticated: `false` + * - Id: `UserController.verifyEmail` + * + * Description: + * - Used in the Confirm Email's link to verify a user's email is attached to their account + */ +export function verifyEmailConfirmation(token: VerifyEmailQuery['t']) { + return (dispatch: Dispatch) => { dispatch({ type: ActionTypes.EMAIL_VERIFICATION_VERIFY, state: 'checking' @@ -229,8 +303,21 @@ export function resetPasswordReset() { }; } -export function validateResetPasswordToken(token) { - return (dispatch) => { +/** + * - Method: `GET` + * - Endpoint: `/reset-password/:token` + * - Authenticated: `false` + * - Id: `UserController.validateResetPasswordToken` + * + * Description: + * - The link in the Reset Password email, which contains a reset token that is valid for 1h + * - If valid, the user will see a form to reset their password + * - Else they will see a message that their token has expired + */ +export function validateResetPasswordToken( + token: ResetOrUpdatePasswordRequestParams['token'] +) { + return (dispatch: Dispatch) => { apiClient .get(`/reset-password/${token}`) .then(() => { @@ -244,9 +331,24 @@ export function validateResetPasswordToken(token) { }; } -export function updatePassword(formValues, token) { - return (dispatch) => - new Promise((resolve) => +/** + * - Method: `POST` + * - Endpoint: `/reset-password/:token` + * - Authenticated: `false` + * - Id: `UserController.updatePassword` + * + * Description: + * - Used by the new password form to update a user's password with the valid token + * - Returns a Generic 401 - 'Password reset token is invalid or has expired.' if the token timed out + * - Returns a PublicUser if successfully saved + * - Returns an Error if network error on save attempt + */ +export function updatePassword( + formValues: UpdatePasswordRequestBody, + token: ResetOrUpdatePasswordRequestParams['token'] +) { + return (dispatch: Dispatch) => + new Promise((resolve) => apiClient .post(`/reset-password/${token}`, formValues) .then((response) => { @@ -263,27 +365,46 @@ export function updatePassword(formValues, token) { ); } -export function updateSettingsSuccess(user) { +export function updateSettingsSuccess(user: PublicUser) { return { type: ActionTypes.SETTINGS_UPDATED, user }; } -export function submitSettings(formValues) { +/** + * - Method: `PUT` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.updateSettings` + * + * Description: + * - Used to update the user's username, email, or password on the `/account` page while authenticated + * - Currently the client only shows the `currentPassword` and `newPassword` fields if no social logins (github & google) are enabled + */ +export function submitSettings(formValues: UpdateSettingsRequestBody) { return apiClient.put('/account', formValues); } - -export function updateSettings(formValues) { - return (dispatch) => - new Promise((resolve) => { +/** + * - Method: `PUT` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.updateSettings` + * + * Description: + * - Used to update the user's username, email, or password on the `/account` page while authenticated + * - Currently the client only shows the `currentPassword` and `newPassword` fields if no social logins (github & google) are enabled + */ +export function updateSettings(formValues: Partial) { + return (dispatch: ThunkDispatch) => + new Promise((resolve) => { if (!formValues.currentPassword && formValues.newPassword) { dispatch(showToast(5500)); dispatch(setToastText('Toast.EmptyCurrentPass')); resolve(); return; } - submitSettings(formValues) + submitSettings(formValues as UpdateSettingsRequestBody) .then((response) => { dispatch(updateSettingsSuccess(response.data)); dispatch(showToast(5500)); @@ -313,15 +434,15 @@ export function updateSettings(formValues) { }); } -export function createApiKeySuccess(user) { +export function createApiKeySuccess(user: PublicUser) { return { type: ActionTypes.API_KEY_CREATED, user }; } -export function createApiKey(label) { - return (dispatch) => +export function createApiKey(label: CreateApiKeyRequestBody['label']) { + return (dispatch: Dispatch) => apiClient .post('/account/api-keys', { label }) .then((response) => { @@ -333,8 +454,8 @@ export function createApiKey(label) { }); } -export function removeApiKey(keyId) { - return (dispatch) => +export function removeApiKey(keyId: RemoveApiKeyRequestParams['keyId']) { + return (dispatch: Dispatch) => apiClient .delete(`/account/api-keys/${keyId}`) .then((response) => { @@ -349,8 +470,8 @@ export function removeApiKey(keyId) { }); } -export function unlinkService(service) { - return (dispatch) => { +export function unlinkService(service: string) { + return (dispatch: Dispatch) => { if (!['github', 'google'].includes(service)) return; apiClient .delete(`/auth/${service}`) @@ -365,9 +486,11 @@ export function unlinkService(service) { }; } -export function setUserCookieConsent(cookieConsent) { +export function setUserCookieConsent( + cookieConsent: UpdateCookieConsentRequestBody['cookieConsent'] +) { // maybe also send this to the server rn? - return (dispatch) => { + return (dispatch: Dispatch) => { apiClient .put('/cookie-consent', { cookieConsent }) .then(() => { From aae6687fe8f6ccdaf9fba48eb90125d8bc46a827 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 22:05:44 +0000 Subject: [PATCH 09/15] server/types/apiKey: update to add comment about token property on last item of return of createApiKeys --- server/controllers/user.controller/apiKey.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index e87747a75c..5a8cc613f0 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -67,7 +67,8 @@ export const createApiKey: RequestHandler< await user.save(); const apiKeys = user.apiKeys.map((apiKey, index) => { - const fields = apiKey.toObject!(); + const fields = apiKey.toObject(); + // only include the token of the most recently made apiKey to display in the copiable field const shouldIncludeToken = index === addedApiKeyIndex - 1; return shouldIncludeToken ? { ...fields, token: keyToBeHashed } : fields; From 90335b3ac5221b07ccd3d0340dc2c2337ce21759 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 22:07:08 +0000 Subject: [PATCH 10/15] server/types/apiKey: update toJson method return type to IApiKey --- server/types/apiKey.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts index 443a30e3e4..be21dfdbcd 100644 --- a/server/types/apiKey.ts +++ b/server/types/apiKey.ts @@ -15,8 +15,8 @@ export interface IApiKey extends VirtualId, MongooseTimestamps { export interface ApiKeyDocument extends IApiKey, Omit, 'id'> { - toJSON(options?: any): SanitisedApiKey; - toObject(options?: any): SanitisedApiKey; + toJSON(options?: any): IApiKey; + toObject(options?: any): IApiKey; } /** From 276356ff76af16420113989874634aeb7d7000a2 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 22:08:49 +0000 Subject: [PATCH 11/15] server/types/PublicUser: update with accurate PublicUser apiKeys, should be sanitisedApiKeys. Add method for sanitisingApiKeys --- .../user.controller/__testUtils__.ts | 12 +++++++--- .../authManagement/3rdPartyManagement.test.ts | 23 ++++++++++++------- .../user.controller/__tests__/helpers.test.ts | 19 ++++++++------- server/controllers/user.controller/helpers.ts | 20 +++++++++++++--- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/server/controllers/user.controller/__testUtils__.ts b/server/controllers/user.controller/__testUtils__.ts index 1a7bf31cd6..7bc90bf0e6 100644 --- a/server/controllers/user.controller/__testUtils__.ts +++ b/server/controllers/user.controller/__testUtils__.ts @@ -59,9 +59,15 @@ export const mockBaseUserFull: Omit = { export function createMockUser( overrides: Partial = {}, unSanitised: boolean = false -): (PublicUser | UserDocument) & Record { +): PublicUser | UserDocument { + if (unSanitised) { + return { + ...mockBaseUserFull, + ...overrides + } as UserDocument; + } return { - ...(unSanitised ? mockBaseUserFull : mockBaseUserSanitised), + ...mockBaseUserSanitised, ...overrides - }; + } as PublicUser; } diff --git a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts index d2e68ee61a..2910b33569 100644 --- a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts @@ -5,6 +5,7 @@ import { Request, Response } from 'express'; import { unlinkGithub, unlinkGoogle } from '../../authManagement'; import { saveUser } from '../../helpers'; import { createMockUser } from '../../__testUtils__'; +import { UserDocument } from '../../../../types'; jest.mock('../../helpers', () => ({ ...jest.requireActual('../../helpers'), @@ -50,10 +51,13 @@ describe('user.controller > auth management > 3rd party auth', () => { }); }); describe('and when there is a user in the request', () => { - const user = createMockUser({ - github: 'testuser', - tokens: [{ kind: 'github' }, { kind: 'google' }] - }); + const user = createMockUser( + { + github: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }, + true + ) as UserDocument; beforeEach(async () => { request.user = user; @@ -96,10 +100,13 @@ describe('user.controller > auth management > 3rd party auth', () => { }); }); describe('and when there is a user in the request', () => { - const user = createMockUser({ - google: 'testuser', - tokens: [{ kind: 'github' }, { kind: 'google' }] - }); + const user = createMockUser( + { + google: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }, + true + ) as UserDocument; beforeEach(async () => { request.user = user; diff --git a/server/controllers/user.controller/__tests__/helpers.test.ts b/server/controllers/user.controller/__tests__/helpers.test.ts index a6add4fda9..b04bb2548a 100644 --- a/server/controllers/user.controller/__tests__/helpers.test.ts +++ b/server/controllers/user.controller/__tests__/helpers.test.ts @@ -10,14 +10,17 @@ import { UserDocument } from '../../../types'; jest.mock('../../../models/user'); -const mockFullUser = createMockUser({ - // sensitive fields to be removed: - name: 'bob dylan', - tokens: [], - password: 'password12314', - resetPasswordToken: 'wijroaijwoer', - banned: true -}); +const mockFullUser = createMockUser( + { + // sensitive fields to be removed: + name: 'bob dylan', + tokens: [], + password: 'password12314', + resetPasswordToken: 'wijroaijwoer', + banned: true + }, + true +) as UserDocument; const { name, diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index 36bcf86649..7d05826fb1 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -1,19 +1,33 @@ import crypto from 'crypto'; import type { Response } from 'express'; import { User } from '../../models/user'; -import { PublicUser, UserDocument } from '../../types'; +import { + ApiKeyDocument, + PublicUser, + SanitisedApiKey, + UserDocument +} from '../../types'; + +export function sanitiseApiKey(key: ApiKeyDocument): SanitisedApiKey { + return { + id: key.id, + label: key.label, + lastUsedAt: key.lastUsedAt, + createdAt: key.createdAt + }; +} /** * Sanitise user objects to remove sensitive fields * @param user * @returns Sanitised user */ -export function userResponse(user: PublicUser | UserDocument): PublicUser { +export function userResponse(user: UserDocument): PublicUser { return { email: user.email, username: user.username, preferences: user.preferences, - apiKeys: user.apiKeys, + apiKeys: user.apiKeys.map((el) => sanitiseApiKey(el)), verified: user.verified, id: user.id, totalSize: user.totalSize, From ad4df7ab33cc03400dd455e31c7b6de48acec120 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 26 Oct 2025 22:42:18 +0000 Subject: [PATCH 12/15] client/modules/User/reducers: update root state of state.user to add password/email verification states + todo notes --- client/modules/User/reducers.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/modules/User/reducers.ts b/client/modules/User/reducers.ts index d8a7141683..08c07b94bf 100644 --- a/client/modules/User/reducers.ts +++ b/client/modules/User/reducers.ts @@ -11,6 +11,12 @@ export type UserAction = { export const user = ( state: Partial & { authenticated: boolean; + // TODO: use state of user from server as single source of truth: + // Currently using redux state below, but server also has similar info. + resetPasswordInitiate?: boolean; + resetPasswordInvalid?: boolean; + emailVerificationInitiate?: boolean; + emailVerificationTokenState?: boolean; } = { authenticated: false }, From 997180a6d72d28b4b7c8aa7a6f3e9d84483a6078 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Mon, 27 Oct 2025 03:36:22 +0000 Subject: [PATCH 13/15] server/types/user: remove typo for totalSize optional --- server/types/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/types/user.ts b/server/types/user.ts index 5d2eb61f34..1f180fee37 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -22,7 +22,7 @@ export interface IUser extends VirtualId, MongooseTimestamps { tokens: { kind: string }[]; apiKeys: Types.DocumentArray; preferences: UserPreferences; - totalSize?: number; + totalSize: number; cookieConsent: CookieConsentOptions; banned: boolean; lastLoginTimestamp?: Date; From 64db3d2624321e5d99250b97db86311924fa87af Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Mon, 27 Oct 2025 12:27:05 +0000 Subject: [PATCH 14/15] client/modules/User/reducers: update user state with correct emailVerifcationTokenStates --- client/modules/User/reducers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/modules/User/reducers.ts b/client/modules/User/reducers.ts index 08c07b94bf..584ef24adb 100644 --- a/client/modules/User/reducers.ts +++ b/client/modules/User/reducers.ts @@ -16,7 +16,7 @@ export const user = ( resetPasswordInitiate?: boolean; resetPasswordInvalid?: boolean; emailVerificationInitiate?: boolean; - emailVerificationTokenState?: boolean; + emailVerificationTokenState?: 'checking' | 'verified' | 'invalid'; } = { authenticated: false }, From 789447a24d3f8d0c815ba535939292a060403482 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Mon, 27 Oct 2025 12:31:40 +0000 Subject: [PATCH 15/15] add GetRootState function type, and refactor modules/User/actions & preferences --- client/modules/IDE/actions/preferences.ts | 2 +- client/modules/IDE/actions/preferences.types.ts | 4 +--- client/modules/User/actions.ts | 8 ++++---- client/reducers.ts | 3 +++ 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/modules/IDE/actions/preferences.ts b/client/modules/IDE/actions/preferences.ts index ccb5ef6a63..f626a20002 100644 --- a/client/modules/IDE/actions/preferences.ts +++ b/client/modules/IDE/actions/preferences.ts @@ -6,7 +6,6 @@ import type { UpdatePreferencesDispatch, SetPreferencesTabValue, SetFontSizeValue, - GetRootState, SetLineNumbersValue, SetAutocloseBracketsQuotesValue, SetAutocompleteHinterValue, @@ -20,6 +19,7 @@ import type { SetLanguageValue, SetThemeValue } from './preferences.types'; +import type { GetRootState } from '../../../reducers'; function updatePreferences( formParams: UpdatePreferencesRequestBody, diff --git a/client/modules/IDE/actions/preferences.types.ts b/client/modules/IDE/actions/preferences.types.ts index e54d28d3cd..dd56c65a77 100644 --- a/client/modules/IDE/actions/preferences.types.ts +++ b/client/modules/IDE/actions/preferences.types.ts @@ -1,6 +1,6 @@ import * as ActionTypes from '../../../constants'; import type { PreferencesState } from '../reducers/preferences'; -import type { RootState } from '../../../reducers'; +import type { GetRootState } from '../../../reducers'; // Value Definitions: export type SetPreferencesTabValue = PreferencesState['tabIndex']; @@ -112,5 +112,3 @@ export type PreferencesThunk = ( dispatch: UpdatePreferencesDispatch, getState: GetRootState ) => void; - -export type GetRootState = () => RootState; diff --git a/client/modules/User/actions.ts b/client/modules/User/actions.ts index ad47bc4d29..b34c2bd1b4 100644 --- a/client/modules/User/actions.ts +++ b/client/modules/User/actions.ts @@ -23,7 +23,7 @@ import type { UserPreferences, VerifyEmailQuery } from '../../../common/types'; -import { RootState } from '../../reducers'; +import type { GetRootState, RootState } from '../../reducers'; export function authError(error: Error) { return { @@ -76,7 +76,7 @@ export function validateAndLoginUser(formProps: { }) { return ( dispatch: ThunkDispatch, - getState: () => RootState + getState: GetRootState ) => { const state = getState(); const { previousPath } = state.ide; @@ -115,7 +115,7 @@ export function validateAndLoginUser(formProps: { * - Create a new user */ export function validateAndSignUpUser(formValues: CreateUserRequestBody) { - return (dispatch: Dispatch, getState: () => RootState) => { + return (dispatch: Dispatch, getState: GetRootState) => { const state = getState(); const { previousPath } = state.ide; return new Promise((resolve) => { @@ -161,7 +161,7 @@ export function getUser() { } export function validateSession() { - return async (dispatch: Dispatch, getState: () => RootState) => { + return async (dispatch: Dispatch, getState: GetRootState) => { try { const response = await apiClient.get('/session'); const state = getState(); diff --git a/client/reducers.ts b/client/reducers.ts index 47d9627f80..99f1b63b57 100644 --- a/client/reducers.ts +++ b/client/reducers.ts @@ -34,5 +34,8 @@ const rootReducer = combineReducers({ // Type for entire redux state export type RootState = ReturnType; +// Type for functions that get root state +export type GetRootState = () => RootState; + // eslint-disable-next-line import/no-default-export export default rootReducer;