mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-31 01:16:01 +02: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 { Route, Routes } from 'react-router-dom';
|
||||||
import EditUser from './EditUser/EditUser';
|
import EditUser from './EditUser/EditUser';
|
||||||
import NotFound from 'component/common/NotFound/NotFound';
|
import NotFound from 'component/common/NotFound/NotFound';
|
||||||
|
import { InactiveUsersList } from './InactiveUsersList/InactiveUsersList';
|
||||||
|
|
||||||
export const UsersAdmin = () => (
|
export const UsersAdmin = () => (
|
||||||
<div>
|
<div>
|
||||||
@ -20,6 +21,7 @@ export const UsersAdmin = () => (
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path=':id/edit' element={<EditUser />} />
|
<Route path=':id/edit' element={<EditUser />} />
|
||||||
|
<Route path='inactive' element={<InactiveUsersList />} />
|
||||||
<Route path='*' element={<NotFound />} />
|
<Route path='*' element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
|
@ -22,7 +22,7 @@ import { useFlexLayout, useSortBy, useTable } from 'react-table';
|
|||||||
import { sortTypes } from 'utils/sortTypes';
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
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 { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||||
import theme from 'themes/theme';
|
import theme from 'themes/theme';
|
||||||
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
|
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 { useSearch } from 'hooks/useSearch';
|
||||||
import { Download } from '@mui/icons-material';
|
import { Download } from '@mui/icons-material';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { StyledUsersLinkDiv } from '../Users.styles';
|
||||||
|
|
||||||
const UsersList = () => {
|
const UsersList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -63,7 +64,6 @@ const UsersList = () => {
|
|||||||
|
|
||||||
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
const closeDelDialog = () => {
|
const closeDelDialog = () => {
|
||||||
setDelDialog(false);
|
setDelDialog(false);
|
||||||
setDelUser(undefined);
|
setDelUser(undefined);
|
||||||
@ -306,6 +306,9 @@ const UsersList = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<UserLimitWarning />
|
<UserLimitWarning />
|
||||||
|
<StyledUsersLinkDiv>
|
||||||
|
<Link to='/admin/users/inactive'>View inactive users</Link>
|
||||||
|
</StyledUsersLinkDiv>
|
||||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||||
<VirtualizedTable
|
<VirtualizedTable
|
||||||
rows={rows}
|
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[];
|
variants: string[];
|
||||||
}
|
}
|
||||||
// biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
|
|
||||||
interface Theme extends CustomTheme {}
|
interface Theme extends CustomTheme {}
|
||||||
// biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
|
|
||||||
interface ThemeOptions extends CustomTheme {}
|
interface ThemeOptions extends CustomTheme {}
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
|
|
||||||
interface Palette extends CustomPalette {}
|
interface Palette extends CustomPalette {}
|
||||||
// biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
|
|
||||||
interface PaletteOptions extends CustomPalette {}
|
interface PaletteOptions extends CustomPalette {}
|
||||||
// biome-ignore lint/suspicious/noEmptyInterface: We need this to keep types from breaking
|
|
||||||
interface TypeBackground extends CustomTypeBackground {}
|
interface TypeBackground extends CustomTypeBackground {}
|
||||||
|
|
||||||
/* Extend the background object from MUI */
|
/* 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 { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store';
|
||||||
import LastSeenStore from '../features/metrics/last-seen/last-seen-store';
|
import LastSeenStore from '../features/metrics/last-seen/last-seen-store';
|
||||||
import FeatureSearchStore from '../features/feature-search/feature-search-store';
|
import FeatureSearchStore from '../features/feature-search/feature-search-store';
|
||||||
|
import { InactiveUsersStore } from '../users/inactive/inactive-users-store';
|
||||||
|
|
||||||
export const createStores = (
|
export const createStores = (
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -141,6 +142,7 @@ export const createStores = (
|
|||||||
dependentFeaturesStore: new DependentFeaturesStore(db),
|
dependentFeaturesStore: new DependentFeaturesStore(db),
|
||||||
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
|
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
|
||||||
featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger),
|
featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger),
|
||||||
|
inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -171,6 +171,9 @@ import {
|
|||||||
searchFeaturesSchema,
|
searchFeaturesSchema,
|
||||||
featureTypeCountSchema,
|
featureTypeCountSchema,
|
||||||
featureSearchResponseSchema,
|
featureSearchResponseSchema,
|
||||||
|
inactiveUserSchema,
|
||||||
|
inactiveUsersSchema,
|
||||||
|
idsSchema,
|
||||||
} from './spec';
|
} from './spec';
|
||||||
import { IServerOption } from '../types';
|
import { IServerOption } from '../types';
|
||||||
import { mapValues, omitKeys } from '../util';
|
import { mapValues, omitKeys } from '../util';
|
||||||
@ -295,6 +298,7 @@ export const schemas: UnleashSchemas = {
|
|||||||
healthOverviewSchema,
|
healthOverviewSchema,
|
||||||
healthReportSchema,
|
healthReportSchema,
|
||||||
idSchema,
|
idSchema,
|
||||||
|
idsSchema,
|
||||||
instanceAdminStatsSchema,
|
instanceAdminStatsSchema,
|
||||||
legalValueSchema,
|
legalValueSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
@ -405,6 +409,8 @@ export const schemas: UnleashSchemas = {
|
|||||||
featureTypeCountSchema,
|
featureTypeCountSchema,
|
||||||
projectOverviewSchema,
|
projectOverviewSchema,
|
||||||
featureSearchResponseSchema,
|
featureSearchResponseSchema,
|
||||||
|
inactiveUserSchema,
|
||||||
|
inactiveUsersSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
// 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 './id-schema';
|
||||||
|
export * from './ids-schema';
|
||||||
export * from './me-schema';
|
export * from './me-schema';
|
||||||
export * from './create-pat-schema';
|
export * from './create-pat-schema';
|
||||||
export * from './pat-schema';
|
export * from './pat-schema';
|
||||||
@ -172,3 +173,5 @@ export * from './search-features-schema';
|
|||||||
export * from './feature-search-query-parameters';
|
export * from './feature-search-query-parameters';
|
||||||
export * from './feature-type-count-schema';
|
export * from './feature-type-count-schema';
|
||||||
export * from './feature-search-response-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 ExportImportController from '../../features/export-import-toggles/export-import-controller';
|
||||||
import { SegmentsController } from '../../features/segment/segment-controller';
|
import { SegmentsController } from '../../features/segment/segment-controller';
|
||||||
import FeatureSearchController from '../../features/feature-search/feature-search-controller';
|
import FeatureSearchController from '../../features/feature-search/feature-search-controller';
|
||||||
|
import { InactiveUsersController } from '../../users/inactive/inactive-users-controller';
|
||||||
|
|
||||||
class AdminApi extends Controller {
|
class AdminApi extends Controller {
|
||||||
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
|
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
|
||||||
@ -78,6 +79,7 @@ class AdminApi extends Controller {
|
|||||||
'/user/tokens',
|
'/user/tokens',
|
||||||
new PatController(config, services).router,
|
new PatController(config, services).router,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.app.use(
|
this.app.use(
|
||||||
'/ui-config',
|
'/ui-config',
|
||||||
new ConfigController(config, services).router,
|
new ConfigController(config, services).router,
|
||||||
@ -102,10 +104,15 @@ class AdminApi extends Controller {
|
|||||||
new ApiTokenController(config, services).router,
|
new ApiTokenController(config, services).router,
|
||||||
);
|
);
|
||||||
this.app.use('/email', new EmailController(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(
|
this.app.use(
|
||||||
'/user-admin',
|
'/user-admin',
|
||||||
new UserAdminController(config, services).router,
|
new UserAdminController(config, services).router,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.app.use(
|
this.app.use(
|
||||||
'/feedback',
|
'/feedback',
|
||||||
new UserFeedbackController(config, services).router,
|
new UserFeedbackController(config, services).router,
|
||||||
|
@ -108,6 +108,7 @@ import {
|
|||||||
createFakeInstanceStatsService,
|
createFakeInstanceStatsService,
|
||||||
createInstanceStatsService,
|
createInstanceStatsService,
|
||||||
} from '../features/instance-stats/createInstanceStatsService';
|
} from '../features/instance-stats/createInstanceStatsService';
|
||||||
|
import { InactiveUsersService } from '../users/inactive/inactive-users-service';
|
||||||
|
|
||||||
export const createServices = (
|
export const createServices = (
|
||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
@ -316,6 +317,9 @@ export const createServices = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const eventAnnouncerService = new EventAnnouncerService(stores, config);
|
const eventAnnouncerService = new EventAnnouncerService(stores, config);
|
||||||
|
const inactiveUsersService = new InactiveUsersService(stores, config, {
|
||||||
|
userService,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessService,
|
accessService,
|
||||||
@ -373,6 +377,7 @@ export const createServices = (
|
|||||||
transactionalDependentFeaturesService,
|
transactionalDependentFeaturesService,
|
||||||
clientFeatureToggleService,
|
clientFeatureToggleService,
|
||||||
featureSearchService,
|
featureSearchService,
|
||||||
|
inactiveUsersService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -51,6 +51,7 @@ import { DependentFeaturesService } from '../features/dependent-features/depende
|
|||||||
import { WithTransactional } from '../db/transaction';
|
import { WithTransactional } from '../db/transaction';
|
||||||
import { ClientFeatureToggleService } from '../features/client-feature-toggles/client-feature-toggle-service';
|
import { ClientFeatureToggleService } from '../features/client-feature-toggles/client-feature-toggle-service';
|
||||||
import { FeatureSearchService } from '../features/feature-search/feature-search-service';
|
import { FeatureSearchService } from '../features/feature-search/feature-search-service';
|
||||||
|
import { InactiveUsersService } from '../users/inactive/inactive-users-service';
|
||||||
|
|
||||||
export interface IUnleashServices {
|
export interface IUnleashServices {
|
||||||
accessService: AccessService;
|
accessService: AccessService;
|
||||||
@ -111,4 +112,5 @@ export interface IUnleashServices {
|
|||||||
transactionalDependentFeaturesService: WithTransactional<DependentFeaturesService>;
|
transactionalDependentFeaturesService: WithTransactional<DependentFeaturesService>;
|
||||||
clientFeatureToggleService: ClientFeatureToggleService;
|
clientFeatureToggleService: ClientFeatureToggleService;
|
||||||
featureSearchService: FeatureSearchService;
|
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 { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type';
|
||||||
import { ILastSeenStore } from '../features/metrics/last-seen/types/last-seen-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 { IFeatureSearchStore } from '../features/feature-search/feature-search-store-type';
|
||||||
|
import { IInactiveUsersStore } from '../users/inactive/types/inactive-users-store-type';
|
||||||
|
|
||||||
export interface IUnleashStores {
|
export interface IUnleashStores {
|
||||||
accessStore: IAccessStore;
|
accessStore: IAccessStore;
|
||||||
@ -78,6 +79,7 @@ export interface IUnleashStores {
|
|||||||
dependentFeaturesStore: IDependentFeaturesStore;
|
dependentFeaturesStore: IDependentFeaturesStore;
|
||||||
lastSeenStore: ILastSeenStore;
|
lastSeenStore: ILastSeenStore;
|
||||||
featureSearchStore: IFeatureSearchStore;
|
featureSearchStore: IFeatureSearchStore;
|
||||||
|
inactiveUsersStore: IInactiveUsersStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
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 { FakeDependentFeaturesStore } from '../../lib/features/dependent-features/fake-dependent-features-store';
|
||||||
import { FakeLastSeenStore } from '../../lib/features/metrics/last-seen/fake-last-seen-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 FakeFeatureSearchStore from '../../lib/features/feature-search/fake-feature-search-store';
|
||||||
|
import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inactive-users-store';
|
||||||
|
|
||||||
const db = {
|
const db = {
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@ -89,6 +90,7 @@ const createStores: () => IUnleashStores = () => {
|
|||||||
dependentFeaturesStore: new FakeDependentFeaturesStore(),
|
dependentFeaturesStore: new FakeDependentFeaturesStore(),
|
||||||
lastSeenStore: new FakeLastSeenStore(),
|
lastSeenStore: new FakeLastSeenStore(),
|
||||||
featureSearchStore: new FakeFeatureSearchStore(),
|
featureSearchStore: new FakeFeatureSearchStore(),
|
||||||
|
inactiveUsersStore: new FakeInactiveUsersStore(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user