From 1d46f0c2f43048ed70eb87463cf620df1db3da2a Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 26 Jun 2026 16:27:37 -0400 Subject: [PATCH 1/5] validation endpoint --- .../src/controllers/users.controllers.ts | 10 ++++++++++ src/backend/src/integrations/slack.ts | 19 ++++++++++++++++++- src/backend/src/routes/users.routes.ts | 2 ++ src/backend/src/services/users.services.ts | 10 ++++++++++ src/frontend/src/app/AppAuthenticated.tsx | 5 +++-- src/frontend/src/app/AppPublic.tsx | 7 ++++++- 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index 75fe877002..00e1f637b1 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -242,4 +242,14 @@ export default class UsersController { next(error); } } + + static async validateSlackId(req: Request, res: Response, next: NextFunction) { + try { + const { slackId } = req.body; + const isValid = await UsersService.validateSlackId(slackId); + res.status(200).json({ isValid }); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 0ac331f125..9ecde09c57 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -289,7 +289,7 @@ export const checkBotInChannel = async (channelId: string): Promise => }; /** - * Given a slack user id, prood.uces the name of the channel + * Given a slack user id, produces the name of the channel * @param userId the id of the slack user * @returns the name of the user (real name if no display name), undefined if cannot be found */ @@ -380,3 +380,20 @@ export const getReceiver = (): ExpressReceiver | null => { // Export the getters for any direct usage if needed export { getSlackClient }; export default getSlackClient; + +/** + * Validates that a given Slack user id exists in the workspace + * All slack ids start with U. If you pass a valid user id to users.info, it returns ok: true; throws error otherwise. + * @param slackId the Slack user id to validate + * @returns true if the user exists, false otherwise + */ +export const validateSlackUserId = async (slackId: string): Promise => { + const client = getSlackClient(); + if (!client) return false; + try { + const res = await client.users.info({ user: slackId }); + return res.ok === true; + } catch (error) { + return false; + } +}; diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 98a7b6b21f..11cba8d7ee 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -66,4 +66,6 @@ userRouter.post( UsersController.getManyUserTasks ); +userRouter.post('/validate-slack-id', nonEmptyString(body('slackId')), validateInputs, UsersController.validateSlackId); + export default userRouter; diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 69bf4cf9e5..bce2daf453 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -29,6 +29,7 @@ import authenticatedUserTransformer from '../transformers/auth-user.transformer. import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args.js'; import taskTransformer from '../transformers/tasks.transformer.js'; import { validateUserIsPartOfFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; +import { validateSlackUserId } from '../integrations/slack.js'; export default class UsersService { /** @@ -622,4 +623,13 @@ export default class UsersService { return users.map(userWithScheduleSettingsTransformer); } + + /** + * Validates a user's slack id + * @param slackId the Slack user id to validate + * @returns true if the user exists, false otherwise + */ + static async validateSlackId(slackId: string): Promise { + return validateSlackUserId(slackId); + } } diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index f4228acb34..5b322ea7a4 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -37,9 +37,10 @@ import SidebarLayout from '../layouts/SidebarLayout'; interface AppAuthenticatedProps { userId: string; userRole: Role; + completedOnboarding: boolean; } -const AppAuthenticated: React.FC = ({ userId, userRole }) => { +const AppAuthenticated: React.FC = ({ userId, userRole, completedOnboarding }) => { const { isLoading, isError, error, data: userSettingsData } = useSingleUserSettings(userId); const { @@ -64,7 +65,7 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) return ( - {userSettingsData.slackId || isGuest(userRole) ? ( + {userSettingsData.slackId || (isGuest(userRole) && !completedOnboarding) ? ( diff --git a/src/frontend/src/app/AppPublic.tsx b/src/frontend/src/app/AppPublic.tsx index 0591137c0e..e9be388e0c 100644 --- a/src/frontend/src/app/AppPublic.tsx +++ b/src/frontend/src/app/AppPublic.tsx @@ -34,7 +34,12 @@ const AppPublic: React.FC = () => { return ; } - return ; + //get onboarding completion to pass to authenticated app for routing + const completedOnboarding = auth.user.onboardedTeamTypeIds.length > 0; + + return ( + + ); } if (!auth.user && !auth.triedCurrent) { From bacb7b1e7d820705378e18da6fc8b1e6856b7c9d Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 14:25:41 -0400 Subject: [PATCH 2/5] api and hook, validate in SetUserPreferences --- src/frontend/src/apis/users.api.ts | 10 +++ src/frontend/src/hooks/users.hooks.ts | 13 ++- .../src/pages/AcceptedPage/AcceptedPage.tsx | 86 ------------------- src/frontend/src/pages/HomePage/Home.tsx | 4 +- .../src/pages/HomePage/OnboardingHomePage.tsx | 6 +- .../components/SetUserPreferences.tsx | 9 +- src/frontend/src/utils/routes.ts | 2 - src/frontend/src/utils/urls.ts | 2 + 8 files changed, 38 insertions(+), 94 deletions(-) delete mode 100644 src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index 4da38b9489..bb0292e847 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -212,3 +212,13 @@ export const getManyUsersWithScheduleSettings = (userIds: string[]) => { export const logUserOut = () => { return axios.post<{ message: string }>(apiUrls.logUserOut()); }; + +/** + * Validates a user's slack id + * + * @param slackId the user's slack id + * @returns true if the slack id is valid, false otherwise + */ +export const validateSlackId = (slackId: string) => { + return axios.post<{ isValid: boolean }>(apiUrls.validateSlackId(), { slackId }); +}; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index c890c23671..ffb3eb19bd 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -24,7 +24,8 @@ import { logUserOut, getManyUsersWithScheduleSettings, getAllOrgUsers, - getAllOrgMembers + getAllOrgMembers, + validateSlackId } from '../apis/users.api'; import { User, @@ -322,3 +323,13 @@ export const useLogUserOut = () => { return data; }); }; + +/** + * Custom react hook to determine if a user's slack id is valid + */ +export const useValidateSlackId = () => { + return useMutation<{ isValid: boolean }, Error, string>(['users', 'validate-slack-id'], async (slackId: string) => { + const { data } = await validateSlackId(slackId); + return data; + }); +}; diff --git a/src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx b/src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx deleted file mode 100644 index ffc1848737..0000000000 --- a/src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Typography, Box, Grid } from '@mui/material'; -import PageLayout from '../../components/PageLayout'; -import { NERButton } from '../../components/NERButton'; -import { useHistory } from 'react-router-dom'; -import { useCurrentUser } from '../../hooks/users.hooks'; -import { routes } from '../../utils/routes'; -import { useCompleteOnboarding } from '../../hooks/team-types.hooks'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import { useCurrentOrganization } from '../../hooks/organizations.hooks'; - -const AcceptedPage = () => { - const history = useHistory(); - const user = useCurrentUser(); - const { data: organization, isLoading: organizationIsLoading } = useCurrentOrganization(); - - const { mutateAsync: completeOnboarding, isLoading: completeOnboardingIsLoading } = useCompleteOnboarding(); - - if (completeOnboardingIsLoading || !organization || organizationIsLoading) { - return ; - } - - const handleClick = async () => { - await completeOnboarding(); - window.location.reload(); - }; - - return ( - - - - Congratulations, {user.firstName}! - - - We are so excited to welcome you to {organization.name}! - - - - - - We can't wait to see you around and all that you'll accomplish! - - - - - - history.push(routes.HOME_SELECT_SUBTEAM)}> - Reject - - - - - Accept - - - - - - ); -}; -export default AcceptedPage; diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 7439241e4a..54e5f267a4 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -7,7 +7,6 @@ import { routes } from '../../utils/routes'; import PNMHomePage from './PNMHomePage'; import OnboardingHomePage from './OnboardingHomePage'; import SelectSubteamPage from './SelectSubteamPage'; -import AcceptedPage from '../AcceptedPage/AcceptedPage'; import HomePage from './HomePage'; import { useCurrentUser } from '../../hooks/users.hooks'; import IntroGuestHomePage from './IntroGuestHomePage'; @@ -23,12 +22,11 @@ const Home: React.FC = () => { {completedOnboarding && !isAdmin(user.role) && - [routes.HOME_PNM, routes.HOME_ONBOARDING, routes.HOME_ACCEPT].map((path) => ( + [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => ( ))} {onOnboarding && !completedOnboarding && } - diff --git a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index 7f6b9836a9..25a23ca79f 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -13,6 +13,7 @@ import { routes } from '../../utils/routes'; import { useCurrentOrganization } from '../../hooks/organizations.hooks'; import OnboardingProgressBar from '../../components/OnboardingProgressBar'; import ErrorPage from '../ErrorPage'; +import { useCompleteOnboarding } from '../../hooks/team-types.hooks'; const OnboardingHomePage = () => { const history = useHistory(); @@ -41,6 +42,8 @@ const OnboardingHomePage = () => { const progress = useChecklistProgress(usersChecklists || [], checkedChecklists || []); + const { mutateAsync: completeOnboarding } = useCompleteOnboarding(); + if (usersChecklistsIsError) { return ; } @@ -69,7 +72,8 @@ const OnboardingHomePage = () => { }; const handleConfirmModal = async () => { - history.push(routes.HOME_ACCEPT); + await completeOnboarding(); + history.push(routes.HOME); }; return ( diff --git a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx index 74292b5dfd..11634e4805 100644 --- a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx +++ b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx @@ -15,7 +15,7 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import NERSuccessButton from '../../../components/NERSuccessButton'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { useToast } from '../../../hooks/toasts.hooks'; -import { useUpdateUserSettings } from '../../../hooks/users.hooks'; +import { useUpdateUserSettings, useValidateSlackId } from '../../../hooks/users.hooks'; import ErrorPage from '../../ErrorPage'; interface SetUserPreferencesProps { @@ -28,13 +28,20 @@ const SetUserPreferences: React.FC = ({ userSettings }) const { handleSubmit, control } = useForm<{ slackId: string }>({ defaultValues: { slackId: userSettings.slackId } }); + const { mutateAsync: validateSlackId } = useValidateSlackId(); if (isLoading) return ; if (isError) return ; const onSubmit = async ({ slackId }: { slackId: string }) => { try { + const { isValid } = await validateSlackId(slackId); + if (!isValid) { + toast.error('Invalid Slack ID! Please check it and try again.'); + return; + } await mutateAsync({ ...userSettings, slackId }); + // window.location.reload(); might not need this if it rerenders automatically } catch (error: unknown) { if (error instanceof Error) { toast.error(error.message); diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index ce658120bd..bd4b941769 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -15,7 +15,6 @@ const CREDITS = `/credits`; const HOME = `/home`; const HOME_PNM = HOME + `/pnm`; const HOME_SELECT_SUBTEAM = HOME + `/select-subteam`; -const HOME_ACCEPT = HOME + `/accept`; const HOME_MEMBER = HOME + `/member`; const HOME_ONBOARDING = HOME + `/onboarding`; @@ -93,7 +92,6 @@ export const routes = { HOME_PNM, HOME_SELECT_SUBTEAM, HOME_ONBOARDING, - HOME_ACCEPT, HOME_MEMBER, TEAMS, diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ee39409179..609f913492 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -32,6 +32,7 @@ const manyUserTasks = () => `${users()}/tasks/get-many`; const currentUser = () => `${users()}/auth/current`; const logUserOut = () => `${users()}/auth/log-out`; const manyUsersWithScheduleSettings = () => `${users()}/scheduleSettings`; +const validateSlackId = () => `${users()}/validate-slack-id`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -532,6 +533,7 @@ export const apiUrls = { currentUser, logUserOut, manyUsersWithScheduleSettings, + validateSlackId, projects, allProjectsGantt, From ae7510d7087b82e2ae326696f409056bbea0c47a Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 14:45:27 -0400 Subject: [PATCH 3/5] unit tests --- src/backend/tests/unit/users.test.ts | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 0c532f579e..62e85009e4 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -10,6 +10,8 @@ import { import UsersService from '../../src/services/users.services.js'; import { NotFoundException, AccessDeniedException } from '../../src/utils/errors.utils.js'; import { RoleEnum } from 'shared'; +import { vi, Mock } from 'vitest'; +import * as slackIntegration from '../../src/integrations/slack.js'; describe('User Tests', () => { let orgId: string; @@ -119,4 +121,35 @@ describe('User Tests', () => { ).rejects.toThrow(new AccessDeniedException('Guests and members cannot update user roles!')); }); }); + + describe('Validate slack id tests', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns true for a valid Slack ID', async () => { + vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(true); + + const result = await UsersService.validateSlackId('U06D5RURPMF'); + + expect(result).toBe(true); + expect(slackIntegration.validateSlackUserId).toHaveBeenCalledWith('U06D5RURPMF'); + }); + + it('returns false for an invalid Slack ID', async () => { + vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); + + const result = await UsersService.validateSlackId('NOTAVALIDID'); + + expect(result).toBe(false); + }); + + it('returns false when Slack client is not configured', async () => { + vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); + + const result = await UsersService.validateSlackId('U06D5RURPMF'); + + expect(result).toBe(false); + }); + }); }); From 8e83829f1412dbe8f9919b597d3aaf7352417264 Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 14:48:54 -0400 Subject: [PATCH 4/5] prettier --- src/frontend/src/pages/HomePage/Home.tsx | 4 +--- src/frontend/src/tests/app/AppAuthenticated.test.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 54e5f267a4..e2aa183cdb 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -22,9 +22,7 @@ const Home: React.FC = () => { {completedOnboarding && !isAdmin(user.role) && - [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => ( - - ))} + [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => )} {onOnboarding && !completedOnboarding && } diff --git a/src/frontend/src/tests/app/AppAuthenticated.test.tsx b/src/frontend/src/tests/app/AppAuthenticated.test.tsx index 33d961cbb4..0d38fc4b11 100644 --- a/src/frontend/src/tests/app/AppAuthenticated.test.tsx +++ b/src/frontend/src/tests/app/AppAuthenticated.test.tsx @@ -30,7 +30,7 @@ const renderComponent = (path?: string, route?: string) => { const RouterWrapper = routerWrapperBuilder({ path, route }); return render( - + ); }; From 978c190d74ae0fc120c7db757bd9036cc778a3f8 Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 15:06:36 -0400 Subject: [PATCH 5/5] different test pattern --- src/backend/tests/unit/users.test.ts | 29 +++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 62e85009e4..2896c38473 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -13,6 +13,10 @@ import { RoleEnum } from 'shared'; import { vi, Mock } from 'vitest'; import * as slackIntegration from '../../src/integrations/slack.js'; +vi.mock('../../src/integrations/slack.js', () => ({ + validateSlackUserId: vi.fn() +})); + describe('User Tests', () => { let orgId: string; let organization: Organization; @@ -122,33 +126,22 @@ describe('User Tests', () => { }); }); - describe('Validate slack id tests', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns true for a valid Slack ID', async () => { - vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(true); - + describe('Validate Slack id tests', () => { + it('returns true for a valid Slack id', async () => { + (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(true); const result = await UsersService.validateSlackId('U06D5RURPMF'); - expect(result).toBe(true); - expect(slackIntegration.validateSlackUserId).toHaveBeenCalledWith('U06D5RURPMF'); }); - it('returns false for an invalid Slack ID', async () => { - vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); - - const result = await UsersService.validateSlackId('NOTAVALIDID'); - + it('returns false for an invalid Slack id', async () => { + (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(false); + const result = await UsersService.validateSlackId('BLAH'); expect(result).toBe(false); }); it('returns false when Slack client is not configured', async () => { - vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); - + (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(false); const result = await UsersService.validateSlackId('U06D5RURPMF'); - expect(result).toBe(false); }); });