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/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 0c532f579e..2896c38473 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -10,6 +10,12 @@ 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'; + +vi.mock('../../src/integrations/slack.js', () => ({ + validateSlackUserId: vi.fn() +})); describe('User Tests', () => { let orgId: string; @@ -119,4 +125,24 @@ describe('User Tests', () => { ).rejects.toThrow(new AccessDeniedException('Guests and members cannot update user roles!')); }); }); + + 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); + }); + + 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 () => { + (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(false); + const result = await UsersService.validateSlackId('U06D5RURPMF'); + expect(result).toBe(false); + }); + }); }); 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/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) { 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..e2aa183cdb 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,9 @@ 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/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( - + ); }; 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,