From a43542b0d11a5635595d302f76b0050940939fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 27 Feb 2023 08:07:22 +0000 Subject: [PATCH] feat: display sign on log (#3193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## About the changes https://linear.app/unleash/issue/2-719/display-basic-sign-on-log-in-frontend Adds a basic, naïve way of displaying sign-on history. We can be OK with merging this for now if we want, given that it's behind a feature flag, and then iterate over it and implement the features and improvements we need in the meantime. ![image](https://user-images.githubusercontent.com/14320932/221217564-90868ce8-6608-4f30-b2e4-88f72f1e4ac6.png) Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: #2951 --- .../Table/cells/TimeAgoCell/TimeAgoCell.tsx | 8 +- .../__snapshots__/routes.test.tsx.snap | 9 + frontend/src/component/menu/routes.ts | 15 ++ .../src/component/signOnLog/SignOnLog.tsx | 20 ++ .../SignOnLogActionsCell.tsx | 32 +++ .../SignOnLogDeleteDialog.tsx | 29 +++ .../SignOnLogSuccessfulCell.tsx | 48 ++++ .../SignOnLogTable/SignOnLogTable.tsx | 234 ++++++++++++++++++ .../useSignOnLogApi/useSignOnLogApi.ts | 24 ++ .../api/getters/useSignOnLog/useSignOnLog.ts | 35 +++ frontend/src/interfaces/signOnEvent.ts | 9 + frontend/src/interfaces/uiConfig.ts | 1 + 12 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 frontend/src/component/signOnLog/SignOnLog.tsx create mode 100644 frontend/src/component/signOnLog/SignOnLogTable/SignOnLogActionsCell/SignOnLogActionsCell.tsx create mode 100644 frontend/src/component/signOnLog/SignOnLogTable/SignOnLogDeleteDialog/SignOnLogDeleteDialog.tsx create mode 100644 frontend/src/component/signOnLog/SignOnLogTable/SignOnLogSuccessfulCell/SignOnLogSuccessfulCell.tsx create mode 100644 frontend/src/component/signOnLog/SignOnLogTable/SignOnLogTable.tsx create mode 100644 frontend/src/hooks/api/actions/useSignOnLogApi/useSignOnLogApi.ts create mode 100644 frontend/src/hooks/api/getters/useSignOnLog/useSignOnLog.ts create mode 100644 frontend/src/interfaces/signOnEvent.ts diff --git a/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx index 45c1b6d8d4..5e339dba2c 100644 --- a/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx +++ b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx @@ -1,7 +1,7 @@ import { Tooltip, Typography } from '@mui/material'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { VFC } from 'react'; -import { formatDateYMD } from 'utils/formatDate'; +import { formatDateYMD, formatDateYMDHMS } from 'utils/formatDate'; import { TextCell } from '../TextCell/TextCell'; import TimeAgo from 'react-timeago'; @@ -10,6 +10,7 @@ interface ITimeAgoCellProps { live?: boolean; emptyText?: string; title?: (date: string) => string; + timestamp?: boolean; } export const TimeAgoCell: VFC = ({ @@ -17,12 +18,15 @@ export const TimeAgoCell: VFC = ({ live = false, emptyText, title, + timestamp, }) => { const { locationSettings } = useLocationSettings(); if (!value) return {emptyText}; - const date = formatDateYMD(value, locationSettings.locale); + const date = timestamp + ? formatDateYMDHMS(value, locationSettings.locale) + : formatDateYMD(value, locationSettings.locale); return ( diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 5bb2a14ee6..02a8e80d61 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -347,6 +347,15 @@ exports[`returns all baseRoutes 1`] = ` "title": "Event log", "type": "protected", }, + { + "component": [Function], + "menu": { + "adminSettings": true, + }, + "path": "/admin/sign-on-log", + "title": "Sign on log", + "type": "protected", + }, { "component": [Function], "menu": {}, diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 0a1e0facd2..054879fdb6 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -43,6 +43,7 @@ import { LazyFeatureView } from 'component/feature/FeatureView/LazyFeatureView'; import { LazyAdmin } from 'component/admin/LazyAdmin'; import { LazyProject } from 'component/project/Project/LazyProject'; import { AdminRedirect } from 'component/admin/AdminRedirect'; +import { SignOnLog } from 'component/signOnLog/SignOnLog'; export const routes: IRoute[] = [ // Splash @@ -355,6 +356,14 @@ export const routes: IRoute[] = [ menu: { adminSettings: true }, }, + { + path: '/admin/sign-on-log', + title: 'Sign on log', + component: SignOnLog, + type: 'protected', + menu: { adminSettings: true }, + }, + // Archive { path: '/archive', @@ -438,6 +447,12 @@ export const adminMenuRoutes: INavigationMenuItem[] = [ title: 'Event log', menu: { adminSettings: true }, }, + { + path: '/admin/sign-on-log', + title: 'Sign-on log', + menu: { adminSettings: true }, + flag: 'loginEventLog', + }, { path: '/admin/users', title: 'Users', diff --git a/frontend/src/component/signOnLog/SignOnLog.tsx b/frontend/src/component/signOnLog/SignOnLog.tsx new file mode 100644 index 0000000000..98b3080e15 --- /dev/null +++ b/frontend/src/component/signOnLog/SignOnLog.tsx @@ -0,0 +1,20 @@ +import { useContext } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; +import { SignOnLogTable } from './SignOnLogTable/SignOnLogTable'; + +export const SignOnLog = () => { + const { hasAccess } = useContext(AccessContext); + + return ( +
+ } + elseShow={} + /> +
+ ); +}; diff --git a/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogActionsCell/SignOnLogActionsCell.tsx b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogActionsCell/SignOnLogActionsCell.tsx new file mode 100644 index 0000000000..9d395670a7 --- /dev/null +++ b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogActionsCell/SignOnLogActionsCell.tsx @@ -0,0 +1,32 @@ +import { Delete } from '@mui/icons-material'; +import { Box, styled } from '@mui/material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +interface ISignOnLogActionsCellProps { + onDelete: (event: React.SyntheticEvent) => void; +} + +export const SignOnLogActionsCell = ({ + onDelete, +}: ISignOnLogActionsCellProps) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogDeleteDialog/SignOnLogDeleteDialog.tsx b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogDeleteDialog/SignOnLogDeleteDialog.tsx new file mode 100644 index 0000000000..b0e30da0aa --- /dev/null +++ b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogDeleteDialog/SignOnLogDeleteDialog.tsx @@ -0,0 +1,29 @@ +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { ISignOnEvent } from 'interfaces/signOnEvent'; + +interface IServiceAccountDeleteDialogProps { + event?: ISignOnEvent; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: (event: ISignOnEvent) => void; +} + +export const SignOnLogDeleteDialog = ({ + event, + open, + setOpen, + onConfirm, +}: IServiceAccountDeleteDialogProps) => ( + onConfirm(event!)} + onClose={() => { + setOpen(false); + }} + > + You are about to delete event: #{event?.id} + +); diff --git a/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogSuccessfulCell/SignOnLogSuccessfulCell.tsx b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogSuccessfulCell/SignOnLogSuccessfulCell.tsx new file mode 100644 index 0000000000..9b20b6ba5d --- /dev/null +++ b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogSuccessfulCell/SignOnLogSuccessfulCell.tsx @@ -0,0 +1,48 @@ +import { VFC } from 'react'; +import { Box, styled } from '@mui/material'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { ISignOnEvent } from 'interfaces/signOnEvent'; +import { Badge } from 'component/common/Badge/Badge'; +import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +interface ISignOnLogSuccessfulCellProps { + row: { + original: ISignOnEvent; + }; + value: boolean; +} + +export const SignOnLogSuccessfulCell: VFC = ({ + row, + value, +}) => { + const { searchQuery } = useSearchHighlightContext(); + + if (value) + return ( + + True + + ); + + return ( + + + {row.original.failure_reason} + + } + > + False + + + ); +}; diff --git a/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogTable.tsx b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogTable.tsx new file mode 100644 index 0000000000..9d13685334 --- /dev/null +++ b/frontend/src/component/signOnLog/SignOnLogTable/SignOnLogTable.tsx @@ -0,0 +1,234 @@ +import { useMemo, useState } from 'react'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { useMediaQuery } from '@mui/material'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import theme from 'themes/theme'; +import { Search } from 'component/common/Search/Search'; +import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; +import { useSearch } from 'hooks/useSearch'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { useSignOnLog } from 'hooks/api/getters/useSignOnLog/useSignOnLog'; +import { SignOnLogSuccessfulCell } from './SignOnLogSuccessfulCell/SignOnLogSuccessfulCell'; +import { ISignOnEvent } from 'interfaces/signOnEvent'; +import { SignOnLogActionsCell } from './SignOnLogActionsCell/SignOnLogActionsCell'; +import { SignOnLogDeleteDialog } from './SignOnLogDeleteDialog/SignOnLogDeleteDialog'; +import { useSignOnLogApi } from 'hooks/api/actions/useSignOnLogApi/useSignOnLogApi'; + +export const SignOnLogTable = () => { + const { setToastData, setToastApiError } = useToast(); + + const { events, loading, refetch } = useSignOnLog(); + const { removeEvent } = useSignOnLogApi(); + + const [searchValue, setSearchValue] = useState(''); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(); + + const onDeleteConfirm = async (event: ISignOnEvent) => { + try { + await removeEvent(event.id); + setToastData({ + title: `Event has been deleted`, + type: 'success', + }); + refetch(); + setDeleteOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const columns = useMemo( + () => [ + { + Header: 'Created', + accessor: 'created_at', + Cell: ({ value }: { value: Date }) => ( + + ), + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Username', + accessor: 'username', + minWidth: 100, + Cell: HighlightCell, + searchable: true, + }, + { + Header: 'Authentication', + accessor: (event: ISignOnEvent) => + event.auth_type === 'simple' + ? 'Password' + : event.auth_type.toUpperCase(), + width: 150, + maxWidth: 150, + Cell: HighlightCell, + searchable: true, + }, + { + Header: 'IP address', + accessor: 'ip', + Cell: HighlightCell, + searchable: true, + }, + { + Header: 'Successful', + accessor: 'successful', + align: 'center', + Cell: SignOnLogSuccessfulCell, + filterName: 'successful', + filterParsing: (value: boolean) => value.toString(), + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ row: { original: event } }: any) => ( + { + setSelectedEvent(event); + setDeleteOpen(true); + }} + /> + ), + width: 150, + disableSortBy: true, + }, + // Always hidden -- for search + { + accessor: 'failure_reason', + Header: 'Failure Reason', + searchable: true, + }, + ], + [] + ); + + const [initialState] = useState({ + sortBy: [{ id: 'created_at' }], + hiddenColumns: ['failure_reason'], + }); + + const { data, getSearchText, getSearchContext } = useSearch( + columns, + searchValue, + events + ); + + const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable( + { + columns: columns as any, + data, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useSortBy, + useFlexLayout + ); + + useConditionallyHiddenColumns( + [ + { + condition: isExtraSmallScreen, + columns: ['role', 'seenAt'], + }, + { + condition: isSmallScreen, + columns: ['imageUrl', 'tokens', 'createdAt'], + }, + ], + setHiddenColumns, + columns + ); + + return ( + + } + /> + } + > + + } + /> + + } + > + + + + 0} + show={ + + No sign-on events found matching “ + {searchValue} + ” + + } + elseShow={ + + No sign-on events available. + + } + /> + } + /> + + + ); +}; diff --git a/frontend/src/hooks/api/actions/useSignOnLogApi/useSignOnLogApi.ts b/frontend/src/hooks/api/actions/useSignOnLogApi/useSignOnLogApi.ts new file mode 100644 index 0000000000..ac7f7d1185 --- /dev/null +++ b/frontend/src/hooks/api/actions/useSignOnLogApi/useSignOnLogApi.ts @@ -0,0 +1,24 @@ +import useAPI from '../useApi/useApi'; + +export const useSignOnLogApi = () => { + const { loading, makeRequest, createRequest, errors } = useAPI({ + propagateErrors: true, + }); + + const removeEvent = async (eventId: number) => { + const requestId = 'removeEvent'; + const req = createRequest( + `api/admin/login-event/${eventId}`, + { method: 'DELETE' }, + requestId + ); + + await makeRequest(req.caller, req.id); + }; + + return { + removeEvent, + errors, + loading, + }; +}; diff --git a/frontend/src/hooks/api/getters/useSignOnLog/useSignOnLog.ts b/frontend/src/hooks/api/getters/useSignOnLog/useSignOnLog.ts new file mode 100644 index 0000000000..de63833eb7 --- /dev/null +++ b/frontend/src/hooks/api/getters/useSignOnLog/useSignOnLog.ts @@ -0,0 +1,35 @@ +import { ISignOnEvent } from 'interfaces/signOnEvent'; +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import useUiConfig from '../useUiConfig/useUiConfig'; + +export const useSignOnLog = () => { + const { uiConfig, isEnterprise } = useUiConfig(); + + const { loginEventLog } = uiConfig.flags; + + const { data, error, mutate } = useConditionalSWR( + loginEventLog && isEnterprise(), + [], + formatApiPath(`api/admin/login-event`), + fetcher + ); + + return useMemo( + () => ({ + events: (data ?? []) as ISignOnEvent[], + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate] + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Sign-On Log')) + .then(res => res.json()); +}; diff --git a/frontend/src/interfaces/signOnEvent.ts b/frontend/src/interfaces/signOnEvent.ts new file mode 100644 index 0000000000..285709ac5c --- /dev/null +++ b/frontend/src/interfaces/signOnEvent.ts @@ -0,0 +1,9 @@ +export interface ISignOnEvent { + id: number; + username: string; + auth_type: string; + created_at: Date; + successful: boolean; + ip?: string; + failure_reason?: string; +} diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index cb14da8782..f9d3247348 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -48,6 +48,7 @@ export interface IFlags { showProjectApiAccess?: boolean; proPlanAutoCharge?: boolean; notifications?: boolean; + loginEventLog?: boolean; } export interface IVersionInfo {