mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: List and delete inactive users
Adds a new Inactive Users list component to admin/users for easier cleanup of users that are counted as inactive: No sign of activity (logins or api token usage) in the last 180 days. --------- Co-authored-by: David Leek <david@getunleash.io>
This commit is contained in:
		
							parent
							
								
									2d7464f517
								
							
						
					
					
						commit
						ea38877b0c
					
				| @ -0,0 +1,61 @@ | ||||
| import { Dialogue } from '../../../../common/Dialogue/Dialogue'; | ||||
| import useLoading from '../../../../../hooks/useLoading'; | ||||
| import { Alert, Typography } from '@mui/material'; | ||||
| import { DEL_INACTIVE_USERS_ERROR } from '../../../../../hooks/api/actions/useInactiveUsersApi/useInactiveUsersApi'; | ||||
| import { ConditionallyRender } from '../../../../common/ConditionallyRender/ConditionallyRender'; | ||||
| import { IInactiveUser } from '../../../../../hooks/api/getters/useInactiveUsers/useInactiveUsers'; | ||||
| import { flexRow } from '../../../../../themes/themeStyles'; | ||||
| 
 | ||||
| interface IDeleteInactiveUsersProps { | ||||
|     showDialog: boolean; | ||||
|     closeDialog: () => void; | ||||
|     inactiveUsersLoading: boolean; | ||||
|     removeInactiveUsers: () => void; | ||||
|     inactiveUserApiErrors: Record<string, string>; | ||||
|     inactiveUsers: IInactiveUser[]; | ||||
| } | ||||
| export const DeleteInactiveUsers = ({ | ||||
|     showDialog, | ||||
|     closeDialog, | ||||
|     inactiveUsersLoading, | ||||
|     removeInactiveUsers, | ||||
|     inactiveUserApiErrors, | ||||
|     inactiveUsers, | ||||
| }: IDeleteInactiveUsersProps) => { | ||||
|     const ref = useLoading(inactiveUsersLoading); | ||||
|     return ( | ||||
|         <Dialogue | ||||
|             open={showDialog} | ||||
|             title={`Really delete all inactive users?`} | ||||
|             onClose={closeDialog} | ||||
|             onClick={removeInactiveUsers} | ||||
|             primaryButtonText={'Delete all inactive users'} | ||||
|             secondaryButtonText={'Cancel'} | ||||
|         > | ||||
|             <div ref={ref}> | ||||
|                 <ConditionallyRender | ||||
|                     condition={Boolean( | ||||
|                         inactiveUserApiErrors[DEL_INACTIVE_USERS_ERROR], | ||||
|                     )} | ||||
|                     show={ | ||||
|                         <Alert | ||||
|                             data-loading | ||||
|                             severity='error' | ||||
|                             style={{ margin: '1rem 0' }} | ||||
|                         > | ||||
|                             {inactiveUserApiErrors[DEL_INACTIVE_USERS_ERROR]} | ||||
|                         </Alert> | ||||
|                     } | ||||
|                 /> | ||||
|                 <div style={flexRow}> | ||||
|                     <Typography variant='subtitle1'> | ||||
|                         You will be deleting{' '} | ||||
|                         {inactiveUsers.length === 1 | ||||
|                             ? `1 inactive user` | ||||
|                             : `${inactiveUsers.length} inactive users`} | ||||
|                     </Typography> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </Dialogue> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,80 @@ | ||||
| import { Dialogue } from 'component/common/Dialogue/Dialogue'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { REMOVE_USER_ERROR } from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; | ||||
| import { Alert, styled } from '@mui/material'; | ||||
| import useLoading from 'hooks/useLoading'; | ||||
| import { Typography } from '@mui/material'; | ||||
| import { useThemeStyles } from 'themes/themeStyles'; | ||||
| import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; | ||||
| import { IInactiveUser } from '../../../../../hooks/api/getters/useInactiveUsers/useInactiveUsers'; | ||||
| 
 | ||||
| const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ | ||||
|     width: theme.spacing(5), | ||||
|     height: theme.spacing(5), | ||||
|     margin: 0, | ||||
| })); | ||||
| 
 | ||||
| interface IDeleteUserProps { | ||||
|     showDialog: boolean; | ||||
|     closeDialog: () => void; | ||||
|     user: IInactiveUser; | ||||
|     userLoading: boolean; | ||||
|     removeUser: () => void; | ||||
|     userApiErrors: Record<string, string>; | ||||
| } | ||||
| 
 | ||||
