diff --git a/frontend/src/component/admin/users/InactiveUsersList/DeleteInactiveUsers/DeleteInactiveUsers.tsx b/frontend/src/component/admin/users/InactiveUsersList/DeleteInactiveUsers/DeleteInactiveUsers.tsx new file mode 100644 index 0000000000..1bca89c41a --- /dev/null +++ b/frontend/src/component/admin/users/InactiveUsersList/DeleteInactiveUsers/DeleteInactiveUsers.tsx @@ -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; + inactiveUsers: IInactiveUser[]; +} +export const DeleteInactiveUsers = ({ + showDialog, + closeDialog, + inactiveUsersLoading, + removeInactiveUsers, + inactiveUserApiErrors, + inactiveUsers, +}: IDeleteInactiveUsersProps) => { + const ref = useLoading(inactiveUsersLoading); + return ( + +
+ + {inactiveUserApiErrors[DEL_INACTIVE_USERS_ERROR]} + + } + /> +
+ + You will be deleting{' '} + {inactiveUsers.length === 1 + ? `1 inactive user` + : `${inactiveUsers.length} inactive users`} + +
+
+
+ ); +}; diff --git a/frontend/src/component/admin/users/InactiveUsersList/DeleteUser/DeleteUser.tsx b/frontend/src/component/admin/users/InactiveUsersList/DeleteUser/DeleteUser.tsx new file mode 100644 index 0000000000..9fc0d90201 --- /dev/null +++ b/frontend/src/component/admin/users/InactiveUsersList/DeleteUser/DeleteUser.tsx @@ -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; +} + +const DeleteUser = ({ + showDialog, + closeDialog, + user, + userLoading, + removeUser, + userApiErrors, +}: IDeleteUserProps) => { + const ref = useLoading(userLoading); + const { classes: themeStyles } = useThemeStyles(); + + return ( + +
+ + {userApiErrors[REMOVE_USER_ERROR]} + + } + /> +
+ + {user.username || user.email} + +
+ + Are you sure you want to delete{' '} + {`${user.name || 'user'} (${user.email || user.username})`} + +
+
+ ); +}; + +export default DeleteUser; diff --git a/frontend/src/component/admin/users/InactiveUsersList/InactiveUsersActionCell/InactiveUsersActionCell.tsx b/frontend/src/component/admin/users/InactiveUsersList/InactiveUsersActionCell/InactiveUsersActionCell.tsx new file mode 100644 index 0000000000..82794a9d79 --- /dev/null +++ b/frontend/src/component/admin/users/InactiveUsersList/InactiveUsersActionCell/InactiveUsersActionCell.tsx @@ -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 = ({ + onDelete, +}) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/component/admin/users/InactiveUsersList/InactiveUsersList.tsx b/frontend/src/component/admin/users/InactiveUsersList/InactiveUsersList.tsx new file mode 100644 index 0000000000..15604cfe0a --- /dev/null +++ b/frontend/src/component/admin/users/InactiveUsersList/InactiveUsersList.tsx @@ -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(); + const [showDelInactiveDialog, setShowDelInactiveDialog] = useState(false); + const closeDelDialog = () => { + setDelDialog(false); + setDelUser(undefined); + }; + + const openDelDialog = + (user: IInactiveUser) => (e: React.SyntheticEvent) => { + e.preventDefault(); + setDelDialog(true); + setDelUser(user); + }; + + const openDelInactiveDialog = (e: React.SyntheticEvent) => { + 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) => ( + + ), + 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; + }) => , + 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) => ( + `Last login: ${date}`} + /> + ), + maxWidth: 150, + }, + { + id: 'pat-last-login', + Header: 'PAT last used', + accessor: (row: any) => row.patSeenAt || '', + Cell: ({ row: { original: user } }: any) => ( + `Last used: ${date}`} + /> + ), + maxWidth: 150, + }, + { + id: 'Actions', + Header: 'Actions', + align: 'center', + Cell: ({ row: { original: user } }: any) => ( + + ), + 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 ( + + + + } + /> + } + > + + View all users + + + + No inactive users found. + + } + /> + onDeleteUser(delUser!.id)} + userApiErrors={userApiErrors} + /> + } + /> + + + ); +}; diff --git a/frontend/src/component/admin/users/Users.styles.ts b/frontend/src/component/admin/users/Users.styles.ts new file mode 100644 index 0000000000..ddaea8ddbd --- /dev/null +++ b/frontend/src/component/admin/users/Users.styles.ts @@ -0,0 +1,6 @@ +import { styled } from '@mui/material'; + +export const StyledUsersLinkDiv = styled('div')(({ theme }) => ({ + marginTop: theme.spacing(-2), + paddingBottom: theme.spacing(2), +})); diff --git a/frontend/src/component/admin/users/UsersAdmin.tsx b/frontend/src/component/admin/users/UsersAdmin.tsx index ff7b8b12c7..c0664e48ca 100644 --- a/frontend/src/component/admin/users/UsersAdmin.tsx +++ b/frontend/src/component/admin/users/UsersAdmin.tsx @@ -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 = () => (
@@ -20,6 +21,7 @@ export const UsersAdmin = () => ( } /> } /> + } /> } /> diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx index 650e7bd9cb..62f8a80351 100644 --- a/frontend/src/component/admin/users/UsersList/UsersList.tsx +++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx @@ -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 = () => { } > + + View inactive users + { + 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 }; +}; diff --git a/frontend/src/hooks/api/getters/useInactiveUsers/useInactiveUsers.ts b/frontend/src/hooks/api/getters/useInactiveUsers/useInactiveUsers.ts new file mode 100644 index 0000000000..12ba5b9c92 --- /dev/null +++ b/frontend/src/hooks/api/getters/useInactiveUsers/useInactiveUsers.ts @@ -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()); +}; diff --git a/frontend/src/themes/themeTypes.ts b/frontend/src/themes/themeTypes.ts index db7412ea2c..3e221a458f 100644 --- a/frontend/src/themes/themeTypes.ts +++ b/frontend/src/themes/themeTypes.ts @@ -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 */ diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 66a9379086..199b92562b 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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), }; }; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 2cb414b925..003ff62332 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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. diff --git a/src/lib/openapi/spec/ids-schema.ts b/src/lib/openapi/spec/ids-schema.ts new file mode 100644 index 0000000000..be5a60415d --- /dev/null +++ b/src/lib/openapi/spec/ids-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/inactive-user-schema.ts b/src/lib/openapi/spec/inactive-user-schema.ts new file mode 100644 index 0000000000..1e50283bd6 --- /dev/null +++ b/src/lib/openapi/spec/inactive-user-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/inactive-users-schema.ts b/src/lib/openapi/spec/inactive-users-schema.ts new file mode 100644 index 0000000000..c9a14ece1a --- /dev/null +++ b/src/lib/openapi/spec/inactive-users-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 86868c381d..a80a018cfd 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -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'; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 8af7949906..12fe75f270 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -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, diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 00cfd76479..b8fc9ca475 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -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, }; }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index e4fbbb26ea..34930cfed6 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -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; clientFeatureToggleService: ClientFeatureToggleService; featureSearchService: FeatureSearchService; + inactiveUsersService: InactiveUsersService; } diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 1ea39e91cc..430e0cc7fb 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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 { diff --git a/src/lib/users/inactive/createInactiveUsersService.ts b/src/lib/users/inactive/createInactiveUsersService.ts new file mode 100644 index 0000000000..e0e4fb9a76 --- /dev/null +++ b/src/lib/users/inactive/createInactiveUsersService.ts @@ -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, + userService: UserService, +): InactiveUsersService => { + const fakeStore = new FakeInactiveUsersStore(); + return new InactiveUsersService( + { inactiveUsersStore: fakeStore }, + { getLogger }, + { userService }, + ); +}; diff --git a/src/lib/users/inactive/fakes/fake-inactive-users-store.ts b/src/lib/users/inactive/fakes/fake-inactive-users-store.ts new file mode 100644 index 0000000000..ef6d9ea52f --- /dev/null +++ b/src/lib/users/inactive/fakes/fake-inactive-users-store.ts @@ -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 { + 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(), + }; + }), + ); + } +} diff --git a/src/lib/users/inactive/inactive-users-controller.ts b/src/lib/users/inactive/inactive-users-controller.ts new file mode 100644 index 0000000000..93e9b4dd41 --- /dev/null +++ b/src/lib/users/inactive/inactive-users-controller.ts @@ -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, + ) { + 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, + ): Promise { + 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, + res: Response, + ): Promise { + await this.inactiveUsersService.deleteInactiveUsers( + req.user, + req.body.ids.filter((inactiveUser) => inactiveUser !== req.user.id), + ); + res.status(200).send(); + } +} diff --git a/src/lib/users/inactive/inactive-users-service.ts b/src/lib/users/inactive/inactive-users-service.ts new file mode 100644 index 0000000000..bb210b620c --- /dev/null +++ b/src/lib/users/inactive/inactive-users-service.ts @@ -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, + { getLogger }: Pick, + services: { + userService: UserService; + }, + ) { + this.logger = getLogger('services/client-feature-toggle-service.ts'); + this.inactiveUsersStore = inactiveUsersStore; + this.userService = services.userService; + } + + async getInactiveUsers(): Promise { + 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 { + this.logger.info('Deleting inactive users'); + for (const userid of userIds) { + if (calledByUser.id !== userid) { + await this.userService.deleteUser(userid, calledByUser); + } + } + } +} diff --git a/src/lib/users/inactive/inactive-users-store.ts b/src/lib/users/inactive/inactive-users-store.ts new file mode 100644 index 0000000000..2f6a108562 --- /dev/null +++ b/src/lib/users/inactive/inactive-users-store.ts @@ -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 { + const stopTimer = this.timer('get_inactive_users'); + const inactiveUsers = await this.db(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; + } +} diff --git a/src/lib/users/inactive/types/inactive-users-store-type.ts b/src/lib/users/inactive/types/inactive-users-store-type.ts new file mode 100644 index 0000000000..ccfb239e6b --- /dev/null +++ b/src/lib/users/inactive/types/inactive-users-store-type.ts @@ -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; +} diff --git a/src/lib/users/index.ts b/src/lib/users/index.ts new file mode 100644 index 0000000000..a5245d3761 --- /dev/null +++ b/src/lib/users/index.ts @@ -0,0 +1,2 @@ +export * from './inactive/createInactiveUsersService'; +export * from './inactive/inactive-users-store'; diff --git a/src/test/e2e/users/inactive/__snapshots__/inactive-users-service.test.ts.snap b/src/test/e2e/users/inactive/__snapshots__/inactive-users-service.test.ts.snap new file mode 100644 index 0000000000..afcc405177 --- /dev/null +++ b/src/test/e2e/users/inactive/__snapshots__/inactive-users-service.test.ts.snap @@ -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"`; diff --git a/src/test/e2e/users/inactive/inactive-users-service.test.ts b/src/test/e2e/users/inactive/inactive-users-service.test.ts new file mode 100644 index 0000000000..49a7b452dd --- /dev/null +++ b/src/test/e2e/users/inactive/inactive-users-service.test.ts @@ -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(); + }); + }); +}); diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index f639fee3e3..8e35cb8a15 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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(), }; };