diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx index be42212e2e..bcf92d49ac 100644 --- a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx +++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx @@ -7,6 +7,8 @@ import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import { IGroupUser } from 'interfaces/group'; import { UG_USERS_ID } from 'utils/testIds'; import { caseInsensitiveSearch } from 'utils/search'; +import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; +import { IServiceAccount } from 'interfaces/service-account'; const StyledOption = styled('div')(({ theme }) => ({ display: 'flex', @@ -44,7 +46,11 @@ const renderOption = ( /> {option.name || option.username} - {option.email} + + {option.name && option.username + ? option.username + : option.email} + ); @@ -57,6 +63,10 @@ const renderTags = (value: IGroupUser[]) => ( ); +type UserOption = IUser & { + type: string; +}; + interface IGroupFormUsersSelectProps { users: IGroupUser[]; setUsers: React.Dispatch>; @@ -67,6 +77,27 @@ export const GroupFormUsersSelect: VFC = ({ setUsers, }) => { const { users: usersAll } = useUsers(); + const { serviceAccounts } = useServiceAccounts(); + + const options = [ + ...usersAll + .map((user: IUser) => ({ ...user, type: 'USERS' })) + .sort((a: IUser, b: IUser) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + }), + ...serviceAccounts + .map((serviceAccount: IServiceAccount) => ({ + ...serviceAccount, + type: 'SERVICE ACCOUNTS', + })) + .sort((a, b) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + }), + ]; return ( @@ -77,7 +108,7 @@ export const GroupFormUsersSelect: VFC = ({ limitTags={1} openOnFocus disableCloseOnSelect - value={users} + value={users as UserOption[]} onChange={(event, newValue, reason) => { if ( event.type === 'keydown' && @@ -88,13 +119,10 @@ export const GroupFormUsersSelect: VFC = ({ } setUsers(newValue); }} - options={[...usersAll].sort((a, b) => { - const aName = a.name || a.username || ''; - const bName = b.name || b.username || ''; - return aName.localeCompare(bName); - })} + groupBy={option => option.type} + options={options} renderOption={(props, option, { selected }) => - renderOption(props, option as IUser, selected) + renderOption(props, option as UserOption, selected) } filterOptions={(options, { inputValue }) => options.filter( @@ -105,7 +133,7 @@ export const GroupFormUsersSelect: VFC = ({ ) } isOptionEqualToValue={(option, value) => option.id === value.id} - getOptionLabel={(option: IUser) => + getOptionLabel={(option: UserOption) => option.email || option.name || option.username || '' } renderInput={params => ( diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx index 1c7dea600b..8910ded59c 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx @@ -34,6 +34,7 @@ import { PA_USERS_GROUPS_TITLE_ID, } from 'utils/testIds'; import { caseInsensitiveSearch } from 'utils/search'; +import { IServiceAccount } from 'interfaces/service-account'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -99,6 +100,7 @@ interface IProjectAccessAssignProps { selected?: IProjectAccess; accesses: IProjectAccess[]; users: IUser[]; + serviceAccounts: IServiceAccount[]; groups: IGroup[]; roles: IProjectRole[]; } @@ -107,6 +109,7 @@ export const ProjectAccessAssign = ({ selected, accesses, users, + serviceAccounts, groups, roles, }: IProjectAccessAssignProps) => { @@ -152,6 +155,21 @@ export const ProjectAccessAssign = ({ entity: user, type: ENTITY_TYPE.USER, })), + ...serviceAccounts + .filter( + (serviceAccount: IServiceAccount) => + edit || + !accesses.some( + ({ entity: { id }, type }) => + serviceAccount.id === id && + type === ENTITY_TYPE.SERVICE_ACCOUNT + ) + ) + .map((serviceAccount: IServiceAccount) => ({ + id: serviceAccount.id, + entity: serviceAccount, + type: ENTITY_TYPE.SERVICE_ACCOUNT, + })), ]; const [selectedOptions, setSelectedOptions] = useState( @@ -167,7 +185,11 @@ export const ProjectAccessAssign = ({ const payload = { users: selectedOptions - ?.filter(({ type }) => type === ENTITY_TYPE.USER) + ?.filter( + ({ type }) => + type === ENTITY_TYPE.USER || + type === ENTITY_TYPE.SERVICE_ACCOUNT + ) .map(({ id }) => ({ id })), groups: selectedOptions ?.filter(({ type }) => type === ENTITY_TYPE.GROUP) @@ -182,7 +204,10 @@ export const ProjectAccessAssign = ({ try { if (!edit) { await addAccessToProject(projectId, role.id, payload); - } else if (selected?.type === ENTITY_TYPE.USER) { + } else if ( + selected?.type === ENTITY_TYPE.USER || + selected?.type === ENTITY_TYPE.SERVICE_ACCOUNT + ) { await changeUserRole(projectId, role.id, selected.entity.id); } else if (selected?.type === ENTITY_TYPE.GROUP) { await changeGroupRole(projectId, role.id, selected.entity.id); @@ -205,7 +230,10 @@ export const ProjectAccessAssign = ({ return `curl --location --request ${edit ? 'PUT' : 'POST'} '${ uiConfig.unleashUrl }/api/admin/projects/${projectId}/${ - selected?.type === ENTITY_TYPE.USER ? 'users' : 'groups' + selected?.type === ENTITY_TYPE.USER || + selected?.type === ENTITY_TYPE.SERVICE_ACCOUNT + ? 'users' + : 'groups' }/${selected?.entity.id}/roles/${role?.id}' \\ --header 'Authorization: INSERT_API_KEY'`; } @@ -250,7 +278,11 @@ export const ProjectAccessAssign = ({ {optionUser?.name || optionUser?.username} - {optionUser?.email} + + {optionUser?.name && optionUser?.username + ? optionUser?.username + : optionUser?.email} + } /> @@ -321,7 +353,11 @@ export const ProjectAccessAssign = ({ renderOption(props, option, selected) } getOptionLabel={(option: IAccessOption) => { - if (option.type === ENTITY_TYPE.USER) { + if ( + option.type === ENTITY_TYPE.USER || + option.type === + ENTITY_TYPE.SERVICE_ACCOUNT + ) { const optionUser = option.entity as IUser; return ( @@ -336,7 +372,11 @@ export const ProjectAccessAssign = ({ }} filterOptions={(options, { inputValue }) => options.filter((option: IAccessOption) => { - if (option.type === ENTITY_TYPE.USER) { + if ( + option.type === ENTITY_TYPE.USER || + option.type === + ENTITY_TYPE.SERVICE_ACCOUNT + ) { const optionUser = option.entity as IUser; return ( diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx index 72162d7a60..90b1a86f8b 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx @@ -7,9 +7,9 @@ export const ProjectAccessCreate = () => { const projectId = useRequiredPathParam('projectId'); const { access } = useProjectAccess(projectId); - const { users, groups } = useAccess(); + const { users, serviceAccounts, groups } = useAccess(); - if (!access || !users || !groups) { + if (!access || !users || !serviceAccounts || !groups) { return null; } @@ -17,6 +17,7 @@ export const ProjectAccessCreate = () => { diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx index 17508ebcb1..7974e647c3 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx @@ -10,9 +10,9 @@ export const ProjectAccessEditGroup = () => { const groupId = useRequiredPathParam('groupId'); const { access } = useProjectAccess(projectId); - const { users, groups } = useAccess(); + const { users, serviceAccounts, groups } = useAccess(); - if (!access || !users || !groups) { + if (!access || !users || !serviceAccounts || !groups) { return null; } @@ -26,6 +26,7 @@ export const ProjectAccessEditGroup = () => { accesses={access.rows} selected={group} users={users} + serviceAccounts={serviceAccounts} groups={groups} roles={access.roles} /> diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx index 3c8897195b..7c3f9b7d58 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx @@ -10,14 +10,15 @@ export const ProjectAccessEditUser = () => { const userId = useRequiredPathParam('userId'); const { access } = useProjectAccess(projectId); - const { users, groups } = useAccess(); + const { users, serviceAccounts, groups } = useAccess(); - if (!access || !users || !groups) { + if (!access || !users || !serviceAccounts || !groups) { return null; } const user = access.rows.find( - row => row.entity.id === Number(userId) && row.type === ENTITY_TYPE.USER + row => + row.entity.id === Number(userId) && row.type !== ENTITY_TYPE.GROUP ); return ( @@ -25,6 +26,7 @@ export const ProjectAccessEditUser = () => { accesses={access.rows} selected={user} users={users} + serviceAccounts={serviceAccounts} groups={groups} roles={access.roles} /> diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx index 39f3581914..182e5c2bb4 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx @@ -151,7 +151,7 @@ export const ProjectAccessTable: VFC = () => { id: 'username', Header: 'Username', accessor: (row: IProjectAccess) => { - if (row.type === ENTITY_TYPE.USER) { + if (row.type !== ENTITY_TYPE.GROUP) { const userRow = row.entity as IUser; return userRow.username || userRow.email; } @@ -194,7 +194,7 @@ export const ProjectAccessTable: VFC = () => { id: 'lastLogin', Header: 'Last login', accessor: (row: IProjectAccess) => { - if (row.type === ENTITY_TYPE.USER) { + if (row.type !== ENTITY_TYPE.GROUP) { const userRow = row.entity as IUser; return userRow.seenAt || ''; } @@ -228,7 +228,9 @@ export const ProjectAccessTable: VFC = () => { permission={UPDATE_PROJECT} projectId={projectId} to={`edit/${ - row.type === ENTITY_TYPE.USER ? 'user' : 'group' + row.type === ENTITY_TYPE.GROUP + ? 'group' + : 'user' }/${row.entity.id}`} disabled={access?.rows.length === 1} tooltipProps={{ @@ -344,13 +346,13 @@ export const ProjectAccessTable: VFC = () => { if (!userOrGroup) return; const { id, roleId } = userOrGroup.entity; let name = userOrGroup.entity.name; - if (userOrGroup.type === ENTITY_TYPE.USER) { + if (userOrGroup.type !== ENTITY_TYPE.GROUP) { const user = userOrGroup.entity as IUser; name = name || user.email || user.username || ''; } try { - if (userOrGroup.type === ENTITY_TYPE.USER) { + if (userOrGroup.type !== ENTITY_TYPE.GROUP) { await removeUserFromRole(projectId, roleId, id); } else { await removeGroupFromRole(projectId, roleId, id); diff --git a/frontend/src/hooks/api/getters/useAccess/useAccess.ts b/frontend/src/hooks/api/getters/useAccess/useAccess.ts index 8fc0970f63..669f4a220a 100644 --- a/frontend/src/hooks/api/getters/useAccess/useAccess.ts +++ b/frontend/src/hooks/api/getters/useAccess/useAccess.ts @@ -3,9 +3,11 @@ import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import { IGroup } from 'interfaces/group'; import { IUser } from 'interfaces/user'; +import { IServiceAccount } from 'interfaces/service-account'; export interface IUseAccessOutput { users?: IUser[]; + serviceAccounts?: IServiceAccount[]; groups?: IGroup[]; loading: boolean; refetch: () => void; @@ -19,7 +21,12 @@ export const useAccess = (): IUseAccessOutput => { ); return { - users: data?.users, + users: (data?.users as IUser[])?.filter( + ({ accountType }) => accountType === 'User' + ), + serviceAccounts: (data?.users as IServiceAccount[])?.filter( + ({ accountType }) => accountType === 'Service Account' + ), groups: data?.groups, loading: !error && !data, refetch: () => mutate(), diff --git a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts index 0cd09e96a8..7d19d573a0 100644 --- a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts +++ b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts @@ -6,10 +6,12 @@ import { IProjectRole } from 'interfaces/role'; import { IGroup } from 'interfaces/group'; import { IUser } from 'interfaces/user'; import { mapGroupUsers } from '../useGroup/useGroup'; +import { IServiceAccount } from 'interfaces/service-account'; export enum ENTITY_TYPE { USER = 'USERS', GROUP = 'GROUPS', + SERVICE_ACCOUNT = 'SERVICE ACCOUNTS', } export interface IProjectAccess { @@ -63,7 +65,12 @@ const useProjectAccess = ( if (data) { return formatAccessData({ roles: data.roles, - users: data.users, + users: (data.users as IUser[]).filter( + ({ accountType }) => accountType === 'User' + ), + serviceAccounts: (data.users as IUser[]).filter( + ({ accountType }) => accountType === 'Service Account' + ), groups: data?.groups.map((group: any) => ({ ...group, @@ -83,15 +90,20 @@ const useProjectAccess = ( const formatAccessData = (access: any): IProjectAccessOutput => { const users = access.users || []; + const serviceAccounts = access.serviceAccounts || []; const groups = access.groups || []; return { ...access, rows: [ - ...users.map((user: any) => ({ + ...users.map((user: IUser) => ({ entity: user, type: ENTITY_TYPE.USER, })), - ...groups.map((group: any) => ({ + ...serviceAccounts.map((serviceAccount: IServiceAccount) => ({ + entity: serviceAccount, + type: ENTITY_TYPE.SERVICE_ACCOUNT, + })), + ...groups.map((group: IGroup) => ({ entity: group, type: ENTITY_TYPE.GROUP, })), diff --git a/frontend/src/interfaces/user.ts b/frontend/src/interfaces/user.ts index 4cea5ffda3..c66f35ddf7 100644 --- a/frontend/src/interfaces/user.ts +++ b/frontend/src/interfaces/user.ts @@ -1,3 +1,6 @@ +export const AccountTypes = ['User', 'Service Account'] as const; +type AccountType = typeof AccountTypes[number]; + export interface IUser { id: number; email: string; @@ -13,6 +16,7 @@ export interface IUser { isAPI: boolean; paid?: boolean; addedAt?: string; + accountType?: AccountType; } export interface IPermission { diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index 33b8a253fe..bfd2e81903 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -14,17 +14,6 @@ import { const TABLE = 'users'; -const USER_COLUMNS = [ - 'id', - 'name', - 'username', - 'email', - 'image_url', - 'login_attempts', - 'seen_at', - 'created_at', -]; - const USER_COLUMNS_PUBLIC = [ 'id', 'name', @@ -32,8 +21,11 @@ const USER_COLUMNS_PUBLIC = [ 'email', 'image_url', 'seen_at', + 'is_service', ]; +const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at']; + const emptify = (value) => { if (!value) { return undefined; @@ -63,6 +55,7 @@ const rowToUser = (row) => { loginAttempts: row.login_attempts, seenAt: row.seen_at, createdAt: row.created_at, + isService: row.is_service, }); }; @@ -133,6 +126,11 @@ 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); } @@ -147,7 +145,7 @@ class UserStore implements IUserStore { } async getAllWithId(userIdList: number[]): Promise { - const users = await this.activeUsers() + const users = await this.activeAll() .select(USER_COLUMNS_PUBLIC) .whereIn('id', userIdList); return users.map(rowToUser); diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts index 9e2f579fbe..7a34b813c1 100644 --- a/src/lib/openapi/spec/user-schema.ts +++ b/src/lib/openapi/spec/user-schema.ts @@ -1,4 +1,5 @@ import { FromSchema } from 'json-schema-to-ts'; +import { AccountTypes } from '../../types'; export const userSchema = { $id: '#/components/schemas/userSchema', @@ -45,6 +46,10 @@ export const userSchema = { type: 'string', format: 'date-time', }, + accountType: { + type: 'string', + enum: AccountTypes, + }, }, components: {}, } as const; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index acef4f0b29..87b08b9024 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -262,7 +262,7 @@ export default class UserAdminController extends Controller { } async getUsers(req: Request, res: Response): Promise { - const users = await this.userService.getAll(); + const users = await this.userService.getAllUsers(); const rootRoles = await this.accessService.getRootRoles(); const inviteLinks = await this.resetTokenService.getActiveInvitations(); @@ -317,6 +317,7 @@ export default class UserAdminController extends Controller { name: u.name, username: u.username, email: u.email, + accountType: u.accountType, } as IUser; }); diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index e9eee361c5..86882d31f1 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -161,6 +161,20 @@ 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( diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index b014f5f7a1..6dce85ff9c 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -25,6 +25,7 @@ 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; diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts index 290eb31db0..44ee864cbe 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.ts @@ -1,6 +1,9 @@ import Joi from 'joi'; import { generateImageUrl } from '../util/generateImageUrl'; +export const AccountTypes = ['User', 'Service Account'] as const; +type AccountType = typeof AccountTypes[number]; + export interface UserData { id: number; name?: string; @@ -10,6 +13,7 @@ export interface UserData { seenAt?: Date; loginAttempts?: number; createdAt?: Date; + isService?: boolean; } export interface IUser { @@ -24,6 +28,7 @@ export interface IUser { loginAttempts: number; isAPI: boolean; imageUrl: string; + accountType?: AccountType; } export interface IProjectUser extends IUser { @@ -51,6 +56,8 @@ export default class User implements IUser { createdAt: Date; + accountType?: AccountType = 'User'; + constructor({ id, name, @@ -60,6 +67,7 @@ export default class User implements IUser { seenAt, loginAttempts, createdAt, + isService, }: UserData) { if (!id) { throw new TypeError('Id is required'); @@ -76,6 +84,7 @@ export default class User implements IUser { this.seenAt = seenAt; this.loginAttempts = loginAttempts; this.createdAt = createdAt; + this.accountType = isService ? 'Service Account' : 'User'; } generateImageUrl(): string { diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index c4a0e11aca..900a464357 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -3543,6 +3543,9 @@ exports[`should serve the OpenAPI spec 1`] = ` "userSchema": { "additionalProperties": false, "properties": { + "accountType": { + "type": "string", + }, "createdAt": { "format": "date-time", "type": "string", diff --git a/src/test/fixtures/fake-user-store.ts b/src/test/fixtures/fake-user-store.ts index 009a3bdb08..f7d247472a 100644 --- a/src/test/fixtures/fake-user-store.ts +++ b/src/test/fixtures/fake-user-store.ts @@ -112,6 +112,10 @@ class UserStoreMock implements IUserStore { throw new Error('Not implemented'); } + async getAllUsers(): Promise { + throw new Error('Not implemented'); + } + async getAllWithId(): Promise { throw new Error('Not implemented'); }