diff --git a/.env.example b/.env.example index 7663c9be..1725b318 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ VITE_PARSEABLE_URL="https://demo.parseable.com" +VITE_USE_BASIC_AUTH=true +VITE_USERNAME=admin +VITE_PASSWORD=admin \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e6e1d02f..2ea4c04e 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -15,13 +15,10 @@ jobs: node-version: lts/* - name: Install dependencies run: npm install -g pnpm && pnpm install + - name: Build Console + run: pnpm run build - name: Install Playwright Browsers run: pnpm exec playwright install --with-deps - - name: Start the development server - run: pnpm run dev & - env: - PORT: 3001 - VITE_PARSEABLE_URL: 'https://demo.parseable.com' - name: Run Playwright tests run: pnpm exec playwright test - uses: actions/upload-artifact@v4 diff --git a/package.json b/package.json index 161efca0..17a973a6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "start": "vite preview --host --port 3002", "tsCheck": "tsc --noEmit", - "pq": "pretty-quick" + "pq": "pretty-quick", + "test": "playwright test", + "test-ui": "playwright test --ui" }, "dependencies": { "@apache-arrow/ts": "^14.0.2", diff --git a/playwright.config.ts b/playwright.config.ts index c607f68f..5c90d80b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,8 +2,9 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', + testIgnore: '**/login.spec.ts', /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: true, // Set this to false to ensure sequential execution of files /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ @@ -11,7 +12,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: 'line', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -27,22 +28,27 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // { + // name: 'Firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // { + // name: 'Safari', + // use: { ...devices['Desktop Safari'] }, + // }, ], /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, + webServer: { + command: 'pnpm run dev', + url: 'http://localhost:3001', + reuseExistingServer: false, + env: { + PORT: '3001', + VITE_PARSEABLE_URL: 'https://demo.parseable.com', + VITE_USE_BASIC_AUTH: 'true', + VITE_USERNAME: 'admin', + VITE_PASSWORD: 'admin', + }, + }, }); diff --git a/src/components/Button/IconButton.tsx b/src/components/Button/IconButton.tsx index ef64bdb3..18b8ea38 100644 --- a/src/components/Button/IconButton.tsx +++ b/src/components/Button/IconButton.tsx @@ -5,6 +5,7 @@ import classes from './Button.module.css'; type IconButtonProps = { onClick?: () => void; renderIcon: () => ReactNode; + data_id?: string; icon?: ReactNode; active?: boolean; tooltipLabel?: string; @@ -17,6 +18,7 @@ const IconButton: FC = (props) => { return ( @@ -27,6 +29,7 @@ const IconButton: FC = (props) => { } else { return ( diff --git a/src/components/Misc/DeleteOrResetModal.tsx b/src/components/Misc/DeleteOrResetModal.tsx new file mode 100644 index 00000000..686f0def --- /dev/null +++ b/src/components/Misc/DeleteOrResetModal.tsx @@ -0,0 +1,130 @@ +import { Box, Button, Modal, Stack, Text, TextInput } from '@mantine/core'; +import classes from './styles/DeleteOrResetModal.module.css'; +import { ChangeEvent, useCallback, useState } from 'react'; + +type BaseProps = { + isModalOpen: boolean; + onClose: () => void; + modalHeader: string; + specialContent?: React.ReactNode; + modalContent: string; + actionProcessingContent?: React.ReactNode; + isActionInProgress?: boolean; + onConfirm: () => void; +}; + +// Note: The `confirmationText` and `placeholder` props are required for 'delete' and 'reset' types, but not for 'simple' type. +type DeleteOrResetModalProps = + | (BaseProps & { + type: 'simple'; + confirmationText?: never; // Will throw an error if `confirmationText` is passed + placeholder?: never; + }) + | (BaseProps & { + type: 'delete' | 'reset'; + confirmationText: string; + placeholder: string; + }); + +/** + * Confirmation modal for deleting or resetting an item. + * @param type - Specifies the type of modal ('simple', 'delete', or 'reset'). + * @param isModalOpen - Controls whether the modal is visible. + * @param onClose - Callback to close the modal and reset the state. + * @param modalHeader - Header text displayed in the modal title. + * @param specialContent - Optional content for additional context or customization. + * @param modalContent - Main descriptive content of the modal. + * @param placeholder - Input placeholder for confirmation text (applicable to 'delete' and 'reset'). + * @param confirmationText - Text required to confirm the action (applicable to 'delete' and 'reset'). + * @param actionProcessingContent - Optional content below text input for showing progress status or related information. + * @param isActionInProgress - Disables the confirm button when action is in progress. + * @param onConfirm - Callback function to be executed when the confirm button is clicked. + */ +const DeleteOrResetModal = ({ + type, + isModalOpen, + onClose, + modalHeader, + specialContent, + modalContent, + placeholder, + confirmationText, + actionProcessingContent, + isActionInProgress, + onConfirm, +}: DeleteOrResetModalProps) => { + const [confirmText, setConfirmText] = useState(''); + + // Handler for the confirmation input field + const onChangeHandler = useCallback((e: ChangeEvent) => { + setConfirmText(e.target.value); + }, []); + + // Function to validate and trigger confirmation logic + const tryConfirm = useCallback(() => { + if (type === 'simple' || confirmationText === confirmText) { + setConfirmText(''); + onConfirm(); + } + }, [type, confirmationText, confirmText, onConfirm]); + + // Function to close the modal and reset the confirmation text state. + const closeModal = useCallback(() => { + setConfirmText(''); + onClose(); + }, [onClose]); + + return ( + {modalHeader}}> + + + {specialContent} + {modalContent} + + {/* Render confirmation field for 'delete' or 'reset' types */} + {type !== 'simple' && ( + <> + + Please type {`"${confirmationText}"`} to + confirm {type === 'delete' ? 'deletion' : 'reset'}. + + + + )} + + {/* Renders the action processing content if provided */} + {actionProcessingContent} + + + {/* Action buttons */} + + + + + + {/* Disable the button if the confirmation text is not correct or the action is processing. */} + + + + + + ); +}; + +export default DeleteOrResetModal; diff --git a/src/components/Misc/styles/DeleteOrResetModal.module.css b/src/components/Misc/styles/DeleteOrResetModal.module.css new file mode 100644 index 00000000..d50dc802 --- /dev/null +++ b/src/components/Misc/styles/DeleteOrResetModal.module.css @@ -0,0 +1,37 @@ +.modalBody { + padding: 0 1rem 1rem 1rem; + width: 400px; +} + +.modalHeader { + padding: 1rem; + padding-bottom: 0.4rem; +} + +.headerText { + font-size: 0.9rem; + font-weight: 600; +} + +.warningText { + margin-top: 0.4rem; + font-size: 0.7rem; + font-weight: 500; + color: var(--mantine-color-gray-8); +} + +.confirmationText { + font-size: 0.7rem; + color: var(--mantine-color-gray-7); +} + +.confirmationTextHighlight { + font-weight: 500; +} + +.actionButtonsContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; +} diff --git a/src/hooks/useGetStreamMetadata.ts b/src/hooks/useGetStreamMetadata.ts index bb2c3da7..d2ab4cc0 100644 --- a/src/hooks/useGetStreamMetadata.ts +++ b/src/hooks/useGetStreamMetadata.ts @@ -15,42 +15,46 @@ export type MetaData = { // until dedicated endpoint been provided - fetch one by one export const useGetStreamMetadata = () => { - const [isLoading, setLoading] = useState(true); - const [error, setError] = useState(false); + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(false); const [metaData, setMetadata] = useState(null); const [userRoles] = useAppStore((store) => store.userRoles); - const getStreamMetadata = useCallback(async (streams: string[]) => { - setLoading(true); - try { - // stats - const allStatsReqs = streams.map((stream) => getLogStreamStats(stream)); - const allStatsRes = await Promise.all(allStatsReqs); + const getStreamMetadata = useCallback( + async (streams: string[]) => { + if (!userRoles) return; + setLoading(true); + try { + // stats + const allStatsReqs = streams.map((stream) => getLogStreamStats(stream)); + const allStatsRes = await Promise.all(allStatsReqs); - // retention - const streamsWithSettingsAccess = _.filter(streams, (stream) => - _.includes(getStreamsSepcificAccess(userRoles, stream), 'StreamSettings'), - ); - const allretentionReqs = streamsWithSettingsAccess.map((stream) => getLogStreamRetention(stream)); - const allretentionRes = await Promise.all(allretentionReqs); + // retention + const streamsWithSettingsAccess = _.filter(streams, (stream) => + _.includes(getStreamsSepcificAccess(userRoles, stream), 'StreamSettings'), + ); + const allretentionReqs = streamsWithSettingsAccess.map((stream) => getLogStreamRetention(stream)); + const allretentionRes = await Promise.all(allretentionReqs); - const metadata = streams.reduce((acc, stream, index) => { - return { - ...acc, - [stream]: { stats: allStatsRes[index]?.data || {}, retention: allretentionRes[index]?.data || [] }, - }; - }, {}); - setMetadata(metadata); - } catch { - setError(true); - setMetadata(null); - notifyError({ - message: 'Unable to fetch stream data', - }); - } finally { - setLoading(false); - } - }, []); + const metadata = streams.reduce((acc, stream, index) => { + return { + ...acc, + [stream]: { stats: allStatsRes[index]?.data || {}, retention: allretentionRes[index]?.data || [] }, + }; + }, {}); + setMetadata(metadata); + } catch { + setError(true); + setMetadata(null); + notifyError({ + message: 'Unable to fetch stream data', + }); + } finally { + setLoading(false); + } + }, + [userRoles], + ); return { isLoading, diff --git a/src/hooks/useQueryLogs.ts b/src/hooks/useQueryLogs.ts index a931c84b..7812f2ae 100644 --- a/src/hooks/useQueryLogs.ts +++ b/src/hooks/useQueryLogs.ts @@ -17,8 +17,16 @@ const { setLogData } = logsStoreReducers; const { parseQuery } = filterStoreReducers; const appendOffsetToQuery = (query: string, offset: number) => { - const hasOffset = query.toLowerCase().includes('offset'); - return !hasOffset ? query.replace(/offset\s+\d+/i, `OFFSET ${offset}`) : `${query}`; + const offsetRegex = /offset\s+\d+/i; + const limitRegex = /limit\s+\d+/i; + + if (offsetRegex.test(query)) { + // Replace the existing OFFSET with the new one + return query.replace(offsetRegex, `OFFSET ${offset}`); + } else { + // Insert OFFSET before LIMIT if OFFSET is not present + return query.replace(limitRegex, `OFFSET ${offset} $&`); + } }; export const useQueryLogs = () => { diff --git a/src/pages/AccessManagement/PrivilegeTR.tsx b/src/pages/AccessManagement/PrivilegeTR.tsx index d20b77c1..7934c442 100644 --- a/src/pages/AccessManagement/PrivilegeTR.tsx +++ b/src/pages/AccessManagement/PrivilegeTR.tsx @@ -19,6 +19,7 @@ import { FC, useEffect, useState } from 'react'; import { useGetLogStreamList } from '@/hooks/useGetLogStreamList'; import { useRole } from '@/hooks/useRole'; import styles from './styles/AccessManagement.module.css'; +import DeleteOrResetModal from '@/components/Misc/DeleteOrResetModal'; interface PrivilegeTRProps { roleName: string; @@ -32,8 +33,6 @@ interface PrivilegeTRProps { const PrivilegeTR: FC = (props) => { const { roleName, defaultRole, deleteRoleMutation, getRoleIsLoading, getRoleIsError } = props; - const [UserInput, setUserInput] = useState(''); - // Delete Privilege Modal Constants : Starts const [deletePrivilegeIndex, setDeletePrivilegeIndex] = useState(0); const [isDeletedPrivilegeOpen, { open: openDeletePrivilege, close: closeDeletePrivilege }] = useDisclosure(); @@ -46,7 +45,7 @@ const PrivilegeTR: FC = (props) => { // Update Role Modal Constants : Starts const [isUpdatedRoleOpen, { open: openUpdateRole, close: closeUpdateRole }] = useDisclosure(); const [selectedPrivilege, setSelectedPrivilege] = useState(''); - const [SelectedStream, setSelectedStream] = useState(''); + const [selectedStream, setSelectedStream] = useState(''); const [streamSearchValue, setStreamSearchValue] = useState(''); const [tagInput, setTagInput] = useState(''); const { getLogStreamListData } = useGetLogStreamList(); @@ -104,7 +103,6 @@ const PrivilegeTR: FC = (props) => { const handleClosePrivilegeDelete = () => { closeDeletePrivilege(); - setUserInput(''); }; const handlePrivilegeDelete = () => { @@ -140,7 +138,6 @@ const PrivilegeTR: FC = (props) => { const handleCloseDelete = () => { closeDeleteRole(); - setUserInput(''); }; const handleCloseUpdateRole = () => { @@ -158,12 +155,12 @@ const PrivilegeTR: FC = (props) => { }); } if (selectedPrivilege === 'reader' || selectedPrivilege === 'writer' || selectedPrivilege === 'ingestor') { - if (getLogStreamListData?.data?.find((stream) => stream.name === SelectedStream)) { + if (getLogStreamListData?.data?.find((stream) => stream.name === selectedStream)) { if (tagInput !== '' && tagInput !== undefined && selectedPrivilege === 'reader') { getRoleData?.data?.push({ privilege: selectedPrivilege, resource: { - stream: SelectedStream, + stream: selectedStream, tag: tagInput, }, }); @@ -171,7 +168,7 @@ const PrivilegeTR: FC = (props) => { getRoleData?.data?.push({ privilege: selectedPrivilege, resource: { - stream: SelectedStream, + stream: selectedStream, }, }); } @@ -193,7 +190,7 @@ const PrivilegeTR: FC = (props) => { getRoleData?.data?.find( (role: any) => role.privilege === selectedPrivilege && - role.resource?.stream === SelectedStream && + role.resource?.stream === selectedStream && (tagInput ? role.resource?.tag === tagInput : role.resource?.tag === null || role.resource?.tag === undefined), @@ -202,9 +199,9 @@ const PrivilegeTR: FC = (props) => { return true; } if ( - getLogStreamListData?.data?.find((stream) => stream.name === SelectedStream) && - SelectedStream !== '' && - SelectedStream !== undefined + getLogStreamListData?.data?.find((stream) => stream.name === selectedStream) && + selectedStream !== '' && + selectedStream !== undefined ) { return false; } @@ -213,15 +210,15 @@ const PrivilegeTR: FC = (props) => { if (selectedPrivilege === 'writer' || selectedPrivilege === 'ingestor') { if ( getRoleData?.data?.find( - (role: any) => role.privilege === selectedPrivilege && role.resource?.stream === SelectedStream, + (role: any) => role.privilege === selectedPrivilege && role.resource?.stream === selectedStream, ) ) { return true; } if ( - getLogStreamListData?.data?.find((stream) => stream.name === SelectedStream) && - SelectedStream !== '' && - SelectedStream !== undefined + getLogStreamListData?.data?.find((stream) => stream.name === selectedStream) && + selectedStream !== '' && + selectedStream !== undefined ) { return false; } @@ -274,7 +271,7 @@ const PrivilegeTR: FC = (props) => { - - - + {getRoleData?.data?.[deletePrivilegeIndex] ? ( - - - {getBadge(getRoleData?.data[deletePrivilegeIndex], deletePrivilegeIndex, false)} - { - setUserInput(e.target.value); - }} - placeholder={`Please enter the role to confirm, i.e. ${roleName}`} - required - /> - - - - - - - + modalHeader="Delete Privilege" + specialContent={{getBadge(getRoleData?.data[deletePrivilegeIndex], deletePrivilegeIndex, false)}} + modalContent="Are you sure you want to delete this role privilege?" + placeholder="Type name of the role to confirm." + confirmationText={roleName} + onConfirm={handlePrivilegeDelete} + /> ) : ( '' )} @@ -386,10 +333,10 @@ const PrivilegeTR: FC = (props) => { setSelectedStream(value ?? ''); }} nothingFoundMessage="No options" - value={SelectedStream} + value={selectedStream} searchValue={streamSearchValue} onSearchChange={(value) => setStreamSearchValue(value)} - onDropdownClose={() => setStreamSearchValue(SelectedStream)} + onDropdownClose={() => setStreamSearchValue(selectedStream)} onDropdownOpen={() => setStreamSearchValue('')} data={getLogStreamListData?.data?.map((stream) => ({ value: stream.name, label: stream.name })) ?? []} searchable diff --git a/src/pages/AccessManagement/RoleTR.tsx b/src/pages/AccessManagement/RoleTR.tsx index 75161d92..b1b19fb7 100644 --- a/src/pages/AccessManagement/RoleTR.tsx +++ b/src/pages/AccessManagement/RoleTR.tsx @@ -1,18 +1,4 @@ -import { - ActionIcon, - Badge, - Box, - Button, - Group, - Modal, - Select, - Stack, - Text, - TextInput, - Tooltip, - px, - rem, -} from '@mantine/core'; +import { ActionIcon, Badge, Box, Button, Group, Modal, Select, Stack, Text, Tooltip, px, rem } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconPlus, IconTransform, IconTrash, IconX } from '@tabler/icons-react'; import { FC, useEffect, useState } from 'react'; @@ -21,6 +7,7 @@ import { useUser } from '@/hooks/useUser'; import { useRole } from '@/hooks/useRole'; import styles from './styles/AccessManagement.module.css'; import { CodeHighlight } from '@mantine/code-highlight'; +import DeleteOrResetModal from '@/components/Misc/DeleteOrResetModal'; interface RoleTRProps { user: { @@ -56,8 +43,7 @@ const RoleTR: FC = (props) => { const [deleteRole, setDeleteRole] = useState(null); const [opened, { open, close }] = useDisclosure(false); - const [UserInput, setUserInput] = useState(''); - const [SelectedRole, setSelectedRole] = useState(''); + const [selectedRole, setSelectedRole] = useState(''); const [roleSearchValue, setRoleSearchValue] = useState(''); const { getUserRolesData, getUserRolesMutation, updateUserMutation, updateUserIsSuccess } = useUser(); @@ -114,18 +100,16 @@ const RoleTR: FC = (props) => { // For Delete User const handleCloseDelete = () => { closeDelete(); - setUserInput(''); }; const handleDelete = () => { deleteUserMutation({ userName: user.id, onSuccess: props.getUserRefetch }); closeDelete(); - setUserInput(''); }; // For Delete Role const handleRoleDelete = () => { - let filtered = Object.keys(getUserRolesData?.data).filter((role) => role !== deleteRole); + const filtered = Object.keys(getUserRolesData?.data).filter((role) => role !== deleteRole); updateUserMutation({ userName: user.id, roles: filtered }); closeDeleteRole(); setDeleteRole(null); @@ -133,7 +117,6 @@ const RoleTR: FC = (props) => { }; const handleCloseRoleDelete = () => { closeDeleteRole(); - setUserInput(''); }; // For Edit Role @@ -144,11 +127,11 @@ const RoleTR: FC = (props) => { }; const handleEditUserRole = () => { - let userRoleArray: any = Object.keys(getUserRolesData?.data); - if (userRoleArray.includes(SelectedRole) || SelectedRole === '') { + const userRoleArray = Object.keys(getUserRolesData?.data); + if (userRoleArray.includes(selectedRole) || selectedRole === '') { return; } - userRoleArray.push(SelectedRole); + userRoleArray.push(selectedRole); updateUserMutation({ userName: user.id, roles: userRoleArray }); handleCloseRoleEdit(); @@ -157,11 +140,10 @@ const RoleTR: FC = (props) => { // for reset password const handleCloseResetPassword = () => { close(); - setUserInput(''); }; - const handleResetPassword = () => { - updateUserPasswordMutation({ userName: UserInput }); + const handleResetPassword = (userName: string) => { + updateUserPasswordMutation({ userName }); }; const classes = styles; @@ -225,106 +207,52 @@ const RoleTR: FC = (props) => { - - { - setUserInput(e.target.value); - }} - placeholder={`Please enter the user to confirm, i.e. ${user.id}`} - required - /> - - - - - - + modalHeader="Delete user" + modalContent="Are you sure you want to delete this user?" + placeholder="Type the name of the user" + confirmationText={user.id} + onConfirm={handleDelete} + /> {getUserRolesData?.data && deleteRole && getUserRolesData?.data[deleteRole] ? ( - - - {getBadge(deleteRole, false)} - { - setUserInput(e.target.value); - }} - placeholder={`Please enter the user to confirm, i.e. ${user.id}`} - required - /> - - - - - - - + modalHeader="Delete user role" + specialContent={{getBadge(deleteRole, false)}} + modalContent="Are you sure you want to delete this user role?" + placeholder="Type the name of the user" + confirmationText={user.id} + onConfirm={handleRoleDelete} + /> ) : ( '' )} - - - { - setUserInput(e.target.value); - }} - required - mb={4} - /> - - {updateUserPasswordIsError ? ( - + modalHeader="Change user password" + modalContent="Are you sure you want to reset this user's password?" + placeholder="Type the name of the user" + confirmationText={user.id} + onConfirm={() => handleResetPassword(user.id)} + actionProcessingContent={ + updateUserPasswordIsError ? ( + {resetPasswordError} ) : updateUserPasswordIsLoading ? ( - loading + + loading... + ) : udpateUserPasswordData?.data ? ( - + Password = (props) => { code={udpateUserPasswordData?.data} copiedLabel="Password copied to clipboard" /> - + Warning this is the only time you are able to see Password ) : ( '' - )} - - - {user.method === 'native' ? ( - - ) : ( - Cannot reset password for this user - )} - - - + ) + } + isActionInProgress={updateUserPasswordIsLoading} + /> = (props) => { setSelectedRole(value ?? ''); }} nothingFoundMessage="No roles found" - value={SelectedRole} + value={selectedRole} searchValue={roleSearchValue} onSearchChange={(value) => setRoleSearchValue(value)} - onDropdownClose={() => setRoleSearchValue(SelectedRole)} + onDropdownClose={() => setRoleSearchValue(selectedRole)} onDropdownOpen={() => setRoleSearchValue('')} data={getRolesData?.data} searchable @@ -393,7 +305,7 @@ const RoleTR: FC = (props) => { // if role is already assigned or no role is selected then disable the button disabled={ getUserRolesData?.data && - (Object.keys(getUserRolesData?.data).includes(SelectedRole) || SelectedRole === '') + (Object.keys(getUserRolesData?.data).includes(selectedRole) || selectedRole === '') ? true : false }> diff --git a/src/pages/AccessManagement/Roles.tsx b/src/pages/AccessManagement/Roles.tsx index edc88d2b..127c2b67 100644 --- a/src/pages/AccessManagement/Roles.tsx +++ b/src/pages/AccessManagement/Roles.tsx @@ -10,7 +10,7 @@ import IconButton from '@/components/Button/IconButton'; import { useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; const navigateToDocs = () => { - return window.open('https://www.parseable.io/docs/rbac', '_blank'); + return window.open('https://www.parseable.com/docs/server/api/rbac', '_blank'); }; const renderDocsIcon = () => ; @@ -167,10 +167,13 @@ const Roles: FC = () => { return ( - Roles + + Roles + )} - + @@ -207,6 +210,7 @@ const Roles: FC = () => { onClose={handleDefaultRoleModalClose} title="Set default oidc role" centered + styles={{ title: { fontWeight: 500 } }} className={classes.modalStyle}>