mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-26 01:17:00 +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 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