From e414ad3849cdae0549f228197ac3ec9e52138378 Mon Sep 17 00:00:00 2001 From: Tin Geber <tin@greenhost.nl> Date: Thu, 12 Oct 2023 10:55:18 +0200 Subject: [PATCH] reset 2FA working --- frontend/public/index.html | 22 +--- frontend/src/components/Modal/Modal/Modal.tsx | 2 +- .../src/components/UserModal/UserModal.tsx | 106 +++++++++++++----- .../src/services/users/hooks/use-users.ts | 6 + frontend/src/services/users/redux/actions.ts | 18 +++ .../src/services/users/transformations.ts | 8 ++ 6 files changed, 110 insertions(+), 52 deletions(-) diff --git a/frontend/public/index.html b/frontend/public/index.html index 76284b1b..6d6ba053 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -8,36 +8,16 @@ <meta name="theme-color" content="#000000" /> <meta name="description" content="Stackspin" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" /> - <!-- - manifest.json provides metadata used when your web app is installed on a - user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ - --> + <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> - <!-- - Notice the use of %PUBLIC_URL% in the tags above. - It will be replaced with the URL of the `public` folder during the build. - Only files inside the `public` folder can be referenced from the HTML. - Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will - work correctly both with client-side routing and a non-root public URL. - Learn how to configure a non-root public URL by running `npm run build`. - --> <title>Stackspin Dashboard</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> - <!-- - This HTML file is a template. - If you open it directly in the browser, you will see an empty page. - - You can add webfonts, meta tags, or analytics to this file. - The build step will place the bundled scripts into the <body> tag. - To begin the development, run `npm start` or `yarn start`. - To create a production bundle, use `npm run build` or `yarn build`. - --> </body> </html> \ No newline at end of file diff --git a/frontend/src/components/Modal/Modal/Modal.tsx b/frontend/src/components/Modal/Modal/Modal.tsx index 24bd4813..c5a0694f 100644 --- a/frontend/src/components/Modal/Modal/Modal.tsx +++ b/frontend/src/components/Modal/Modal/Modal.tsx @@ -88,7 +88,7 @@ export const Modal: React.FC<ModalProps> = ({ <div className="bg-white px-4 p-6 relative overflow-y-auto">{children}</div> {onSave && ( - <div className="bg-gray-50 px-4 py-5 flex flex-shrink-0 w-full"> + <div className="bg-gray-50 px-4 py-5 flex flex-shrink-0 w-full border-0 border-t-2 border-gray-300"> <div className="mr-auto sm:flex gap-2">{leftActions}</div> <div className="ml-auto sm:flex sm:flex-row-reverse"> <button diff --git a/frontend/src/components/UserModal/UserModal.tsx b/frontend/src/components/UserModal/UserModal.tsx index 838c955b..7e089fdd 100644 --- a/frontend/src/components/UserModal/UserModal.tsx +++ b/frontend/src/components/UserModal/UserModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import _ from 'lodash'; -import { TrashIcon } from '@heroicons/react/outline'; +import { TrashIcon, KeyIcon, QrcodeIcon } from '@heroicons/react/outline'; import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { Banner, Modal, ConfirmationModal, InfoModal } from 'src/components'; import { Input, Select } from 'src/components/Form'; @@ -16,6 +16,7 @@ import { UserModalProps } from './types'; export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalProps) => { const [deleteModal, setDeleteModal] = useState(false); const [passwordLinkModal, setPasswordLinkModal] = useState(false); + const [totpModal, setTotpModal] = useState(false); const [isAdminRoleSelected, setAdminRoleSelected] = useState(true); const [isPersonalModal, setPersonalModal] = useState(false); const { @@ -30,6 +31,7 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP deleteUserById, getRecoveryLinkUserById, clearSelectedUser, + resetTotp, } = useUsers(); const { currentUser, isAdmin } = useAuth(); @@ -126,18 +128,18 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP setUserId(null); }; - const handleKeyPress = (e: any) => { - if (e.key === 'Enter' || e.key === 'NumpadEnter') { - handleSave(); - } - }; - const handleClose = () => { onClose(); clearSelectedUser(); setUserId(null); }; + const handleKeyPress = (e: any) => { + if (e.key === 'Enter' || e.key === 'NumpadEnter') { + handleSave(); + } + }; + const deleteModalOpen = () => setDeleteModal(true); const deleteModalClose = () => setDeleteModal(false); @@ -150,6 +152,15 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP const passwordLinkModalClose = () => setPasswordLinkModal(false); + const totpModalOpen = () => { + if (userId) { + resetTotp(userId); + } + setTotpModal(true); + }; + + const totpModalClose = () => setTotpModal(false); + const handleDelete = () => { if (userId) { deleteUserById(userId); @@ -171,8 +182,8 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP type="button" className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" > - <TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> - Delete + Delete user + <TrashIcon className="-mr-0.5 ml-2 h-4 w-4" aria-hidden="true" /> </button> ) ); @@ -187,9 +198,10 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP onClick={passwordLinkModalOpen} type="button" className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm - font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" + font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-700" > Password Link + <KeyIcon className="-mr-0.5 ml-2 h-4 w-4" aria-hidden="true" /> </button> ) ); @@ -202,12 +214,13 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP isAdmin && user.totp && ( <button - onClick={passwordLinkModalOpen} + onClick={totpModalOpen} type="button" className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm - font-medium rounded-md text-yellow-400 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" + font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-700" > Reset 2FA + <QrcodeIcon className="-mr-0.5 ml-2 h-4 w-4" aria-hidden="true" /> </button> ) ); @@ -215,26 +228,14 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP return ( <> - <Modal - onClose={handleClose} - open={open} - onSave={handleSave} - isLoading={userModalLoading} - leftActions={ - <> - {buttonDelete()} - {buttonPasswordLink()} - {buttonTotp()} - </> - } - useCancelButton - > + <Modal onClose={handleClose} open={open} onSave={handleSave} isLoading={userModalLoading} useCancelButton> <div className="bg-white px-4"> <div className="space-y-10 divide-y divide-gray-200"> <div> - <div> - <h3 className="text-lg leading-6 font-medium text-gray-900">{userId ? 'Edit user' : 'Add new user'}</h3> - {user.totp && <div>hello</div>} + <div className="py-4"> + <h3 className="text-lg leading-6 font-medium text-gray-900"> + {userId ? <span>Edit {user.email}</span> : 'Add new user'} + </h3> </div> <div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> @@ -302,7 +303,7 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP </div> {isAdminRoleSelected && ( - <div className="sm:col-span-6"> + <div className="sm:col-span-6 py-12"> <Banner title="Admin users automatically have admin-level access to all apps." titleSm="Admin user" @@ -355,6 +356,44 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP )} </div> )} + {isAdmin && !userModalLoading && ( + <div> + <div className="mt-8"> + <h3 className="text-lg leading-6 font-medium text-gray-900">User Access</h3> + </div> + <div> + <div className="flow-root mt-6"> + <ul className="-my-5 divide-y divide-gray-200"> + <li className="py-4"> + <div className="flex items-center justify-between"> + <p className="leading-6 text-sm text-gray-500"> + Generate password reset link for {user.email} + </p> + {buttonPasswordLink()} + </div> + </li> + {user.totp && ( + <li className="py-4"> + <div className="flex items-center justify-between"> + <p className="leading-6 text-sm text-gray-500">Reset 2-factor authentication</p> + {buttonTotp()} + </div> + </li> + )} + + {user.email !== currentUser?.email && ( + <li className="py-4"> + <div className="flex items-center justify-between"> + <p className="leading-6 text-sm text-gray-500">Delete {user.email}</p> + {buttonDelete()} + </div> + </li> + )} + </ul> + </div> + </div> + </div> + )} </div> </div> </Modal> @@ -372,6 +411,13 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP body="Below is a password reset link. Copy this link and send to the user to reset the password without using e-mail" dynamicData={recoveryLink} /> + <InfoModal + open={totpModal} + onClose={totpModalClose} + title="Reset 2-Factor Authentication" + body="You have successfully removed the user's 2FA device." + dynamicData={recoveryLink} + /> </> ); }; diff --git a/frontend/src/services/users/hooks/use-users.ts b/frontend/src/services/users/hooks/use-users.ts index cf189fc8..ebcbb917 100644 --- a/frontend/src/services/users/hooks/use-users.ts +++ b/frontend/src/services/users/hooks/use-users.ts @@ -12,6 +12,7 @@ import { clearCurrentUser, createBatchUsers, fetchRecoveryLink, + resetTotpById, } from '../redux'; import { getUserById, getRecoveryLink, getUserModalLoading, getUserslLoading } from '../redux/selectors'; @@ -66,6 +67,10 @@ export function useUsers() { return dispatch(fetchRecoveryLink(id)); } + function resetTotp(id: string) { + return dispatch(resetTotpById(id)); + } + return { users, user, @@ -83,5 +88,6 @@ export function useUsers() { getRecoveryLinkUserById, clearSelectedUser, createUsers, + resetTotp, }; } diff --git a/frontend/src/services/users/redux/actions.ts b/frontend/src/services/users/redux/actions.ts index 394a9340..76e7eaad 100644 --- a/frontend/src/services/users/redux/actions.ts +++ b/frontend/src/services/users/redux/actions.ts @@ -12,6 +12,7 @@ import { transformUser, transformUpdateMultipleUsers, transformRecoveryLink, + transformTotp, } from '../transformations'; export enum UserActionTypes { @@ -25,6 +26,7 @@ export enum UserActionTypes { SET_USERS_LOADING = 'users/users_loading', CREATE_BATCH_USERS = 'users/create_batch_users', UPDATE_MULTIPLE_USERS = '/users/multi-edit', + RESET_TOTP = 'users/reset-totp-user', } export const setUsersLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => { @@ -235,6 +237,22 @@ export const fetchRecoveryLink = (id: string) => async (dispatch: Dispatch<any>) } }; +export const resetTotpById = (id: string) => async (dispatch: Dispatch<any>) => { + try { + const { data } = await performApiCall({ + path: `/users/${id}/reset_2fa`, + method: 'POST', + }); + + dispatch({ + type: UserActionTypes.RESET_TOTP, + payload: transformTotp(data), + }); + } catch (err) { + console.error(err); + } +}; + export const deleteUser = (id: string) => async (dispatch: Dispatch<any>) => { dispatch(setUserModalLoading(true)); diff --git a/frontend/src/services/users/transformations.ts b/frontend/src/services/users/transformations.ts index 41e1525b..e430c1f0 100644 --- a/frontend/src/services/users/transformations.ts +++ b/frontend/src/services/users/transformations.ts @@ -119,3 +119,11 @@ export const transformBatchResponse = (response: any): any => { export const transformRecoveryLink = (response: any): string => { return response.recovery_link; }; + +export const transformResetTotpById = (response: any): any => { + return { + success: response.success, + existing: response.existing, + failed: response.failed, + }; +}; -- GitLab