1
0
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:
Christopher Kolstad 2024-02-05 14:07:38 +01:00 committed by GitHub
parent 2d7464f517
commit ea38877b0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1179 additions and 7 deletions

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,6 @@
import { styled } from '@mui/material';
export const StyledUsersLinkDiv = styled('div')(({ theme }) => ({
marginTop: theme.spacing(-2),
paddingBottom: theme.spacing(2),
}));

View File

@ -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>

View File

@ -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}

View File

@ -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 };
};

View File

@ -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());
};

View File

@ -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 */

View File

@ -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),
};
};

View File

@ -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.

View 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>;

View 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>;

View 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>;

View File

@ -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';

View File

@ -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,

View File

@ -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,
};
};

View File

@ -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;
}

View File

@ -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 {

View 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 },
);
};

View 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(),
};
}),
);
}
}

View 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();
}
}

View 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);
}
}
}
}

View 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;
}
}

View 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
View File

@ -0,0 +1,2 @@
export * from './inactive/createInactiveUsersService';
export * from './inactive/inactive-users-store';

View File

@ -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"`;

View 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();
});
});
});

View File

@ -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(),
};
};