diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx index 8910ded59c..e817bd4ce1 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx @@ -150,6 +150,11 @@ export const ProjectAccessAssign = ({ user.id === id && type === ENTITY_TYPE.USER ) ) + .sort((a: IUser, b: IUser) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + }) .map((user: IUser) => ({ id: user.id, entity: user, @@ -165,6 +170,11 @@ export const ProjectAccessAssign = ({ type === ENTITY_TYPE.SERVICE_ACCOUNT ) ) + .sort((a: IServiceAccount, b: IServiceAccount) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + }) .map((serviceAccount: IServiceAccount) => ({ id: serviceAccount.id, entity: serviceAccount, diff --git a/frontend/src/hooks/api/getters/useAccess/useAccess.ts b/frontend/src/hooks/api/getters/useAccess/useAccess.ts index 669f4a220a..4a3d354db9 100644 --- a/frontend/src/hooks/api/getters/useAccess/useAccess.ts +++ b/frontend/src/hooks/api/getters/useAccess/useAccess.ts @@ -22,7 +22,7 @@ export const useAccess = (): IUseAccessOutput => { return { users: (data?.users as IUser[])?.filter( - ({ accountType }) => accountType === 'User' + ({ accountType }) => !accountType || accountType === 'User' ), serviceAccounts: (data?.users as IServiceAccount[])?.filter( ({ accountType }) => accountType === 'Service Account' diff --git a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts index 7d19d573a0..c33e0323f6 100644 --- a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts +++ b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts @@ -66,7 +66,7 @@ const useProjectAccess = ( return formatAccessData({ roles: data.roles, users: (data.users as IUser[]).filter( - ({ accountType }) => accountType === 'User' + ({ accountType }) => !accountType || accountType === 'User' ), serviceAccounts: (data.users as IUser[]).filter( ({ accountType }) => accountType === 'Service Account' diff --git a/src/lib/db/account-store.ts b/src/lib/db/account-store.ts new file mode 100644 index 0000000000..39ae0c4da6 --- /dev/null +++ b/src/lib/db/account-store.ts @@ -0,0 +1,172 @@ +import { Knex } from 'knex'; +import { Logger, LogProvider } from '../logger'; +import User from '../types/user'; + +import NotFoundError from '../error/notfound-error'; +import { IUserLookup } from '../types/stores/user-store'; +import { IAccountStore } from '../types'; + +const TABLE = 'users'; + +const USER_COLUMNS_PUBLIC = [ + 'id', + 'name', + 'username', + 'email', + 'image_url', + 'seen_at', + 'is_service', +]; + +const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at']; + +const emptify = (value) => { + if (!value) { + return undefined; + } + return value; +}; + +const safeToLower = (s?: string) => (s ? s.toLowerCase() : s); + +const rowToUser = (row) => { + if (!row) { + throw new NotFoundError('No user found'); + } + return new User({ + id: row.id, + name: emptify(row.name), + username: emptify(row.username), + email: emptify(row.email), + imageUrl: emptify(row.image_url), + loginAttempts: row.login_attempts, + seenAt: row.seen_at, + createdAt: row.created_at, + isService: row.is_service, + }); +}; + +export class AccountStore implements IAccountStore { + private db: Knex; + + private logger: Logger; + + constructor(db: Knex, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('account-store.ts'); + } + + buildSelectAccount(q: IUserLookup): any { + const query = this.activeAccounts(); + if (q.id) { + return query.where('id', q.id); + } + if (q.email) { + return query.where('email', safeToLower(q.email)); + } + if (q.username) { + return query.where('username', q.username); + } + throw new Error('Can only find users with id, username or email.'); + } + + activeAccounts(): any { + return this.db(TABLE).where({ + deleted_at: null, + }); + } + + async hasAccount(idQuery: IUserLookup): Promise { + const query = this.buildSelectAccount(idQuery); + const item = await query.first('id'); + return item ? item.id : undefined; + } + + async getAll(): Promise { + const users = await this.activeAccounts().select(USER_COLUMNS); + return users.map(rowToUser); + } + + async search(query: string): Promise { + const users = await this.activeAccounts() + .select(USER_COLUMNS_PUBLIC) + .where('name', 'ILIKE', `%${query}%`) + .orWhere('username', 'ILIKE', `${query}%`) + .orWhere('email', 'ILIKE', `${query}%`); + return users.map(rowToUser); + } + + async getAllWithId(userIdList: number[]): Promise { + const users = await this.activeAccounts() + .select(USER_COLUMNS_PUBLIC) + .whereIn('id', userIdList); + return users.map(rowToUser); + } + + async getByQuery(idQuery: IUserLookup): Promise { + const row = await this.buildSelectAccount(idQuery).first(USER_COLUMNS); + return rowToUser(row); + } + + async delete(id: number): Promise { + return this.activeAccounts() + .where({ id }) + .update({ + deleted_at: new Date(), + email: null, + username: null, + name: this.db.raw('name || ?', '(Deleted)'), + }); + } + + async deleteAll(): Promise { + await this.activeAccounts().del(); + } + + async count(): Promise { + return this.activeAccounts() + .count('*') + .then((res) => Number(res[0].count)); + } + + destroy(): void {} + + async exists(id: number): Promise { + const result = await this.db.raw( + `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ? and deleted_at = null) AS present`, + [id], + ); + const { present } = result.rows[0]; + return present; + } + + async get(id: number): Promise { + const row = await this.activeAccounts().where({ id }).first(); + return rowToUser(row); + } + + async getAccountByPersonalAccessToken(secret: string): Promise { + const row = await this.activeAccounts() + .select(USER_COLUMNS.map((column) => `${TABLE}.${column}`)) + .leftJoin( + 'personal_access_tokens', + 'personal_access_tokens.user_id', + `${TABLE}.id`, + ) + .where('secret', secret) + .andWhere('expires_at', '>', 'now()') + .first(); + return rowToUser(row); + } + + async markSeenAt(secrets: string[]): Promise { + const now = new Date(); + try { + await this.db('personal_access_tokens') + .whereIn('secret', secrets) + .update({ seen_at: now }); + } catch (err) { + this.logger.error('Could not update lastSeen, error: ', err); + } + } +} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 4cff815106..659bd0f300 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -34,6 +34,7 @@ import PatStore from './pat-store'; import { PublicSignupTokenStore } from './public-signup-token-store'; import { FavoriteFeaturesStore } from './favorite-features-store'; import { FavoriteProjectsStore } from './favorite-projects-store'; +import { AccountStore } from './account-store'; export const createStores = ( config: IUnleashConfig, @@ -57,6 +58,7 @@ export const createStores = ( contextFieldStore: new ContextFieldStore(db, getLogger), settingStore: new SettingStore(db, getLogger), userStore: new UserStore(db, getLogger), + accountStore: new AccountStore(db, getLogger), projectStore: new ProjectStore( db, eventBus, diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index bfd2e81903..09bc117740 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -126,11 +126,6 @@ class UserStore implements IUserStore { } async getAll(): Promise { - const users = await this.activeAll().select(USER_COLUMNS); - return users.map(rowToUser); - } - - async getAllUsers(): Promise { const users = await this.activeUsers().select(USER_COLUMNS); return users.map(rowToUser); } @@ -145,7 +140,7 @@ class UserStore implements IUserStore { } async getAllWithId(userIdList: number[]): Promise { - const users = await this.activeAll() + const users = await this.activeUsers() .select(USER_COLUMNS_PUBLIC) .whereIn('id', userIdList); return users.map(rowToUser); @@ -221,31 +216,6 @@ class UserStore implements IUserStore { const row = await this.activeUsers().where({ id }).first(); return rowToUser(row); } - - async getUserByPersonalAccessToken(secret: string): Promise { - const row = await this.activeAll() - .select(USER_COLUMNS.map((column) => `${TABLE}.${column}`)) - .leftJoin( - 'personal_access_tokens', - 'personal_access_tokens.user_id', - `${TABLE}.id`, - ) - .where('secret', secret) - .andWhere('expires_at', '>', 'now()') - .first(); - return rowToUser(row); - } - - async markSeenAt(secrets: string[]): Promise { - const now = new Date(); - try { - await this.db('personal_access_tokens') - .whereIn('secret', secrets) - .update({ seen_at: now }); - } catch (err) { - this.logger.error('Could not update lastSeen, error: ', err); - } - } } module.exports = UserStore; diff --git a/src/lib/middleware/pat-middleware.test.ts b/src/lib/middleware/pat-middleware.test.ts index aea580e1bf..6a2b6c723c 100644 --- a/src/lib/middleware/pat-middleware.test.ts +++ b/src/lib/middleware/pat-middleware.test.ts @@ -14,11 +14,11 @@ beforeEach(() => { }); test('should not set user if unknown token', async () => { - const userService = { - getUserByPersonalAccessToken: jest.fn(), + const accountService = { + getAccountByPersonalAccessToken: jest.fn(), }; - const func = patMiddleware(config, { userService }); + const func = patMiddleware(config, { accountService }); const cb = jest.fn(); @@ -35,11 +35,11 @@ test('should not set user if unknown token', async () => { }); test('should not set user if token wrong format', async () => { - const userService = { - getUserByPersonalAccessToken: jest.fn(), + const accountService = { + getAccountByPersonalAccessToken: jest.fn(), }; - const func = patMiddleware(config, { userService }); + const func = patMiddleware(config, { accountService }); const cb = jest.fn(); @@ -50,7 +50,9 @@ test('should not set user if token wrong format', async () => { await func(req, undefined, cb); - expect(userService.getUserByPersonalAccessToken).not.toHaveBeenCalled(); + expect( + accountService.getAccountByPersonalAccessToken, + ).not.toHaveBeenCalled(); expect(cb).toHaveBeenCalled(); expect(req.header).toHaveBeenCalled(); expect(req.user).toBeFalsy(); @@ -61,11 +63,11 @@ test('should add user if known token', async () => { id: 44, username: 'my-user', }); - const userService = { - getUserByPersonalAccessToken: jest.fn().mockReturnValue(apiUser), + const accountService = { + getAccountByPersonalAccessToken: jest.fn().mockReturnValue(apiUser), }; - const func = patMiddleware(config, { userService }); + const func = patMiddleware(config, { accountService }); const cb = jest.fn(); @@ -82,15 +84,15 @@ test('should add user if known token', async () => { expect(req.user).toBe(apiUser); }); -test('should call next if userService throws exception', async () => { +test('should call next if accountService throws exception', async () => { getLogger.setMuteError(true); - const userService = { - getUserByPersonalAccessToken: () => { + const accountService = { + getAccountByPersonalAccessToken: () => { throw new Error('Error occurred'); }, }; - const func = patMiddleware(config, { userService }); + const func = patMiddleware(config, { accountService }); const cb = jest.fn(); diff --git a/src/lib/middleware/pat-middleware.ts b/src/lib/middleware/pat-middleware.ts index 933d159f3a..947b743435 100644 --- a/src/lib/middleware/pat-middleware.ts +++ b/src/lib/middleware/pat-middleware.ts @@ -4,7 +4,7 @@ import { IAuthRequest } from '../routes/unleash-types'; /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ const patMiddleware = ( { getLogger }: Pick, - { userService }: any, + { accountService }: any, ): any => { const logger = getLogger('/middleware/pat-middleware.ts'); logger.debug('Enabling PAT middleware'); @@ -13,11 +13,12 @@ const patMiddleware = ( try { const apiToken = req.header('authorization'); if (apiToken?.startsWith('user:')) { - const user = await userService.getUserByPersonalAccessToken( - apiToken, - ); + const user = + await accountService.getAccountByPersonalAccessToken( + apiToken, + ); req.user = user; - userService.addPATSeen(apiToken); + accountService.addPATSeen(apiToken); } } catch (error) { logger.error(error); diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 87b08b9024..22c171ff85 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import Controller from '../controller'; import { ADMIN, NONE } from '../../types/permissions'; import UserService from '../../services/user-service'; +import { AccountService } from '../../services/account-service'; import { AccessService } from '../../services/access-service'; import { Logger } from '../../logger'; import { IUnleashConfig, IUnleashServices } from '../../types'; @@ -44,6 +45,8 @@ export default class UserAdminController extends Controller { private userService: UserService; + private accountService: AccountService; + private accessService: AccessService; private readonly logger: Logger; @@ -64,6 +67,7 @@ export default class UserAdminController extends Controller { config: IUnleashConfig, { userService, + accountService, accessService, emailService, resetTokenService, @@ -73,6 +77,7 @@ export default class UserAdminController extends Controller { }: Pick< IUnleashServices, | 'userService' + | 'accountService' | 'accessService' | 'emailService' | 'resetTokenService' @@ -83,6 +88,7 @@ export default class UserAdminController extends Controller { ) { super(config); this.userService = userService; + this.accountService = accountService; this.accessService = accessService; this.emailService = emailService; this.resetTokenService = resetTokenService; @@ -262,7 +268,7 @@ export default class UserAdminController extends Controller { } async getUsers(req: Request, res: Response): Promise { - const users = await this.userService.getAllUsers(); + const users = await this.userService.getAll(); const rootRoles = await this.accessService.getRootRoles(); const inviteLinks = await this.resetTokenService.getActiveInvitations(); @@ -310,7 +316,7 @@ export default class UserAdminController extends Controller { req: Request, res: Response, ): Promise { - let allUsers = await this.userService.getAll(); + let allUsers = await this.accountService.getAll(); let users = allUsers.map((u) => { return { id: u.id, diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index 4fac9f5370..27988dda81 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -9,9 +9,8 @@ import { IUserPermission, IUserRole, } from '../types/stores/access-store'; -import { IUserStore } from '../types/stores/user-store'; import { Logger } from '../logger'; -import { IUnleashStores } from '../types/stores'; +import { IAccountStore, IUnleashStores } from '../types/stores'; import { IAvailablePermissions, ICustomRole, @@ -67,7 +66,7 @@ const isProjectPermission = (permission) => PROJECT_ADMIN.includes(permission); export class AccessService { private store: IAccessStore; - private userStore: IUserStore; + private accountStore: IAccountStore; private roleStore: IRoleStore; @@ -80,18 +79,18 @@ export class AccessService { constructor( { accessStore, - userStore, + accountStore, roleStore, environmentStore, }: Pick< IUnleashStores, - 'accessStore' | 'userStore' | 'roleStore' | 'environmentStore' + 'accessStore' | 'accountStore' | 'roleStore' | 'environmentStore' >, { getLogger }: { getLogger: Function }, groupService: GroupService, ) { this.store = accessStore; - this.userStore = userStore; + this.accountStore = accountStore; this.roleStore = roleStore; this.groupService = groupService; this.environmentStore = environmentStore; @@ -363,7 +362,7 @@ export class AccessService { async getUsersForRole(roleId: number): Promise { const userIdList = await this.store.getUserIdsForRole(roleId); if (userIdList.length > 0) { - return this.userStore.getAllWithId(userIdList); + return this.accountStore.getAllWithId(userIdList); } return []; } @@ -378,7 +377,7 @@ export class AccessService { ); if (userRoleList.length > 0) { const userIdList = userRoleList.map((u) => u.userId); - const users = await this.userStore.getAllWithId(userIdList); + const users = await this.accountStore.getAllWithId(userIdList); return users.map((user) => { const role = userRoleList.find((r) => r.userId == user.id); return { diff --git a/src/lib/services/account-service.ts b/src/lib/services/account-service.ts new file mode 100644 index 0000000000..8204493f99 --- /dev/null +++ b/src/lib/services/account-service.ts @@ -0,0 +1,76 @@ +import { Logger } from '../logger'; +import { IUser } from '../types/user'; +import { IUnleashConfig } from '../types/option'; +import { IAccountStore, IUnleashStores } from '../types/stores'; +import { minutesToMilliseconds } from 'date-fns'; +import { AccessService } from './access-service'; +import { RoleName } from '../types/model'; + +interface IUserWithRole extends IUser { + rootRole: number; +} + +export class AccountService { + private logger: Logger; + + private store: IAccountStore; + + private accessService: AccessService; + + private seenTimer: NodeJS.Timeout; + + private lastSeenSecrets: Set = new Set(); + + constructor( + stores: Pick, + { getLogger }: Pick, + services: { + accessService: AccessService; + }, + ) { + this.logger = getLogger('service/account-service.ts'); + this.store = stores.accountStore; + this.accessService = services.accessService; + this.updateLastSeen(); + } + + async getAll(): Promise { + const accounts = await this.store.getAll(); + const defaultRole = await this.accessService.getRootRole( + RoleName.VIEWER, + ); + const userRoles = await this.accessService.getRootRoleForAllUsers(); + const accountsWithRootRole = accounts.map((u) => { + const rootRole = userRoles.find((r) => r.userId === u.id); + const roleId = rootRole ? rootRole.roleId : defaultRole.id; + return { ...u, rootRole: roleId }; + }); + return accountsWithRootRole; + } + + async getAccountByPersonalAccessToken(secret: string): Promise { + return this.store.getAccountByPersonalAccessToken(secret); + } + + async updateLastSeen(): Promise { + if (this.lastSeenSecrets.size > 0) { + const toStore = [...this.lastSeenSecrets]; + this.lastSeenSecrets = new Set(); + await this.store.markSeenAt(toStore); + } + + this.seenTimer = setTimeout( + async () => this.updateLastSeen(), + minutesToMilliseconds(3), + ).unref(); + } + + addPATSeen(secret: string): void { + this.lastSeenSecrets.add(secret); + } + + destroy(): void { + clearTimeout(this.seenTimer); + this.seenTimer = null; + } +} diff --git a/src/lib/services/group-service.ts b/src/lib/services/group-service.ts index 2ad66a7487..d1e2600b5a 100644 --- a/src/lib/services/group-service.ts +++ b/src/lib/services/group-service.ts @@ -13,7 +13,7 @@ import BadDataError from '../error/bad-data-error'; import { GROUP_CREATED, GROUP_UPDATED } from '../types/events'; import { IEventStore } from '../types/stores/event-store'; import NameExistsError from '../error/name-exists-error'; -import { IUserStore } from '../types/stores/user-store'; +import { IAccountStore } from '../types/stores/account-store'; import { IUser } from '../types/user'; export class GroupService { @@ -21,18 +21,21 @@ export class GroupService { private eventStore: IEventStore; - private userStore: IUserStore; + private accountStore: IAccountStore; private logger: Logger; constructor( - stores: Pick, + stores: Pick< + IUnleashStores, + 'groupStore' | 'eventStore' | 'accountStore' + >, { getLogger }: Pick, ) { this.logger = getLogger('service/group-service.js'); this.groupStore = stores.groupStore; this.eventStore = stores.eventStore; - this.userStore = stores.userStore; + this.accountStore = stores.accountStore; } async getAll(): Promise { @@ -40,7 +43,7 @@ export class GroupService { const allGroupUsers = await this.groupStore.getAllUsersByGroups( groups.map((g) => g.id), ); - const users = await this.userStore.getAllWithId( + const users = await this.accountStore.getAllWithId( allGroupUsers.map((u) => u.userId), ); const groupProjects = await this.groupStore.getGroupProjects( @@ -72,7 +75,7 @@ export class GroupService { async getGroup(id: number): Promise { const group = await this.groupStore.get(id); const groupUsers = await this.groupStore.getAllUsersByGroups([id]); - const users = await this.userStore.getAllWithId( + const users = await this.accountStore.getAllWithId( groupUsers.map((u) => u.userId), ); return this.mapGroupWithUsers(group, groupUsers, users); @@ -156,7 +159,7 @@ export class GroupService { groups.map((g) => g.id), ); - const users = await this.userStore.getAllWithId( + const users = await this.accountStore.getAllWithId( groupUsers.map((u) => u.userId), ); return groups.map((group) => { diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index ef15fb68af..5eed27e691 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -42,6 +42,7 @@ import MaintenanceService from './maintenance-service'; import ExportImportService from './export-import-service'; import SchedulerService from './scheduler-service'; import { minutesToMilliseconds } from 'date-fns'; +import { AccountService } from './account-service'; export const createServices = ( stores: IUnleashStores, @@ -76,6 +77,9 @@ export const createServices = ( sessionService, settingService, }); + const accountService = new AccountService(stores, config, { + accessService, + }); const versionService = new VersionService(stores, config); const healthService = new HealthService(stores, config); const userFeedbackService = new UserFeedbackService(stores, config); @@ -159,6 +163,7 @@ export const createServices = ( return { accessService, + accountService, addonService, featureToggleService: featureToggleServiceV2, featureToggleServiceV2, diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 37e6b6c4b7..de1ffba846 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -16,7 +16,7 @@ import { ProjectGroupRemovedEvent, ProjectGroupUpdateRoleEvent, } from '../types/events'; -import { IUnleashStores, IUnleashConfig } from '../types'; +import { IUnleashStores, IUnleashConfig, IAccountStore } from '../types'; import { FeatureToggle, IProject, @@ -42,7 +42,6 @@ import IncompatibleProjectError from '../error/incompatible-project-error'; import { DEFAULT_PROJECT } from '../types/project'; import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store'; import ProjectWithoutOwnerError from '../error/project-without-owner-error'; -import { IUserStore } from 'lib/types/stores/user-store'; import { arraysHaveSameItems } from '../util/arraysHaveSameItems'; import { GroupService } from './group-service'; import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group'; @@ -79,7 +78,7 @@ export default class ProjectService { private tagStore: IFeatureTagStore; - private userStore: IUserStore; + private accountStore: IAccountStore; private favoritesService: FavoritesService; @@ -92,7 +91,7 @@ export default class ProjectService { environmentStore, featureEnvironmentStore, featureTagStore, - userStore, + accountStore, }: Pick< IUnleashStores, | 'projectStore' @@ -102,7 +101,7 @@ export default class ProjectService { | 'environmentStore' | 'featureEnvironmentStore' | 'featureTagStore' - | 'userStore' + | 'accountStore' >, config: IUnleashConfig, accessService: AccessService, @@ -120,7 +119,7 @@ export default class ProjectService { this.featureToggleService = featureToggleService; this.favoritesService = favoriteService; this.tagStore = featureTagStore; - this.userStore = userStore; + this.accountStore = accountStore; this.groupService = groupService; this.logger = config.getLogger('services/project-service.js'); } @@ -317,7 +316,7 @@ export default class ProjectService { const [roles, users] = await this.accessService.getProjectRoleAccess( projectId, ); - const user = await this.userStore.get(userId); + const user = await this.accountStore.get(userId); const role = roles.find((r) => r.id === roleId); if (!role) { @@ -359,7 +358,7 @@ export default class ProjectService { await this.accessService.removeUserFromRole(userId, role.id, projectId); - const user = await this.userStore.get(userId); + const user = await this.accountStore.get(userId); await this.eventStore.store( new ProjectUserRemovedEvent({ diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 86882d31f1..8619e38db6 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -28,7 +28,6 @@ import PasswordMismatch from '../error/password-mismatch'; import BadDataError from '../error/bad-data-error'; import { isDefined } from '../util/isDefined'; import { TokenUserSchema } from '../openapi/spec/token-user-schema'; -import { minutesToMilliseconds } from 'date-fns'; const systemUser = new User({ id: -1, username: 'system' }); @@ -79,10 +78,6 @@ class UserService { private passwordResetTimeouts: { [key: string]: NodeJS.Timeout } = {}; - private seenTimer: NodeJS.Timeout; - - private lastSeenSecrets: Set = new Set(); - constructor( stores: Pick, { @@ -108,7 +103,6 @@ class UserService { if (authentication && authentication.createAdminUser) { process.nextTick(() => this.initAdminUser()); } - this.updateLastSeen(); } validatePassword(password: string): boolean { @@ -161,20 +155,6 @@ class UserService { return usersWithRootRole; } - async getAllUsers(): Promise { - const users = await this.store.getAllUsers(); - const defaultRole = await this.accessService.getRootRole( - RoleName.VIEWER, - ); - const userRoles = await this.accessService.getRootRoleForAllUsers(); - const usersWithRootRole = users.map((u) => { - const rootRole = userRoles.find((r) => r.userId === u.id); - const roleId = rootRole ? rootRole.roleId : defaultRole.id; - return { ...u, rootRole: roleId }; - }); - return usersWithRootRole; - } - async getUser(id: number): Promise { const roles = await this.accessService.getUserRootRoles(id); const defaultRole = await this.accessService.getRootRole( @@ -442,32 +422,6 @@ class UserService { ); return resetLink; } - - async getUserByPersonalAccessToken(secret: string): Promise { - return this.store.getUserByPersonalAccessToken(secret); - } - - async updateLastSeen(): Promise { - if (this.lastSeenSecrets.size > 0) { - const toStore = [...this.lastSeenSecrets]; - this.lastSeenSecrets = new Set(); - await this.store.markSeenAt(toStore); - } - - this.seenTimer = setTimeout( - async () => this.updateLastSeen(), - minutesToMilliseconds(3), - ).unref(); - } - - addPATSeen(secret: string): void { - this.lastSeenSecrets.add(secret); - } - - destroy(): void { - clearTimeout(this.seenTimer); - this.seenTimer = null; - } } module.exports = UserService; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index d35f8640bf..c8d595ad80 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -38,9 +38,11 @@ import { InstanceStatsService } from '../services/instance-stats-service'; import { FavoritesService } from '../services'; import MaintenanceService from '../services/maintenance-service'; import ExportImportService from 'lib/services/export-import-service'; +import { AccountService } from '../services/account-service'; export interface IUnleashServices { accessService: AccessService; + accountService: AccountService; addonService: AddonService; apiTokenService: ApiTokenService; clientInstanceService: ClientInstanceService; diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 88d5ba4376..d917199de0 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -30,9 +30,11 @@ import { IPatStore } from './stores/pat-store'; import { IPublicSignupTokenStore } from './stores/public-signup-token-store'; import { IFavoriteFeaturesStore } from './stores/favorite-features'; import { IFavoriteProjectsStore } from './stores/favorite-projects'; +import { IAccountStore } from './stores/account-store'; export interface IUnleashStores { accessStore: IAccessStore; + accountStore: IAccountStore; addonStore: IAddonStore; apiTokenStore: IApiTokenStore; clientApplicationsStore: IClientApplicationsStore; @@ -68,6 +70,7 @@ export interface IUnleashStores { export { IAccessStore, + IAccountStore, IAddonStore, IApiTokenStore, IClientApplicationsStore, diff --git a/src/lib/types/stores/account-store.ts b/src/lib/types/stores/account-store.ts new file mode 100644 index 0000000000..4501109915 --- /dev/null +++ b/src/lib/types/stores/account-store.ts @@ -0,0 +1,18 @@ +import { IUser } from '../user'; +import { Store } from './store'; + +export interface IUserLookup { + id?: number; + username?: string; + email?: string; +} + +export interface IAccountStore extends Store { + hasAccount(idQuery: IUserLookup): Promise; + search(query: string): Promise; + getAllWithId(userIdList: number[]): Promise; + getByQuery(idQuery: IUserLookup): Promise; + count(): Promise; + getAccountByPersonalAccessToken(secret: string): Promise; + markSeenAt(secrets: string[]): Promise; +} diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index 6dce85ff9c..32b3beaa83 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -25,7 +25,6 @@ export interface IUserStore extends Store { upsert(user: ICreateUser): Promise; hasUser(idQuery: IUserLookup): Promise; search(query: string): Promise; - getAllUsers(): Promise; getAllWithId(userIdList: number[]): Promise; getByQuery(idQuery: IUserLookup): Promise; getPasswordHash(userId: number): Promise; @@ -33,6 +32,4 @@ export interface IUserStore extends Store { incLoginAttempts(user: IUser): Promise; successfullyLogin(user: IUser): Promise; count(): Promise; - getUserByPersonalAccessToken(secret: string): Promise; - markSeenAt(secrets: string[]): Promise; } diff --git a/src/test/fixtures/access-service-mock.ts b/src/test/fixtures/access-service-mock.ts index 4e9ee82013..0583410fc8 100644 --- a/src/test/fixtures/access-service-mock.ts +++ b/src/test/fixtures/access-service-mock.ts @@ -16,7 +16,7 @@ class AccessServiceMock extends AccessService { super( { accessStore: undefined, - userStore: undefined, + accountStore: undefined, roleStore: undefined, environmentStore: undefined, }, diff --git a/src/test/fixtures/fake-account-store.ts b/src/test/fixtures/fake-account-store.ts new file mode 100644 index 0000000000..284a797c0c --- /dev/null +++ b/src/test/fixtures/fake-account-store.ts @@ -0,0 +1,96 @@ +import { IUser } from '../../lib/types/user'; +import { + // ICreateUser, + IUserLookup, + IAccountStore, +} from '../../lib/types/stores/account-store'; + +export class FakeAccountStore implements IAccountStore { + data: IUser[]; + + idSeq: number; + + constructor() { + this.idSeq = 1; + this.data = []; + } + + async hasAccount({ + id, + username, + email, + }: IUserLookup): Promise { + const user = this.data.find((i) => { + if (id && i.id === id) return true; + if (username && i.username === username) return true; + if (email && i.email === email) return true; + return false; + }); + if (user) { + return user.id; + } + return undefined; + } + + destroy(): void {} + + async exists(key: number): Promise { + return this.data.some((u) => u.id === key); + } + + async count(): Promise { + return this.data.length; + } + + async get(key: number): Promise { + return this.data.find((u) => u.id === key); + } + + async getByQuery({ id, username, email }: IUserLookup): Promise { + const user = this.data.find((i) => { + if (i.id && i.id === id) return true; + if (i.username && i.username === username) return true; + if (i.email && i.email === email) return true; + return false; + }); + if (user) { + return user; + } + throw new Error('Could not find user'); + } + + async getAll(): Promise { + return Promise.resolve(this.data); + } + + async delete(id: number): Promise { + this.data = this.data.filter((item) => item.id !== id); + return Promise.resolve(); + } + + buildSelectUser(): any { + throw new Error('Not implemented'); + } + + async search(): Promise { + throw new Error('Not implemented'); + } + + async getAllWithId(): Promise { + throw new Error('Not implemented'); + } + + deleteAll(): Promise { + return Promise.resolve(undefined); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAccountByPersonalAccessToken(secret: string): Promise { + return Promise.resolve(undefined); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async markSeenAt(secrets: string[]): Promise { + throw new Error('Not implemented'); + } +} diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 1657130958..8bde38c8c6 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -31,6 +31,7 @@ import FakePatStore from './fake-pat-store'; import FakePublicSignupStore from './fake-public-signup-store'; import FakeFavoriteFeaturesStore from './fake-favorite-features-store'; import FakeFavoriteProjectsStore from './fake-favorite-projects-store'; +import { FakeAccountStore } from './fake-account-store'; const createStores: () => IUnleashStores = () => { const db = { @@ -56,6 +57,7 @@ const createStores: () => IUnleashStores = () => { projectStore: new FakeProjectStore(), userStore: new FakeUserStore(), accessStore: new FakeAccessStore(), + accountStore: new FakeAccountStore(), userFeedbackStore: new FakeUserFeedbackStore(), featureStrategiesStore: new FakeFeatureStrategiesStore(), featureTagStore: new FakeFeatureTagStore(),