| const DeleteUser = ({ | ||||
|     showDialog, | ||||
|     closeDialog, | ||||
|     user, | ||||
|     userLoading, | ||||
|     removeUser, | ||||
|     userApiErrors, | ||||
| }: IDeleteUserProps) => { | ||||
|     const ref = useLoading(userLoading); | ||||
|     const { classes: themeStyles } = useThemeStyles(); | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialogue | ||||
|             open={showDialog} | ||||
|             title='Really delete user?' | ||||
|             onClose={closeDialog} | ||||
|             onClick={removeUser} | ||||
|             primaryButtonText='Delete user' | ||||
|             secondaryButtonText='Cancel' | ||||
|         > | ||||
|             <div ref={ref}> | ||||
|                 <ConditionallyRender | ||||
|                     condition={Boolean(userApiErrors[REMOVE_USER_ERROR])} | ||||
|                     show={ | ||||
|                         <Alert | ||||
|                             data-loading | ||||
|                             severity='error' | ||||
|                             style={{ margin: '1rem 0' }} | ||||
|                         > | ||||
|                             {userApiErrors[REMOVE_USER_ERROR]} | ||||
|                         </Alert> | ||||
|                     } | ||||
|                 /> | ||||
|                 <div data-loading className={themeStyles.flexRow}> | ||||
|                     <Typography | ||||
|                         variant='subtitle1' | ||||
|                         style={{ marginLeft: '1rem' }} | ||||
|                     > | ||||
|                         {user.username || user.email} | ||||
|                     </Typography> | ||||
|                 </div> | ||||
|                 <Typography | ||||
|                     data-loading | ||||
|                     variant='body1' | ||||
|                     style={{ marginTop: '1rem' }} | ||||
|                 > | ||||
|                     Are you sure you want to delete{' '} | ||||
|                     {`${user.name || 'user'} (${user.email || user.username})`} | ||||
|                 </Typography> | ||||
|             </div> | ||||
|         </Dialogue> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default DeleteUser; | ||||
| @ -0,0 +1,32 @@ | ||||
| import React, { VFC } from 'react'; | ||||
| import { Box, styled } from '@mui/material'; | ||||
| import PermissionIconButton from '../../../../common/PermissionIconButton/PermissionIconButton'; | ||||
| import { ADMIN } from '../../../../providers/AccessProvider/permissions'; | ||||
| import { Delete } from '@mui/icons-material'; | ||||
| 
 | ||||
| const StyledBox = styled(Box)(() => ({ | ||||
|     display: 'flex', | ||||
|     justifyContent: 'center', | ||||
| })); | ||||
| interface IInactiveUsersActionsCellProps { | ||||
|     onDelete: (event: React.SyntheticEvent) => void; | ||||
| } | ||||
| 
 | ||||
| export const InactiveUsersActionCell: VFC<IInactiveUsersActionsCellProps> = ({ | ||||
|     onDelete, | ||||
| }) => { | ||||
|     return ( | ||||
|         <StyledBox> | ||||
|             <PermissionIconButton | ||||
|                 data-loading | ||||
|                 onClick={onDelete} | ||||
|                 permission={ADMIN} | ||||
|                 tooltipProps={{ | ||||
|                     title: 'Remove user', | ||||
|                 }} | ||||
|             > | ||||
|                 <Delete /> | ||||
|             </PermissionIconButton> | ||||
|         </StyledBox> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,266 @@ | ||||
| import { | ||||
|     IInactiveUser, | ||||
|     useInactiveUsers, | ||||
| } from 'hooks/api/getters/useInactiveUsers/useInactiveUsers'; | ||||
| import { useUsers } from '../../../../hooks/api/getters/useUsers/useUsers'; | ||||
| import useAdminUsersApi from '../../../../hooks/api/actions/useAdminUsersApi/useAdminUsersApi'; | ||||
| import { useInactiveUsersApi } from '../../../../hooks/api/actions/useInactiveUsersApi/useInactiveUsersApi'; | ||||
| import useToast from '../../../../hooks/useToast'; | ||||
| import { formatUnknownError } from '../../../../utils/formatUnknownError'; | ||||
| import { IUser } from '../../../../interfaces/user'; | ||||
| import React, { useMemo, useState } from 'react'; | ||||
| import { TimeAgoCell } from '../../../common/Table/cells/TimeAgoCell/TimeAgoCell'; | ||||
| import { IRole } from '../../../../interfaces/role'; | ||||
| import { RoleCell } from '../../../common/Table/cells/RoleCell/RoleCell'; | ||||
| import { HighlightCell } from '../../../common/Table/cells/HighlightCell/HighlightCell'; | ||||
| import { PageContent } from '../../../common/PageContent/PageContent'; | ||||
| import { PageHeader } from '../../../common/PageHeader/PageHeader'; | ||||
| import { Button } from '@mui/material'; | ||||
| import { useFlexLayout, useSortBy, useTable } from 'react-table'; | ||||
| import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender'; | ||||
| import { TablePlaceholder, VirtualizedTable } from '../../../common/Table'; | ||||
| 
 | ||||
| import { DateCell } from '../../../common/Table/cells/DateCell/DateCell'; | ||||
| import { InactiveUsersActionCell } from './InactiveUsersActionCell/InactiveUsersActionCell'; | ||||
| import { TextCell } from '../../../common/Table/cells/TextCell/TextCell'; | ||||
| import DeleteUser from './DeleteUser/DeleteUser'; | ||||
| import { DeleteInactiveUsers } from './DeleteInactiveUsers/DeleteInactiveUsers'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { StyledUsersLinkDiv } from '../Users.styles'; | ||||
| 
 | ||||
| export const InactiveUsersList = () => { | ||||
|     const { removeUser, userApiErrors } = useAdminUsersApi(); | ||||
|     const { deleteInactiveUsers, errors: inactiveUsersApiErrors } = | ||||
|         useInactiveUsersApi(); | ||||
|     const { setToastData, setToastApiError } = useToast(); | ||||
|     const { inactiveUsers, refetchInactiveUsers, loading, error } = | ||||
|         useInactiveUsers(); | ||||
|     const { | ||||
|         users, | ||||
|         roles, | ||||
|         loading: usersLoading, | ||||
|         refetch, | ||||
|         error: usersError, | ||||
|     } = useUsers(); | ||||
|     const [delDialog, setDelDialog] = useState(false); | ||||
|     const [delUser, setDelUser] = useState<IInactiveUser>(); | ||||
|     const [showDelInactiveDialog, setShowDelInactiveDialog] = useState(false); | ||||
|     const closeDelDialog = () => { | ||||
|         setDelDialog(false); | ||||
|         setDelUser(undefined); | ||||
|     }; | ||||
| 
 | ||||
|     const openDelDialog = | ||||
|         (user: IInactiveUser) => (e: React.SyntheticEvent<Element, Event>) => { | ||||
|             e.preventDefault(); | ||||
|             setDelDialog(true); | ||||
|             setDelUser(user); | ||||
|         }; | ||||
| 
 | ||||
|     const openDelInactiveDialog = (e: React.SyntheticEvent<Element, Event>) => { | ||||
|         e.preventDefault(); | ||||
|         setShowDelInactiveDialog(true); | ||||
|     }; | ||||
| 
 | ||||
|     const closeDelInactiveDialog = (): void => { | ||||
|         setShowDelInactiveDialog(false); | ||||
|     }; | ||||
| 
 | ||||
|     const onDelInactive = async () => { | ||||
|         try { | ||||
|             await deleteInactiveUsers(inactiveUsers.map((i) => i.id)); | ||||
|             setToastData({ | ||||
|                 title: `Inactive users has been deleted`, | ||||
|                 type: 'success', | ||||
|             }); | ||||
|             setShowDelInactiveDialog(false); | ||||
|             refetchInactiveUsers(); | ||||
|         } catch (error: unknown) { | ||||
|             setToastApiError(formatUnknownError(error)); | ||||
|         } | ||||
|     }; | ||||
|     const onDeleteUser = async (userId: number) => { | ||||
|         try { | ||||
|             await removeUser(userId); | ||||
|             setToastData({ | ||||
|                 title: `User has been deleted`, | ||||
|                 type: 'success', | ||||
|             }); | ||||
|             refetchInactiveUsers(); | ||||
|             closeDelDialog(); | ||||
|         } catch (error: unknown) { | ||||
|             setToastApiError(formatUnknownError(error)); | ||||
|         } | ||||
|     }; | ||||
|     const massagedData = useMemo( | ||||
|         () => | ||||
|             inactiveUsers.map((inactiveUser) => { | ||||
|                 const u = users.find((u) => u.id === inactiveUser.id); | ||||
|                 return { | ||||
|                     ...inactiveUser, | ||||
|                     rootRole: u?.rootRole, | ||||
|                 }; | ||||
|             }), | ||||
|         [inactiveUsers, users], | ||||
|     ); | ||||
|     const columns = useMemo( | ||||
|         () => [ | ||||
|             { | ||||
|                 id: 'name', | ||||
|                 Header: 'Name', | ||||
|                 accessor: (row: any) => row.name || '', | ||||
|                 minWidth: 200, | ||||
|                 Cell: ({ row: { original: user } }: any) => ( | ||||
|                     <HighlightCell | ||||
|                         value={user.name} | ||||
|                         subtitle={user.email || user.username} | ||||
|                     /> | ||||
|                 ), | ||||
|                 searchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 id: 'role', | ||||
|                 Header: 'Role', | ||||
|                 accessor: (row: any) => | ||||
|                     roles.find((role: IRole) => role.id === row.rootRole) | ||||
|                         ?.name || '', | ||||
|                 Cell: ({ | ||||
|                     row: { original: user }, | ||||
|                     value, | ||||
|                 }: { | ||||
|                     row: { original: IUser }; | ||||
|                     value: string; | ||||
|                 }) => <RoleCell value={value} role={user.rootRole} />, | ||||
|                 maxWidth: 120, | ||||
|             }, | ||||
|             { | ||||
|                 Header: 'Created', | ||||
|                 accessor: 'createdAt', | ||||
|                 Cell: DateCell, | ||||
|                 width: 120, | ||||
|                 maxWidth: 120, | ||||
|             }, | ||||
|             { | ||||
|                 id: 'last-login', | ||||
|                 Header: 'Last login', | ||||
|                 accessor: (row: any) => row.seenAt || '', | ||||
|                 Cell: ({ row: { original: user } }: any) => ( | ||||
|                     <TimeAgoCell | ||||
|                         value={user.seenAt} | ||||
|                         emptyText='Never' | ||||
|                         title={(date) => `Last login: ${date}`} | ||||
|                     /> | ||||
|                 ), | ||||
|                 maxWidth: 150, | ||||
|             }, | ||||
|             { | ||||
|                 id: 'pat-last-login', | ||||
|                 Header: 'PAT last used', | ||||
|                 accessor: (row: any) => row.patSeenAt || '', | ||||
|                 Cell: ({ row: { original: user } }: any) => ( | ||||
|                     <TimeAgoCell | ||||
|                         value={user.patSeenAt} | ||||
|                         emptyText='Never' | ||||
|                         title={(date) => `Last used: ${date}`} | ||||
|                     /> | ||||
|                 ), | ||||
|                 maxWidth: 150, | ||||
|             }, | ||||
|             { | ||||
|                 id: 'Actions', | ||||
|                 Header: 'Actions', | ||||
|                 align: 'center', | ||||
|                 Cell: ({ row: { original: user } }: any) => ( | ||||
|                     <InactiveUsersActionCell onDelete={openDelDialog(user)} /> | ||||
|                 ), | ||||
|                 width: 200, | ||||
|                 disableSortBy: true, | ||||
|             }, | ||||
|         ], | ||||
|         [roles], | ||||
|     ); | ||||
|     const initialState = useMemo(() => { | ||||
|         return { | ||||
|             sortBy: [{ id: 'createdAt', desc: true }], | ||||
|             hiddenColumns: ['username', 'email'], | ||||
|         }; | ||||
|     }, []); | ||||
| 
 | ||||
|     const { headerGroups, rows, prepareRow } = useTable( | ||||
|         { | ||||
|             columns: columns as any, | ||||
|             data: massagedData, | ||||
|             initialState, | ||||
|             autoResetHiddenColumns: false, | ||||
|             autoResetSortBy: false, | ||||
|             disableSortRemove: true, | ||||
|             disableMultiSort: true, | ||||
|             defaultColumn: { | ||||
|                 Cell: TextCell, | ||||
|             }, | ||||
|         }, | ||||
|         useSortBy, | ||||
|         useFlexLayout, | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|         <PageContent | ||||
|             isLoading={loading} | ||||
|             header={ | ||||
|                 <PageHeader | ||||
|                     title={`Inactive users (${rows.length})`} | ||||
|                     actions={ | ||||
|                         <> | ||||
|                             <Button | ||||
|                                 variant='contained' | ||||
|                                 color='primary' | ||||
|                                 onClick={openDelInactiveDialog} | ||||
|                                 disabled={inactiveUsers.length === 0} | ||||
|                             > | ||||
|                                 Delete all inactive users | ||||
|                             </Button> | ||||
|                         </> | ||||
|                     } | ||||
|                 /> | ||||
|             } | ||||
|         > | ||||
|             <StyledUsersLinkDiv> | ||||
|                 <Link to={'/admin/users'}>View all users</Link> | ||||
|             </StyledUsersLinkDiv> | ||||
|             <VirtualizedTable | ||||
|                 rows={rows} | ||||
|                 headerGroups={headerGroups} | ||||
|                 prepareRow={prepareRow} | ||||
|             /> | ||||
|             <ConditionallyRender | ||||
|                 condition={rows.length === 0} | ||||
|                 show={ | ||||
|                     <TablePlaceholder> | ||||
|                         No inactive users found. | ||||
|                     </TablePlaceholder> | ||||
|                 } | ||||
|             /> | ||||
|             <ConditionallyRender | ||||
|                 condition={Boolean(delUser)} | ||||
|                 show={ | ||||
|                     <DeleteUser | ||||
|                         showDialog={delDialog} | ||||
|                         closeDialog={closeDelDialog} | ||||
|                         user={delUser!} | ||||
|                         userLoading={usersLoading} | ||||
|                         removeUser={() => onDeleteUser(delUser!.id)} | ||||
|                         userApiErrors={userApiErrors} | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|             <DeleteInactiveUsers | ||||
|                 showDialog={showDelInactiveDialog} | ||||
|                 closeDialog={closeDelInactiveDialog} | ||||
|                 inactiveUsersLoading={loading} | ||||
|                 inactiveUserApiErrors={inactiveUsersApiErrors} | ||||
|                 inactiveUsers={inactiveUsers} | ||||
|                 removeInactiveUsers={onDelInactive} | ||||
|             /> | ||||
|         </PageContent> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										6
									
								
								frontend/src/component/admin/users/Users.styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								frontend/src/component/admin/users/Users.styles.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| import { styled } from '@mui/material'; | ||||
| 
 | ||||
| export const StyledUsersLinkDiv = styled('div')(({ theme }) => ({ | ||||
|     marginTop: theme.spacing(-2), | ||||
|     paddingBottom: theme.spacing(2), | ||||
| })); | ||||
| @ -5,6 +5,7 @@ import { InviteLinkBar } from './InviteLinkBar/InviteLinkBar'; | ||||
| import { Route, Routes } from 'react-router-dom'; | ||||
| import EditUser from './EditUser/EditUser'; | ||||
| import NotFound from 'component/common/NotFound/NotFound'; | ||||
| import { InactiveUsersList } from './InactiveUsersList/InactiveUsersList'; | ||||
| 
 | ||||
| export const UsersAdmin = () => ( | ||||
|     <div> | ||||
| @ -20,6 +21,7 @@ export const UsersAdmin = () => ( | ||||
|                     } | ||||
|                 /> | ||||
|                 <Route path=':id/edit' element={<EditUser />} /> | ||||
|                 <Route path='inactive' element={<InactiveUsersList />} /> | ||||
|                 <Route path='*' element={<NotFound />} /> | ||||
|             </Routes> | ||||
|         </PermissionGuard> | ||||
|  | ||||
| @ -22,7 +22,7 @@ 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 { useNavigate } from 'react-router-dom'; | ||||
| import { Link, useNavigate } from 'react-router-dom'; | ||||
| import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; | ||||
| import theme from 'themes/theme'; | ||||
| import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; | ||||
| @ -35,6 +35,7 @@ import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell'; | ||||
| import { useSearch } from 'hooks/useSearch'; | ||||
| import { Download } from '@mui/icons-material'; | ||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import { StyledUsersLinkDiv } from '../Users.styles'; | ||||
| 
 | ||||
| const UsersList = () => { | ||||
|     const navigate = useNavigate(); | ||||
| @ -63,7 +64,6 @@ const UsersList = () => { | ||||
| 
 | ||||
|     const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|     const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); | ||||
| 
 | ||||
|     const closeDelDialog = () => { | ||||
|         setDelDialog(false); | ||||
|         setDelUser(undefined); | ||||
| @ -306,6 +306,9 @@ const UsersList = () => { | ||||
|             } | ||||
|         > | ||||
|             <UserLimitWarning /> | ||||
|             <StyledUsersLinkDiv> | ||||
|                 <Link to='/admin/users/inactive'>View inactive users</Link> | ||||
|             </StyledUsersLinkDiv> | ||||
|             <SearchHighlightProvider value={getSearchText(searchValue)}> | ||||
|                 <VirtualizedTable | ||||
|                     rows={rows} | ||||
|  | ||||
| @ -0,0 +1,21 @@ | ||||
| import useAPI from '../useApi/useApi'; | ||||
| 
 | ||||
| export const DEL_INACTIVE_USERS_ERROR = 'delInactiveUsers'; | ||||
| export const useInactiveUsersApi = () => { | ||||
|     const { makeRequest, createRequest, errors, loading } = useAPI({ | ||||
|         propagateErrors: true, | ||||
|     }); | ||||
| 
 | ||||
|     const deleteInactiveUsers = async (userIds: number[]) => { | ||||
|         const path = `api/admin/user-admin/inactive/delete`; | ||||
|         const req = createRequest(path, { | ||||
|             method: 'POST', | ||||
|             body: JSON.stringify({ | ||||
|                 ids: userIds, | ||||
|             }), | ||||
|         }); | ||||
|         return makeRequest(req.caller, req.id); | ||||
|     }; | ||||
| 
 | ||||
|     return { deleteInactiveUsers, errors, loading }; | ||||
| }; | ||||
| @ -0,0 +1,43 @@ | ||||
| import handleErrorResponses from '../httpErrorResponseHandler'; | ||||
| import { formatApiPath } from '../../../../utils/formatPath'; | ||||
| import useSWR from 'swr'; | ||||
| import { useMemo } from 'react'; | ||||
| 
 | ||||
| export interface IInactiveUser { | ||||
|     id: number; | ||||
|     username?: string; | ||||
|     email?: string; | ||||
|     name?: string; | ||||
|     seenAt?: Date; | ||||
|     patSeenAt?: Date; | ||||
|     createdAt?: Date; | ||||
| } | ||||
| export interface IUseInactiveUsersOutput { | ||||
|     inactiveUsers: IInactiveUser[]; | ||||
|     refetchInactiveUsers: () => void; | ||||
|     loading: boolean; | ||||
|     error?: Error; | ||||
| } | ||||
| 
 | ||||
| export const useInactiveUsers = (): IUseInactiveUsersOutput => { | ||||
|     const { data, error, mutate } = useSWR( | ||||
|         formatApiPath(`api/admin/user-admin/inactive`), | ||||
|         fetcher, | ||||
|     ); | ||||
| 
 | ||||
|     return useMemo( | ||||
|         () => ({ | ||||
|             inactiveUsers: data?.inactiveUsers ?? [], | ||||
|             error, | ||||
|             refetchInactiveUsers: () => mutate(), | ||||
|             loading: !error && !data, | ||||
|         }), | ||||
|         [data, error, mutate], | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const fetcher = (path: string) => { | ||||
|     return fetch(path) | ||||
|         .then(handleErrorResponses('User')) | ||||
|         .then((res) => res.json()); | ||||
| }; | ||||
| @ -122,16 +122,11 @@ declare module '@mui/material/styles' { | ||||
|          **/ | ||||
|         variants: string[]; | ||||
|     } | ||||
|     // biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
 | ||||
|     interface Theme extends CustomTheme {} | ||||
|     // biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
 | ||||
|     interface ThemeOptions extends CustomTheme {} | ||||
| 
 | ||||
|     // biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
 | ||||
|     interface Palette extends CustomPalette {} | ||||
|     // biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
 | ||||
|     interface PaletteOptions extends CustomPalette {} | ||||
|     // biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
 | ||||
|     interface TypeBackground extends CustomTypeBackground {} | ||||
| 
 | ||||
|     /* Extend the background object from MUI */ | ||||
|  | ||||
| @ -40,6 +40,7 @@ import PrivateProjectStore from '../features/private-project/privateProjectStore | ||||
| import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store'; | ||||
| import LastSeenStore from '../features/metrics/last-seen/last-seen-store'; | ||||
| import FeatureSearchStore from '../features/feature-search/feature-search-store'; | ||||
| import { InactiveUsersStore } from '../users/inactive/inactive-users-store'; | ||||
| 
 | ||||
| export const createStores = ( | ||||
|     config: IUnleashConfig, | ||||
| @ -141,6 +142,7 @@ export const createStores = ( | ||||
|         dependentFeaturesStore: new DependentFeaturesStore(db), | ||||
|         lastSeenStore: new LastSeenStore(db, eventBus, getLogger), | ||||
|         featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger), | ||||
|         inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -171,6 +171,9 @@ import { | ||||
|     searchFeaturesSchema, | ||||
|     featureTypeCountSchema, | ||||
|     featureSearchResponseSchema, | ||||
|     inactiveUserSchema, | ||||
|     inactiveUsersSchema, | ||||
|     idsSchema, | ||||
| } from './spec'; | ||||
| import { IServerOption } from '../types'; | ||||
| import { mapValues, omitKeys } from '../util'; | ||||
| @ -295,6 +298,7 @@ export const schemas: UnleashSchemas = { | ||||
|     healthOverviewSchema, | ||||
|     healthReportSchema, | ||||
|     idSchema, | ||||
|     idsSchema, | ||||
|     instanceAdminStatsSchema, | ||||
|     legalValueSchema, | ||||
|     loginSchema, | ||||
| @ -405,6 +409,8 @@ export const schemas: UnleashSchemas = { | ||||
|     featureTypeCountSchema, | ||||
|     projectOverviewSchema, | ||||
|     featureSearchResponseSchema, | ||||
|     inactiveUserSchema, | ||||
|     inactiveUsersSchema, | ||||
| }; | ||||
| 
 | ||||
| // Remove JSONSchema keys that would result in an invalid OpenAPI spec.
 | ||||
|  | ||||
							
								
								
									
										23
									
								
								src/lib/openapi/spec/ids-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/lib/openapi/spec/ids-schema.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { FromSchema } from 'json-schema-to-ts'; | ||||
| 
 | ||||
| export const idsSchema = { | ||||
|     $id: '#/components/schemas/idsSchema', | ||||
|     type: 'object', | ||||
|     additionalProperties: false, | ||||
|     description: 'Used for bulk deleting multiple ids', | ||||
|     required: ['ids'], | ||||
|     properties: { | ||||
|         ids: { | ||||
|             type: 'array', | ||||
|             description: 'Ids, for instance userid', | ||||
|             items: { | ||||
|                 type: 'number', | ||||
|                 minimum: 0, | ||||
|             }, | ||||
|             example: [12, 212], | ||||
|         }, | ||||
|     }, | ||||
|     components: {}, | ||||
| } as const; | ||||
| 
 | ||||
| export type IdsSchema = FromSchema<typeof idsSchema>; | ||||
							
								
								
									
										57
									
								
								src/lib/openapi/spec/inactive-user-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/lib/openapi/spec/inactive-user-schema.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,57 @@ | ||||
| import { FromSchema } from 'json-schema-to-ts'; | ||||
| 
 | ||||
| export const inactiveUserSchema = { | ||||
|     $id: '#/components/schemas/inactiveUserSchema', | ||||
|     type: 'object', | ||||
|     additionalProperties: false, | ||||
|     description: 'A Unleash user that has been flagged as inactive', | ||||
|     required: ['id'], | ||||
|     properties: { | ||||
|         id: { | ||||
|             description: 'The user id', | ||||
|             type: 'integer', | ||||
|             minimum: 0, | ||||
|             example: 123, | ||||
|         }, | ||||
|         name: { | ||||
|             description: 'Name of the user', | ||||
|             type: 'string', | ||||
|             example: 'Ned Ryerson', | ||||
|             nullable: true, | ||||
|         }, | ||||
|         email: { | ||||
|             description: 'Email of the user', | ||||
|             type: 'string', | ||||
|             example: 'user@example.com', | ||||
|         }, | ||||
|         username: { | ||||
|             description: 'A unique username for the user', | ||||
|             type: 'string', | ||||
|             example: 'nedryerson', | ||||
|             nullable: true, | ||||
|         }, | ||||
|         seenAt: { | ||||
|             description: 'The last time this user logged in', | ||||
|             type: 'string', | ||||
|             format: 'date-time', | ||||
|             nullable: true, | ||||
|             example: '2024-01-25T11:42:00.345Z', | ||||
|         }, | ||||
|         createdAt: { | ||||
|             description: 'The user was created at this time', | ||||
|             type: 'string', | ||||
|             format: 'date-time', | ||||
|             example: '2023-12-31T23:59:59.999Z', | ||||
|         }, | ||||
|         patSeenAt: { | ||||
|             description: `The last time this user's PAT token (if any) was used`, | ||||
|             type: 'string', | ||||
|             format: 'date-time', | ||||
|             nullable: true, | ||||
|             example: '2024-01-01T23:59:59.999Z', | ||||
|         }, | ||||
|     }, | ||||
|     components: {}, | ||||
| } as const; | ||||
| 
 | ||||
| export type InactiveUserSchema = FromSchema<typeof inactiveUserSchema>; | ||||
							
								
								
									
										31
									
								
								src/lib/openapi/spec/inactive-users-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/lib/openapi/spec/inactive-users-schema.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| import { inactiveUserSchema } from './inactive-user-schema'; | ||||
| import { FromSchema } from 'json-schema-to-ts'; | ||||
| 
 | ||||
| export const inactiveUsersSchema = { | ||||
|     $id: '#/components/schemas/inactiveUsersSchema', | ||||
|     type: 'object', | ||||
|     additionalProperties: false, | ||||
|     description: 'A list of users that has been flagged as inactive', | ||||
|     required: ['version', 'inactiveUsers'], | ||||
|     properties: { | ||||
|         version: { | ||||
|             description: | ||||
|                 'The version of this schema. Used to keep track of compatibility', | ||||
|             type: 'integer', | ||||
|             minimum: 1, | ||||
|             example: 1, | ||||
|         }, | ||||
|         inactiveUsers: { | ||||
|             description: 'The list of users that are flagged as inactive', | ||||
|             type: 'array', | ||||
|             items: { | ||||
|                 $ref: '#/components/schemas/inactiveUserSchema', | ||||
|             }, | ||||
|         }, | ||||
|     }, | ||||
|     components: { | ||||
|         inactiveUserSchema, | ||||
|     }, | ||||
| } as const; | ||||
| 
 | ||||
| export type InactiveUsersSchema = FromSchema<typeof inactiveUsersSchema>; | ||||
| @ -1,4 +1,5 @@ | ||||
| export * from './id-schema'; | ||||
| export * from './ids-schema'; | ||||
| export * from './me-schema'; | ||||
| export * from './create-pat-schema'; | ||||
| export * from './pat-schema'; | ||||
| @ -172,3 +173,5 @@ export * from './search-features-schema'; | ||||
| export * from './feature-search-query-parameters'; | ||||
| export * from './feature-type-count-schema'; | ||||
| export * from './feature-search-response-schema'; | ||||
| export * from './inactive-user-schema'; | ||||
| export * from './inactive-users-schema'; | ||||
|  | ||||
| @ -34,6 +34,7 @@ import { Db } from '../../db/db'; | ||||
| import ExportImportController from '../../features/export-import-toggles/export-import-controller'; | ||||
| import { SegmentsController } from '../../features/segment/segment-controller'; | ||||
| import FeatureSearchController from '../../features/feature-search/feature-search-controller'; | ||||
| import { InactiveUsersController } from '../../users/inactive/inactive-users-controller'; | ||||
| 
 | ||||
| class AdminApi extends Controller { | ||||
|     constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { | ||||
| @ -78,6 +79,7 @@ class AdminApi extends Controller { | ||||
|             '/user/tokens', | ||||
|             new PatController(config, services).router, | ||||
|         ); | ||||
| 
 | ||||
|         this.app.use( | ||||
|             '/ui-config', | ||||
|             new ConfigController(config, services).router, | ||||
| @ -102,10 +104,15 @@ class AdminApi extends Controller { | ||||
|             new ApiTokenController(config, services).router, | ||||
|         ); | ||||
|         this.app.use('/email', new EmailController(config, services).router); | ||||
|         this.app.use( | ||||
|             '/user-admin/inactive', | ||||
|             new InactiveUsersController(config, services).router, | ||||
|         ); // Needs to load first, so that /api/admin/user-admin/{id} doesn't hit first
 | ||||
|         this.app.use( | ||||
|             '/user-admin', | ||||
|             new UserAdminController(config, services).router, | ||||
|         ); | ||||
| 
 | ||||
|         this.app.use( | ||||
|             '/feedback', | ||||
|             new UserFeedbackController(config, services).router, | ||||
|  | ||||
| @ -108,6 +108,7 @@ import { | ||||
|     createFakeInstanceStatsService, | ||||
|     createInstanceStatsService, | ||||
| } from '../features/instance-stats/createInstanceStatsService'; | ||||
| import { InactiveUsersService } from '../users/inactive/inactive-users-service'; | ||||
| 
 | ||||
| export const createServices = ( | ||||
|     stores: IUnleashStores, | ||||
| @ -316,6 +317,9 @@ export const createServices = ( | ||||
|     ); | ||||
| 
 | ||||
|     const eventAnnouncerService = new EventAnnouncerService(stores, config); | ||||
|     const inactiveUsersService = new InactiveUsersService(stores, config, { | ||||
|         userService, | ||||
|     }); | ||||
| 
 | ||||
|     return { | ||||
|         accessService, | ||||
| @ -373,6 +377,7 @@ export const createServices = ( | ||||
|         transactionalDependentFeaturesService, | ||||
|         clientFeatureToggleService, | ||||
|         featureSearchService, | ||||
|         inactiveUsersService, | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -51,6 +51,7 @@ import { DependentFeaturesService } from '../features/dependent-features/depende | ||||
| import { WithTransactional } from '../db/transaction'; | ||||
| import { ClientFeatureToggleService } from '../features/client-feature-toggles/client-feature-toggle-service'; | ||||
| import { FeatureSearchService } from '../features/feature-search/feature-search-service'; | ||||
| import { InactiveUsersService } from '../users/inactive/inactive-users-service'; | ||||
| 
 | ||||
| export interface IUnleashServices { | ||||
|     accessService: AccessService; | ||||
| @ -111,4 +112,5 @@ export interface IUnleashServices { | ||||
|     transactionalDependentFeaturesService: WithTransactional<DependentFeaturesService>; | ||||
|     clientFeatureToggleService: ClientFeatureToggleService; | ||||
|     featureSearchService: FeatureSearchService; | ||||
|     inactiveUsersService: InactiveUsersService; | ||||
| } | ||||
|  | ||||
| @ -37,6 +37,7 @@ import { IPrivateProjectStore } from '../features/private-project/privateProject | ||||
| import { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type'; | ||||
| import { ILastSeenStore } from '../features/metrics/last-seen/types/last-seen-store-type'; | ||||
| import { IFeatureSearchStore } from '../features/feature-search/feature-search-store-type'; | ||||
| import { IInactiveUsersStore } from '../users/inactive/types/inactive-users-store-type'; | ||||
| 
 | ||||
| export interface IUnleashStores { | ||||
|     accessStore: IAccessStore; | ||||
| @ -78,6 +79,7 @@ export interface IUnleashStores { | ||||
|     dependentFeaturesStore: IDependentFeaturesStore; | ||||
|     lastSeenStore: ILastSeenStore; | ||||
|     featureSearchStore: IFeatureSearchStore; | ||||
|     inactiveUsersStore: IInactiveUsersStore; | ||||
| } | ||||
| 
 | ||||
| export { | ||||
|  | ||||
							
								
								
									
										34
									
								
								src/lib/users/inactive/createInactiveUsersService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/lib/users/inactive/createInactiveUsersService.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | ||||
| import { InactiveUsersService } from './inactive-users-service'; | ||||
| import { IUnleashConfig } from '../../server-impl'; | ||||
| import { Db } from '../../server-impl'; | ||||
| import { InactiveUsersStore } from './inactive-users-store'; | ||||
| import { FakeInactiveUsersStore } from './fakes/fake-inactive-users-store'; | ||||
| import { UserService } from '../../services'; | ||||
| 
 | ||||
| export const DAYS_TO_BE_COUNTED_AS_INACTIVE = 180; | ||||
| export const createInactiveUsersService = ( | ||||
|     db: Db, | ||||
|     config: IUnleashConfig, | ||||
|     userService: UserService, | ||||
| ): InactiveUsersService => { | ||||
|     const { eventBus, getLogger } = config; | ||||
|     const inactiveUsersStore = new InactiveUsersStore(db, eventBus, getLogger); | ||||
| 
 | ||||
|     return new InactiveUsersService( | ||||
|         { inactiveUsersStore }, | ||||
|         { getLogger }, | ||||
|         { userService }, | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export const createFakeInactiveUsersService = ( | ||||
|     { getLogger, eventBus }: Pick<IUnleashConfig, 'getLogger' | 'eventBus'>, | ||||
|     userService: UserService, | ||||
| ): InactiveUsersService => { | ||||
|     const fakeStore = new FakeInactiveUsersStore(); | ||||
|     return new InactiveUsersService( | ||||
|         { inactiveUsersStore: fakeStore }, | ||||
|         { getLogger }, | ||||
|         { userService }, | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										37
									
								
								src/lib/users/inactive/fakes/fake-inactive-users-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/lib/users/inactive/fakes/fake-inactive-users-store.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
| import { | ||||
|     IInactiveUserRow, | ||||
|     IInactiveUsersStore, | ||||
| } from '../types/inactive-users-store-type'; | ||||
| import { IUser } from '../../../types'; | ||||
| import { subDays } from 'date-fns'; | ||||
| 
 | ||||
| export class FakeInactiveUsersStore implements IInactiveUsersStore { | ||||
|     private users: IUser[] = []; | ||||
|     constructor(users?: IUser[]) { | ||||
|         this.users = users ?? []; | ||||
|     } | ||||
|     getInactiveUsers(daysInactive: number): Promise<IInactiveUserRow[]> { | ||||
|         return Promise.resolve( | ||||
|             this.users | ||||
|                 .filter((user) => { | ||||
|                     if (user.seenAt) { | ||||
|                         return user.seenAt < subDays(new Date(), daysInactive); | ||||
|                     } else if (user.createdAt) { | ||||
|                         return ( | ||||
|                             user.createdAt < subDays(new Date(), daysInactive) | ||||
|                         ); | ||||
|                     } | ||||
|                 }) | ||||
|                 .map((user) => { | ||||
|                     return { | ||||
|                         id: user.id, | ||||
|                         name: user.name, | ||||
|                         username: user.username, | ||||
|                         email: user.email, | ||||
|                         seen_at: user.seenAt, | ||||
|                         created_at: user.createdAt || new Date(), | ||||
|                     }; | ||||
|                 }), | ||||
|         ); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										101
									
								
								src/lib/users/inactive/inactive-users-controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/lib/users/inactive/inactive-users-controller.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,101 @@ | ||||
| import Controller from '../../routes/controller'; | ||||
| import { ADMIN, IUnleashConfig, IUnleashServices } from '../../types'; | ||||
| import { Logger } from '../../logger'; | ||||
| import { InactiveUsersService } from './inactive-users-service'; | ||||
| import { | ||||
|     createRequestSchema, | ||||
|     createResponseSchema, | ||||
|     emptyResponse, | ||||
|     getStandardResponses, | ||||
|     IdsSchema, | ||||
|     inactiveUsersSchema, | ||||
|     InactiveUsersSchema, | ||||
| } from '../../openapi'; | ||||
| import { IAuthRequest } from '../../routes/unleash-types'; | ||||
| import { Response } from 'express'; | ||||
| import { OpenApiService } from '../../services'; | ||||
| import { DAYS_TO_BE_COUNTED_AS_INACTIVE } from './createInactiveUsersService'; | ||||
| export class InactiveUsersController extends Controller { | ||||
|     private readonly logger: Logger; | ||||
| 
 | ||||
|     private inactiveUsersService: InactiveUsersService; | ||||
| 
 | ||||
|     private openApiService: OpenApiService; | ||||
|     constructor( | ||||
|         config: IUnleashConfig, | ||||
|         { | ||||
|             inactiveUsersService, | ||||
|             openApiService, | ||||
|         }: Pick<IUnleashServices, 'inactiveUsersService' | 'openApiService'>, | ||||
|     ) { | ||||
|         super(config); | ||||
|         this.logger = config.getLogger( | ||||
|             'user/inactive/inactive-users-controller.ts', | ||||
|         ); | ||||
|         this.inactiveUsersService = inactiveUsersService; | ||||
|         this.openApiService = openApiService; | ||||
| 
 | ||||
|         this.route({ | ||||
|             method: 'get', | ||||
|             path: '', | ||||
|             handler: this.getInactiveUsers, | ||||
|             permission: ADMIN, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     operationId: 'getInactiveUsers', | ||||
|                     summary: 'Gets inactive users', | ||||
|                     description: `Gets all inactive users. An inactive user is a user that has not logged in in the last ${DAYS_TO_BE_COUNTED_AS_INACTIVE} days`, | ||||
|                     tags: ['Users'], | ||||
|                     responses: { | ||||
|                         200: createResponseSchema('inactiveUsersSchema'), | ||||
|                     }, | ||||
|                 }), | ||||
|             ], | ||||
|         }); | ||||
|         this.route({ | ||||
|             method: 'post', | ||||
|             path: '/delete', | ||||
|             handler: this.deleteInactiveUsers, | ||||
|             permission: ADMIN, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     operationId: 'deleteInactiveUsers', | ||||
|                     summary: 'Deletes inactive users', | ||||
|                     description: `Deletes all inactive users. An inactive user is a user that has not logged in in the last ${DAYS_TO_BE_COUNTED_AS_INACTIVE} days`, | ||||
|                     tags: ['Users'], | ||||
|                     requestBody: createRequestSchema('idsSchema'), | ||||
|                     responses: { | ||||
|                         200: emptyResponse, | ||||
|                         ...getStandardResponses(400, 401, 403), | ||||
|                     }, | ||||
|                 }), | ||||
|             ], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     async getInactiveUsers( | ||||
|         _req: IAuthRequest, | ||||
|         res: Response<InactiveUsersSchema>, | ||||
|     ): Promise<void> { | ||||
|         this.logger.info('Hitting inactive users'); | ||||
|         const inactiveUsers = | ||||
|             await this.inactiveUsersService.getInactiveUsers(); | ||||
|         this.openApiService.respondWithValidation( | ||||
|             200, | ||||
|             res, | ||||
|             inactiveUsersSchema.$id, | ||||
|             { version: 1, inactiveUsers }, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async deleteInactiveUsers( | ||||
|         req: IAuthRequest<undefined, undefined, IdsSchema>, | ||||
|         res: Response<void>, | ||||
|     ): Promise<void> { | ||||
|         await this.inactiveUsersService.deleteInactiveUsers( | ||||
|             req.user, | ||||
|             req.body.ids.filter((inactiveUser) => inactiveUser !== req.user.id), | ||||
|         ); | ||||
|         res.status(200).send(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										61
									
								
								src/lib/users/inactive/inactive-users-service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/lib/users/inactive/inactive-users-service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | ||||
| import { | ||||
|     IUnleashConfig, | ||||
|     IUnleashStores, | ||||
|     IUser, | ||||
|     serializeDates, | ||||
| } from '../../types'; | ||||
| import { IInactiveUsersStore } from './types/inactive-users-store-type'; | ||||
| import { Logger } from '../../logger'; | ||||
| import { InactiveUserSchema } from '../../openapi'; | ||||
| import { UserService } from '../../services'; | ||||
| import { DAYS_TO_BE_COUNTED_AS_INACTIVE } from './createInactiveUsersService'; | ||||
| 
 | ||||
| export class InactiveUsersService { | ||||
|     private inactiveUsersStore: IInactiveUsersStore; | ||||
|     private readonly logger: Logger; | ||||
|     private userService: UserService; | ||||
|     constructor( | ||||
|         { inactiveUsersStore }: Pick<IUnleashStores, 'inactiveUsersStore'>, | ||||
|         { getLogger }: Pick<IUnleashConfig, 'getLogger'>, | ||||
|         services: { | ||||
|             userService: UserService; | ||||
|         }, | ||||
|     ) { | ||||
|         this.logger = getLogger('services/client-feature-toggle-service.ts'); | ||||
|         this.inactiveUsersStore = inactiveUsersStore; | ||||
|         this.userService = services.userService; | ||||
|     } | ||||
| 
 | ||||
|     async getInactiveUsers(): Promise<InactiveUserSchema[]> { | ||||
|         const users = await this.inactiveUsersStore.getInactiveUsers( | ||||
|             DAYS_TO_BE_COUNTED_AS_INACTIVE, | ||||
|         ); | ||||
|         if (users.length > 0) { | ||||
|             return users.map((user) => { | ||||
|                 return serializeDates({ | ||||
|                     id: user.id, | ||||
|                     name: user.name, | ||||
|                     email: user.email, | ||||
|                     username: user.username, | ||||
|                     seenAt: user.seen_at, | ||||
|                     createdAt: user.created_at, | ||||
|                     patSeenAt: user.pat_seen_at, | ||||
|                 }); | ||||
|             }); | ||||
|         } else { | ||||
|             return []; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async deleteInactiveUsers( | ||||
|         calledByUser: IUser, | ||||
|         userIds: number[], | ||||
|     ): Promise<void> { | ||||
|         this.logger.info('Deleting inactive users'); | ||||
|         for (const userid of userIds) { | ||||
|             if (calledByUser.id !== userid) { | ||||
|                 await this.userService.deleteUser(userid, calledByUser); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										63
									
								
								src/lib/users/inactive/inactive-users-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/lib/users/inactive/inactive-users-store.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,63 @@ | ||||
| import { | ||||
|     IInactiveUserRow, | ||||
|     IInactiveUsersStore, | ||||
| } from './types/inactive-users-store-type'; | ||||
| import { Db } from '../../db/db'; | ||||
| import EventEmitter from 'events'; | ||||
| import { Logger, LogProvider } from '../../logger'; | ||||
| import metricsHelper from '../../util/metrics-helper'; | ||||
| import { DB_TIME } from '../../metric-events'; | ||||
| 
 | ||||
| const TABLE = 'users'; | ||||
| export class InactiveUsersStore implements IInactiveUsersStore { | ||||
|     private db: Db; | ||||
| 
 | ||||
|     private readonly logger: Logger; | ||||
| 
 | ||||
|     private timer: Function; | ||||
| 
 | ||||
|     private eventEmitter: EventEmitter; | ||||
| 
 | ||||
|     constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) { | ||||
|         this.db = db; | ||||
|         this.logger = getLogger('users/inactive/inactive-users-store.ts'); | ||||
|         this.eventEmitter = eventBus; | ||||
|         this.timer = (action) => | ||||
|             metricsHelper.wrapTimer(eventBus, DB_TIME, { | ||||
|                 store: 'inactive_users', | ||||
|                 action, | ||||
|             }); | ||||
|     } | ||||
|     async getInactiveUsers(daysInactive: number): Promise<IInactiveUserRow[]> { | ||||
|         const stopTimer = this.timer('get_inactive_users'); | ||||
|         const inactiveUsers = await this.db<IInactiveUserRow>(TABLE) | ||||
|             .select( | ||||
|                 'users.id AS id', | ||||
|                 'users.name AS name', | ||||
|                 'users.username AS username', | ||||
|                 'users.email AS email', | ||||
|                 'users.seen_at AS seen_at', | ||||
|                 'pat.seen_at AS pat_seen_at', | ||||
|                 'users.created_at AS created_at', | ||||
|             ) | ||||
|             .leftJoin( | ||||
|                 'personal_access_tokens AS pat', | ||||
|                 'users.id', | ||||
|                 'pat.user_id', | ||||
|             ) | ||||
|             .where('deleted_at', null) | ||||
|             .andWhereRaw(`users.seen_at < now() - INTERVAL '?? DAYS'`, [ | ||||
|                 daysInactive, | ||||
|             ]) | ||||
|             .orWhereRaw( | ||||
|                 `users.seen_at IS NULL AND users.created_at < now() - INTERVAL '?? DAYS'`, | ||||
|                 [daysInactive], | ||||
|             ) | ||||
|             .andWhereRaw( | ||||
|                 `pat.seen_at IS NULL OR pat.seen_at < now() - INTERVAL '?? DAYS'`, | ||||
|                 [daysInactive], | ||||
|             ); | ||||
|         stopTimer(); | ||||
|         return inactiveUsers; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										13
									
								
								src/lib/users/inactive/types/inactive-users-store-type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/lib/users/inactive/types/inactive-users-store-type.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| export interface IInactiveUserRow { | ||||
|     id: number; | ||||
|     name?: string; | ||||
|     username?: string; | ||||
|     email: string; | ||||
|     seen_at?: Date; | ||||
|     pat_seen_at?: Date; | ||||
|     created_at: Date; | ||||
| } | ||||
| 
 | ||||
| export interface IInactiveUsersStore { | ||||
|     getInactiveUsers(daysInactive: number): Promise<IInactiveUserRow[]>; | ||||
| } | ||||
							
								
								
									
										2
									
								
								src/lib/users/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/lib/users/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| export * from './inactive/createInactiveUsersService'; | ||||
| export * from './inactive/inactive-users-store'; | ||||
| @ -0,0 +1,5 @@ | ||||
| // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||
| 
 | ||||
| exports[`Inactive users service Deleting inactive users Deletes users that have never logged in but was created before our deadline: noUserSnapshot 1`] = `"No user found"`; | ||||
| 
 | ||||
| exports[`Inactive users service Deleting inactive users Finds users that was last logged in before our deadline: noUserSnapshot 1`] = `"No user found"`; | ||||
							
								
								
									
										207
									
								
								src/test/e2e/users/inactive/inactive-users-service.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								src/test/e2e/users/inactive/inactive-users-service.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,207 @@ | ||||
| import dbInit, { ITestDb } from '../../helpers/database-init'; | ||||
| import getLogger from '../../../fixtures/no-logger'; | ||||
| import { createTestConfig } from '../../../config/test-config'; | ||||
| import { | ||||
|     AccessService, | ||||
|     EmailService, | ||||
|     EventService, | ||||
|     GroupService, | ||||
| } from '../../../../lib/services'; | ||||
| import ResetTokenService from '../../../../lib/services/reset-token-service'; | ||||
| import SessionService from '../../../../lib/services/session-service'; | ||||
| import SettingService from '../../../../lib/services/setting-service'; | ||||
| import UserService from '../../../../lib/services/user-service'; | ||||
| import { ADMIN, IUnleashStores, IUser } from '../../../../lib/types'; | ||||
| import { InactiveUsersService } from '../../../../lib/users/inactive/inactive-users-service'; | ||||
| import { createInactiveUsersService } from '../../../../lib/users'; | ||||
| 
 | ||||
| let db: ITestDb; | ||||
| let stores: IUnleashStores; | ||||
| let userService: UserService; | ||||
| let sessionService: SessionService; | ||||
| let settingService: SettingService; | ||||
| let eventService: EventService; | ||||
| let accessService: AccessService; | ||||
| let inactiveUserService: InactiveUsersService; | ||||
| const deletionUser: IUser = { | ||||
|     id: -12, | ||||
|     name: 'admin user for deletion', | ||||
|     username: 'admin', | ||||
|     email: 'admin@example.com', | ||||
|     permissions: [ADMIN], | ||||
|     isAPI: false, | ||||
|     imageUrl: '', | ||||
| }; | ||||
| beforeAll(async () => { | ||||
|     db = await dbInit('inactive_user_service_serial', getLogger); | ||||
|     stores = db.stores; | ||||
|     const config = createTestConfig(); | ||||
|     eventService = new EventService(stores, config); | ||||
|     const groupService = new GroupService(stores, config, eventService); | ||||
|     accessService = new AccessService( | ||||
|         stores, | ||||
|         config, | ||||
|         groupService, | ||||
|         eventService, | ||||
|     ); | ||||
|     const resetTokenService = new ResetTokenService(stores, config); | ||||
|     const emailService = new EmailService(config); | ||||
|     sessionService = new SessionService(stores, config); | ||||
|     settingService = new SettingService(stores, config, eventService); | ||||
| 
 | ||||
|     userService = new UserService(stores, config, { | ||||
|         accessService, | ||||
|         resetTokenService, | ||||
|         emailService, | ||||
|         eventService, | ||||
|         sessionService, | ||||
|         settingService, | ||||
|     }); | ||||
|     inactiveUserService = createInactiveUsersService( | ||||
|         db.rawDatabase, | ||||
|         config, | ||||
|         userService, | ||||
|     ); | ||||
| }); | ||||
| 
 | ||||
| afterEach(async () => { | ||||
|     await db.rawDatabase.raw('DELETE FROM users WHERE id > 1000'); | ||||
| }); | ||||
| afterAll(async () => { | ||||
|     await db.destroy(); | ||||
| }); | ||||
| 
 | ||||
| describe('Inactive users service', () => { | ||||
|     describe('Finding inactive users', () => { | ||||
|         test('Finds users that have never logged in but was created before our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (9595, 'test user who never logged in', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '7 MONTHS')`);
 | ||||
|             const users = await inactiveUserService.getInactiveUsers(); | ||||
|             expect(users).toBeTruthy(); | ||||
|             expect(users).toHaveLength(1); | ||||
|         }); | ||||
|         test('Finds users that was last logged in before our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at, seen_at)
 | ||||
|                                       VALUES (9595, 'test user who never logged in', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '7 MONTHS', now() - INTERVAL '182 DAYS')`);
 | ||||
|             const users = await inactiveUserService.getInactiveUsers(); | ||||
|             expect(users).toBeTruthy(); | ||||
|             expect(users).toHaveLength(1); | ||||
|         }); | ||||
|         test('Does not find users that was last logged in after our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at, seen_at)
 | ||||
|                                       VALUES (9595, 'test user who has logged in', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '7 MONTHS', now() - INTERVAL '1 MONTH')`);
 | ||||
|             const users = await inactiveUserService.getInactiveUsers(); | ||||
|             expect(users).toBeTruthy(); | ||||
|             expect(users).toHaveLength(0); | ||||
|         }); | ||||
|         test('Does not find users that has never logged in, but was created after our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (9595, 'test user who never logged in', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '3 MONTHS')`);
 | ||||
|             const users = await inactiveUserService.getInactiveUsers(); | ||||
|             expect(users).toBeTruthy(); | ||||
|             expect(users).toHaveLength(0); | ||||
|         }); | ||||
|         test('A user with a pat that was last seen last week is not inactive', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (9595, 'test user with active PAT', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '200 DAYS')`);
 | ||||
|             await db.rawDatabase.raw( | ||||
|                 `INSERT INTO personal_access_tokens(secret, user_id, expires_at, seen_at, created_at) VALUES ('user:somefancysecret', 9595, now() + INTERVAL '6 MONTHS', now() - INTERVAL '1 WEEK', now() - INTERVAL '8 MONTHS')`, | ||||
|             ); | ||||
|             const users = await inactiveUserService.getInactiveUsers(); | ||||
|             expect(users).toBeTruthy(); | ||||
|             expect(users).toHaveLength(0); | ||||
|         }); | ||||
|         test('A user with a pat that was last seen 7 months ago is inactive', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (9595, 'test user with active PAT', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '200 DAYS')`);
 | ||||
|             await db.rawDatabase.raw( | ||||
|                 `INSERT INTO personal_access_tokens(secret, user_id, expires_at, seen_at, created_at) VALUES ('user:somefancysecret', 9595, now() + INTERVAL '6 MONTHS', now() - INTERVAL '7 MONTHS', now() - INTERVAL '8 MONTHS')`, | ||||
|             ); | ||||
|             const users = await inactiveUserService.getInactiveUsers(); | ||||
|             expect(users).toBeTruthy(); | ||||
|             expect(users).toHaveLength(1); | ||||
|         }); | ||||
|     }); | ||||
|     describe('Deleting inactive users', () => { | ||||
|         test('Deletes users that have never logged in but was created before our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (9595, 'test user who never logged in', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '7 MONTHS')`);
 | ||||
|             const usersToDelete = await inactiveUserService | ||||
|                 .getInactiveUsers() | ||||
|                 .then((users) => users.map((user) => user.id)); | ||||
|             await inactiveUserService.deleteInactiveUsers( | ||||
|                 deletionUser, | ||||
|                 usersToDelete, | ||||
|             ); | ||||
|             await expect( | ||||
|                 userService.getUser(9595), | ||||
|             ).rejects.toThrowErrorMatchingSnapshot('noUserSnapshot'); | ||||
|         }); | ||||
|         test('Finds users that was last logged in before our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at, seen_at)
 | ||||
|                                       VALUES (9595, 'test user who has not logged in in a while', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '7 MONTHS', now() - INTERVAL '182 DAYS')`);
 | ||||
|             const usersToDelete = await inactiveUserService | ||||
|                 .getInactiveUsers() | ||||
|                 .then((users) => users.map((user) => user.id)); | ||||
|             await inactiveUserService.deleteInactiveUsers( | ||||
|                 deletionUser, | ||||
|                 usersToDelete, | ||||
|             ); | ||||
|             await expect( | ||||
|                 userService.getUser(9595), | ||||
|             ).rejects.toThrowErrorMatchingSnapshot('noUserSnapshot'); | ||||
|         }); | ||||
|         test('Does not delete users that was last logged in after our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at, seen_at)
 | ||||
|                                       VALUES (9595, 'test user who has logged in recently', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '7 MONTHS', now() - INTERVAL '1 MONTH')`);
 | ||||
|             const usersToDelete = await inactiveUserService | ||||
|                 .getInactiveUsers() | ||||
|                 .then((users) => users.map((user) => user.id)); | ||||
|             await inactiveUserService.deleteInactiveUsers( | ||||
|                 deletionUser, | ||||
|                 usersToDelete, | ||||
|             ); | ||||
|             await expect(userService.getUser(9595)).resolves.toBeTruthy(); | ||||
|         }); | ||||
|         test('Does not delete users that has never logged in, but was created after our deadline', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (9595, 'test user who never logged in', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '3 MONTHS')`);
 | ||||
|             const usersToDelete = await inactiveUserService | ||||
|                 .getInactiveUsers() | ||||
|                 .then((users) => users.map((user) => user.id)); | ||||
|             await inactiveUserService.deleteInactiveUsers( | ||||
|                 deletionUser, | ||||
|                 usersToDelete, | ||||
|             ); | ||||
|             await expect(userService.getUser(9595)).resolves.toBeTruthy(); | ||||
|         }); | ||||
|         test('Does not delete the user that calls the service', async () => { | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (9595, 'test user who never logged in', 'nedryerson', 'ned@ryerson.com', | ||||
|                                               now() - INTERVAL '7 MONTHS')`);
 | ||||
|             await db.rawDatabase.raw(`INSERT INTO users(id, name, username, email, created_at)
 | ||||
|                                       VALUES (${deletionUser.id}, '${deletionUser.name}', '${deletionUser.username}', '${deletionUser.email}', now() - INTERVAL '7 MONTHS')`);
 | ||||
|             const usersToDelete = await inactiveUserService | ||||
|                 .getInactiveUsers() | ||||
|                 .then((users) => users.map((user) => user.id)); | ||||
|             await inactiveUserService.deleteInactiveUsers( | ||||
|                 deletionUser, | ||||
|                 usersToDelete, | ||||
|             ); | ||||
|             await expect(userService.getUser(9595)).rejects.toBeTruthy(); | ||||
|             await expect( | ||||
|                 userService.getUser(deletionUser.id), | ||||
|             ).resolves.toBeTruthy(); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							| @ -40,6 +40,7 @@ import FakeProjectStatsStore from './fake-project-stats-store'; | ||||
| import { FakeDependentFeaturesStore } from '../../lib/features/dependent-features/fake-dependent-features-store'; | ||||
| import { FakeLastSeenStore } from '../../lib/features/metrics/last-seen/fake-last-seen-store'; | ||||
| import FakeFeatureSearchStore from '../../lib/features/feature-search/fake-feature-search-store'; | ||||
| import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inactive-users-store'; | ||||
| 
 | ||||
| const db = { | ||||
|     select: () => ({ | ||||
| @ -89,6 +90,7 @@ const createStores: () => IUnleashStores = () => { | ||||
|         dependentFeaturesStore: new FakeDependentFeaturesStore(), | ||||
|         lastSeenStore: new FakeLastSeenStore(), | ||||
|         featureSearchStore: new FakeFeatureSearchStore(), | ||||
|         inactiveUsersStore: new FakeInactiveUsersStore(), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user