mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: display sign on log (#3193)
## 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.  <!-- (For internal contributors): Does it relate to an issue on public roadmap? --> Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: #2951
This commit is contained in:
		
							parent
							
								
									e7ef06ff9d
								
							
						
					
					
						commit
						a43542b0d1
					
				| @ -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<ITimeAgoCellProps> = ({ | ||||
| @ -17,12 +18,15 @@ export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({ | ||||
|     live = false, | ||||
|     emptyText, | ||||
|     title, | ||||
|     timestamp, | ||||
| }) => { | ||||
|     const { locationSettings } = useLocationSettings(); | ||||
| 
 | ||||
|     if (!value) return <TextCell>{emptyText}</TextCell>; | ||||
| 
 | ||||
|     const date = formatDateYMD(value, locationSettings.locale); | ||||
|     const date = timestamp | ||||
|         ? formatDateYMDHMS(value, locationSettings.locale) | ||||
|         : formatDateYMD(value, locationSettings.locale); | ||||
| 
 | ||||
|     return ( | ||||
|         <TextCell> | ||||
|  | ||||
| @ -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": {}, | ||||
|  | ||||
| @ -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', | ||||
|  | ||||
							
								
								
									
										20
									
								
								frontend/src/component/signOnLog/SignOnLog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/src/component/signOnLog/SignOnLog.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -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 ( | ||||
|         <div> | ||||
|             <ConditionallyRender | ||||
|                 condition={hasAccess(ADMIN)} | ||||
|                 show={<SignOnLogTable />} | ||||
|                 elseShow={<AdminAlert />} | ||||
|             /> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @ -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 ( | ||||
|         <StyledBox> | ||||
|             <PermissionIconButton | ||||
|                 data-loading | ||||
|                 onClick={onDelete} | ||||
|                 permission={ADMIN} | ||||
|                 tooltipProps={{ | ||||
|                     title: 'Remove event', | ||||
|                 }} | ||||
|             > | ||||
|                 <Delete /> | ||||
|             </PermissionIconButton> | ||||
|         </StyledBox> | ||||
|     ); | ||||
| }; | ||||
| @ -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<React.SetStateAction<boolean>>; | ||||
|     onConfirm: (event: ISignOnEvent) => void; | ||||
| } | ||||
| 
 | ||||
| export const SignOnLogDeleteDialog = ({ | ||||
|     event, | ||||
|     open, | ||||
|     setOpen, | ||||
|     onConfirm, | ||||
| }: IServiceAccountDeleteDialogProps) => ( | ||||
|     <Dialogue | ||||
|         title="Delete event?" | ||||
|         open={open} | ||||
|         primaryButtonText="Delete event" | ||||
|         secondaryButtonText="Cancel" | ||||
|         onClick={() => onConfirm(event!)} | ||||
|         onClose={() => { | ||||
|             setOpen(false); | ||||
|         }} | ||||
|     > | ||||
|         You are about to delete event: <strong>#{event?.id}</strong> | ||||
|     </Dialogue> | ||||
| ); | ||||
| @ -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<ISignOnLogSuccessfulCellProps> = ({ | ||||
|     row, | ||||
|     value, | ||||
| }) => { | ||||
|     const { searchQuery } = useSearchHighlightContext(); | ||||
| 
 | ||||
|     if (value) | ||||
|         return ( | ||||
|             <StyledBox> | ||||
|                 <Badge color="success">True</Badge> | ||||
|             </StyledBox> | ||||
|         ); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledBox> | ||||
|             <HtmlTooltip | ||||
|                 arrow | ||||
|                 title={ | ||||
|                     <Highlighter search={searchQuery}> | ||||
|                         {row.original.failure_reason} | ||||
|                     </Highlighter> | ||||
|                 } | ||||
|             > | ||||
|                 <Badge color="error">False</Badge> | ||||
|             </HtmlTooltip> | ||||
|         </StyledBox> | ||||
|     ); | ||||
| }; | ||||
| @ -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<ISignOnEvent>(); | ||||
| 
 | ||||
|     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 }) => ( | ||||
|                     <TimeAgoCell value={value} timestamp /> | ||||
|                 ), | ||||
|                 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) => ( | ||||
|                     <SignOnLogActionsCell | ||||
|                         onDelete={() => { | ||||
|                             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 ( | ||||
|         <PageContent | ||||
|             isLoading={loading} | ||||
|             header={ | ||||
|                 <PageHeader | ||||
|                     title={`Sign-on log (${rows.length})`} | ||||
|                     actions={ | ||||
|                         <ConditionallyRender | ||||
|                             condition={!isSmallScreen} | ||||
|                             show={ | ||||
|                                 <Search | ||||
|                                     initialValue={searchValue} | ||||
|                                     onChange={setSearchValue} | ||||
|                                     hasFilters | ||||
|                                     getSearchContext={getSearchContext} | ||||
|                                 /> | ||||
|                             } | ||||
|                         /> | ||||
|                     } | ||||
|                 > | ||||
|                     <ConditionallyRender | ||||
|                         condition={isSmallScreen} | ||||
|                         show={ | ||||
|                             <Search | ||||
|                                 initialValue={searchValue} | ||||
|                                 onChange={setSearchValue} | ||||
|                                 hasFilters | ||||
|                                 getSearchContext={getSearchContext} | ||||
|                             /> | ||||
|                         } | ||||
|                     /> | ||||
|                 </PageHeader> | ||||
|             } | ||||
|         > | ||||
|             <SearchHighlightProvider value={getSearchText(searchValue)}> | ||||
|                 <VirtualizedTable | ||||
|                     rows={rows} | ||||
|                     headerGroups={headerGroups} | ||||
|                     prepareRow={prepareRow} | ||||
|                 /> | ||||
|             </SearchHighlightProvider> | ||||
|             <ConditionallyRender | ||||
|                 condition={rows.length === 0} | ||||
|                 show={ | ||||
|                     <ConditionallyRender | ||||
|                         condition={searchValue?.length > 0} | ||||
|                         show={ | ||||
|                             <TablePlaceholder> | ||||
|                                 No sign-on events found matching “ | ||||
|                                 {searchValue} | ||||
|                                 ” | ||||
|                             </TablePlaceholder> | ||||
|                         } | ||||
|                         elseShow={ | ||||
|                             <TablePlaceholder> | ||||
|                                 No sign-on events available. | ||||
|                             </TablePlaceholder> | ||||
|                         } | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|             <SignOnLogDeleteDialog | ||||
|                 event={selectedEvent} | ||||
|                 open={deleteOpen} | ||||
|                 setOpen={setDeleteOpen} | ||||
|                 onConfirm={onDeleteConfirm} | ||||
|             /> | ||||
|         </PageContent> | ||||
|     ); | ||||
| }; | ||||
| @ -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, | ||||
|     }; | ||||
| }; | ||||
							
								
								
									
										35
									
								
								frontend/src/hooks/api/getters/useSignOnLog/useSignOnLog.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								frontend/src/hooks/api/getters/useSignOnLog/useSignOnLog.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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()); | ||||
| }; | ||||
							
								
								
									
										9
									
								
								frontend/src/interfaces/signOnEvent.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/interfaces/signOnEvent.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| export interface ISignOnEvent { | ||||
|     id: number; | ||||
|     username: string; | ||||
|     auth_type: string; | ||||
|     created_at: Date; | ||||
|     successful: boolean; | ||||
|     ip?: string; | ||||
|     failure_reason?: string; | ||||
| } | ||||
| @ -48,6 +48,7 @@ export interface IFlags { | ||||
|     showProjectApiAccess?: boolean; | ||||
|     proPlanAutoCharge?: boolean; | ||||
|     notifications?: boolean; | ||||
|     loginEventLog?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface IVersionInfo { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user