diff --git a/frontend/public/index.html b/frontend/public/index.html index aa069f27cbd9d53394428171c3989fd03db73c76..6d6ba053c4dc7a32796130e7455acdea2fa7c935 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,43 +1,23 @@ <!DOCTYPE html> <html lang="en"> - <head> - <meta charset="utf-8" /> - <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <meta name="theme-color" content="#000000" /> - <meta - name="description" - content="Web site created using create-react-app" - /> - <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.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>React App</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. +<head> + <meta charset="utf-8" /> + <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="theme-color" content="#000000" /> + <meta name="description" content="Stackspin" /> + <link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" /> - You can add webfonts, meta tags, or analytics to this file. - The build step will place the bundled scripts into the <body> tag. + <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> - To begin the development, run `npm start` or `yarn start`. - To create a production bundle, use `npm run build` or `yarn build`. - --> - </body> -</html> + <title>Stackspin Dashboard</title> +</head> + +<body> + <noscript>You need to enable JavaScript to run this app.</noscript> + <div id="root"></div> + +</body> + +</html> \ No newline at end of file diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 080d6c77ac21bb2ef88a6992b2b73ad93daaca92..8eec81fe6b57259f0c896a98719c8c4b466572f5 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Stackspin", + "name": "Stackspin Dashboard", "icons": [ { "src": "favicon.ico", @@ -8,14 +8,14 @@ "type": "image/x-icon" }, { - "src": "logo192.png", + "src": "android-chrome-192x192.png", "type": "image/png", "sizes": "192x192" }, { - "src": "logo512.png", + "src": "android-chrome-256x256.png", "type": "image/png", - "sizes": "512x512" + "sizes": "256x256" } ], "start_url": ".", diff --git a/frontend/src/components/Modal/Modal/Modal.tsx b/frontend/src/components/Modal/Modal/Modal.tsx index 00e5ba031f395cca03cbb27744782169b01211b5..c5a0694fddea0c8a28474cf014a096fe545bbf11 100644 --- a/frontend/src/components/Modal/Modal/Modal.tsx +++ b/frontend/src/components/Modal/Modal/Modal.tsx @@ -23,7 +23,24 @@ export const Modal: React.FC<ModalProps> = ({ return ( <Transition.Root show={open} as={Fragment}> <Dialog as="div" className="fixed z-10 inset-0 overflow-y-auto" initialFocus={cancelButtonRef} onClose={() => {}}> - <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> + {isLoading && ( + <Dialog.Overlay className="isloading inset-0 bg-gray-400 z-20 bg-opacity-75 transition-opacity absolute flex justify-center items-center"> + <svg + className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + > + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" + /> + </svg> + </Dialog.Overlay> + )} + <div className="pt-4 h-full px-4 pb-20 text-center sm:block sm:p-0"> <Transition.Child as={Fragment} enter="ease-out duration-300" @@ -37,9 +54,9 @@ export const Modal: React.FC<ModalProps> = ({ </Transition.Child> {/* This element is to trick the browser into centering the modal contents. */} - <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> + {/* <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> ​ - </span> + </span> */} <Transition.Child as={Fragment} enter="ease-out duration-300" @@ -49,72 +66,56 @@ export const Modal: React.FC<ModalProps> = ({ leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full relative"> - {isLoading && ( - <Dialog.Overlay className="inset-0 bg-gray-400 bg-opacity-75 transition-opacity absolute flex justify-center items-center"> - <svg - className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - > - <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> - <path - className="opacity-75" - fill="currentColor" - d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" - /> - </svg> - </Dialog.Overlay> - )} - - {!useCancelButton && ( - <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:items-center sm:justify-between"> - <div className="flex items-center"> - <img className="rounded-md" width={32} src={img} alt={title} /> - <span className="ml-2 uppercase font-bold">{title}</span> - </div> - <button - type="button" - className="w-full inline-flex justify-center rounded-md border border-gray-200 p-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" - onClick={onClose} - ref={cancelButtonRef} - > - <XIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> - </button> - </div> - )} - - <div className="bg-white px-4 p-6">{children}</div> - - {onSave && ( - <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex"> - {leftActions} - <div className="ml-auto sm:flex sm:flex-row-reverse"> + <div className="w-full h-full flex justify-center items-center"> + <div className="bg-white h-4/5 overflow-auto flex flex-col rounded-lg text-left shadow-xl transform transition-all sm:max-w-2xl sm:w-full"> + {!useCancelButton && ( + <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:items-center sm:justify-between"> + <div className="flex items-center"> + <img className="rounded-md" width={32} src={img} alt={title} /> + <span className="ml-2 uppercase font-bold">{title}</span> + </div> <button type="button" - className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm ${ - saveButtonDisabled ? 'opacity-50' : '' - }`} - onClick={onSave} - ref={saveButtonRef} - disabled={saveButtonDisabled} + className="w-full inline-flex justify-center rounded-md border border-gray-200 p-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" + onClick={onClose} + ref={cancelButtonRef} > - {saveButtonTitle} + <XIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> </button> - {useCancelButton && ( + </div> + )} + + <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 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 type="button" - className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-200 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" - onClick={onClose} - ref={cancelButtonRef} + className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm ${ + saveButtonDisabled ? 'opacity-50' : '' + }`} + onClick={onSave} + ref={saveButtonRef} + disabled={saveButtonDisabled} > - {cancelButtonTitle} + {saveButtonTitle} </button> - )} + {useCancelButton && ( + <button + type="button" + className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-200 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" + onClick={onClose} + ref={cancelButtonRef} + > + {cancelButtonTitle} + </button> + )} + </div> </div> - </div> - )} + )} + </div> </div> </Transition.Child> </div> diff --git a/frontend/src/components/UserModal/UserModal.tsx b/frontend/src/components/UserModal/UserModal.tsx index 05679dcbf7cd6d56a362e42a0ce24e851bcb6618..2cca37875ab79bad557ab771934293dcb860cd0e 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,17 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP const passwordLinkModalClose = () => setPasswordLinkModal(false); + const totpModalOpen = () => { + if (userId) { + resetTotp(userId); + clearSelectedUser(); + setUserId(userId); + } + setTotpModal(true); + }; + + const totpModalClose = () => setTotpModal(false); + const handleDelete = () => { if (userId) { deleteUserById(userId); @@ -171,8 +184,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 +200,29 @@ 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> + ) + ); + }; + + // Button to reset 2FA + const buttonTotp = () => { + return ( + userId && + isAdmin && + user.totp && ( + <button + 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-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> ) ); @@ -197,24 +230,14 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP return ( <> - <Modal - onClose={handleClose} - open={open} - onSave={handleSave} - isLoading={userModalLoading} - leftActions={ - <> - {buttonDelete()} - {buttonPasswordLink()} - </> - } - 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> + <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"> @@ -282,7 +305,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" @@ -335,6 +358,53 @@ 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> + {user.totp ? ( + <>{buttonTotp()}</> + ) : ( + <p + className="leading-6 text-sm text-gray-400 mb-4 sm:mb-0 inline-flex items-center px-4 py-2 + font-medium rounded-md bg-gray-50" + > + No 2FA set + </p> + )} + </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> @@ -352,6 +422,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="" + /> </> ); }; diff --git a/frontend/src/services/users/hooks/use-users.ts b/frontend/src/services/users/hooks/use-users.ts index cf189fc899b70be2da788eb9621bd953ea3e2265..ebcbb9170c688d2410303c9d78620be840c05c7b 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 394a93407288cbd18e7af40f6e1661c419715c89..76e7eaad04ef5b59af9baf8efc6092bd5fc5f5e7 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 3d4a0ecb85084b1f5ede794f1c8a9c569446cb52..e430c1f08aca360bab416598331b6023c67a31c2 100644 --- a/frontend/src/services/users/transformations.ts +++ b/frontend/src/services/users/transformations.ts @@ -45,6 +45,13 @@ export const transformRequestAppRoles = (data: AppRoles): any => { }; }; +export const transformTotp = (data: any) => { + if (data.credentials !== undefined) { + return data.credentials.totp !== undefined; + } + return undefined; +}; + export const transformUser = (response: any): User => { return { id: response.id ?? '', @@ -53,6 +60,7 @@ export const transformUser = (response: any): User => { name: response.traits.name ?? '', preferredUsername: response.preferredUsername ?? '', status: response.state ?? '', + totp: transformTotp(response), }; }; @@ -111,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, + }; +}; diff --git a/frontend/src/services/users/types.ts b/frontend/src/services/users/types.ts index bdc6cf7303ab798375668315d26eafcea9a8ed66..cb3112f8180bd05dc9c39ed9cde33312e99769f9 100644 --- a/frontend/src/services/users/types.ts +++ b/frontend/src/services/users/types.ts @@ -5,6 +5,7 @@ export interface User { name: string; preferredUsername: string; status: string; + totp?: boolean; } export interface FormUser extends User {