Merge pull request #1723 from specklesystems/gergo/adminFacelift
Admin Facelift Backend
This commit is contained in:
@@ -7,11 +7,11 @@
|
||||
# BIND_ADDRESS="127.0.0.1"
|
||||
PORT=3000
|
||||
|
||||
CANONICAL_URL="http://localhost:3000"
|
||||
CANONICAL_URL="http://127.0.0.1:3000"
|
||||
SESSION_SECRET="-> FILL IN <-"
|
||||
|
||||
# Redis connection: default for local development environment
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
REDIS_URL="redis://127.0.0.1:6379"
|
||||
|
||||
############################################################
|
||||
# Frontend 2.0 settings
|
||||
@@ -20,7 +20,7 @@ REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# Whether server is meant to be used with Frontend 2.0
|
||||
USE_FRONTEND_2=false
|
||||
FRONTEND_ORIGIN="http://localhost:8081"
|
||||
FRONTEND_ORIGIN="http://127.0.0.1:8081"
|
||||
|
||||
# URL of a project on any FE2 speckle server that will be pulled in and used as the onboarding stream
|
||||
ONBOARDING_STREAM_URL=https://latest.speckle.systems/projects/843d07eb10
|
||||
@@ -64,8 +64,8 @@ S3_CREATE_BUCKET="true"
|
||||
# Emails
|
||||
############################################################
|
||||
EMAIL=true
|
||||
EMAIL_HOST="127.0.0.1"
|
||||
EMAIL_FROM="no-reply@example.org"
|
||||
EMAIL_HOST="localhost"
|
||||
EMAIL_PORT="1025"
|
||||
|
||||
# EMAIL_HOST="-> FILL IN <-"
|
||||
@@ -126,4 +126,4 @@ STRATEGY_LOCAL=true
|
||||
# FRONTEND_HOST=localhost
|
||||
# FRONTEND_PORT=8081
|
||||
|
||||
SPECKLE_AUTOMATE_URL="http://localhost:3030"
|
||||
SPECKLE_AUTOMATE_URL="http://127.0.0.1:3030"
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
###############################################
|
||||
|
||||
PORT=0
|
||||
POSTGRES_URL=postgres://speckle:speckle@localhost/speckle2_test
|
||||
POSTGRES_URL=postgres://speckle:speckle@127.0.0.1/speckle2_test
|
||||
POSTGRES_USER=''
|
||||
@@ -0,0 +1,34 @@
|
||||
type AdminUserList {
|
||||
items: [LimitedUser!]!
|
||||
cursor: String
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type ServerStatistics {
|
||||
totalProjectCount: Int!
|
||||
totalUserCount: Int!
|
||||
totalPendingInvites: Int!
|
||||
}
|
||||
|
||||
type AdminQueries {
|
||||
userList(
|
||||
limit: Int! = 25
|
||||
cursor: String = null
|
||||
query: String = null
|
||||
role: ServerRole = null
|
||||
): AdminUserList! @hasScope(scope: "users:read")
|
||||
|
||||
projectList(
|
||||
query: String
|
||||
orderBy: String
|
||||
visibility: String
|
||||
limit: Int! = 25
|
||||
cursor: String = null
|
||||
): ProjectCollection!
|
||||
|
||||
serverStatistics: ServerStatistics! @hasScope(scope: "server:stats")
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
admin: AdminQueries! @hasServerRole(role: SERVER_ADMIN)
|
||||
}
|
||||
@@ -22,7 +22,9 @@ extend type Query {
|
||||
orderBy: String
|
||||
visibility: String
|
||||
limit: Int = 25
|
||||
): StreamCollection @hasRole(role: "server:admin")
|
||||
): StreamCollection
|
||||
@hasRole(role: "server:admin")
|
||||
@deprecated(reason: "use admin.projectList instead")
|
||||
|
||||
"""
|
||||
All of the discoverable streams of the server
|
||||
|
||||
@@ -28,6 +28,7 @@ extend type Query {
|
||||
offset: Int! = 0
|
||||
query: String = null
|
||||
): AdminUsersListCollection
|
||||
@deprecated(reason: "use admin.UserList instead")
|
||||
@hasRole(role: "server:admin")
|
||||
@hasScope(scope: "users:read")
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
extend type Query {
|
||||
serverStats: ServerStats!
|
||||
serverStats: ServerStats! @deprecated(reason: "use admin.serverStatistics instead")
|
||||
}
|
||||
|
||||
type ServerStats {
|
||||
|
||||
@@ -24,6 +24,8 @@ generates:
|
||||
ModelMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
|
||||
VersionMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
|
||||
CommentMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
|
||||
AdminQueries: '@/modules/core/helpers/graphTypes#GraphQLEmptyReturn'
|
||||
ServerStatistics: '@/modules/core/helpers/graphTypes#GraphQLEmptyReturn'
|
||||
CommentReplyAuthorCollection: '@/modules/comments/helpers/graphTypes#CommentReplyAuthorCollectionGraphQLReturn'
|
||||
Comment: '@/modules/comments/helpers/graphTypes#CommentGraphQLReturn'
|
||||
PendingStreamCollaborator: '@/modules/serverinvites/helpers/graphTypes#PendingStreamCollaboratorGraphQLReturn'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ForbiddenError } from '@/modules/shared/errors'
|
||||
import { getDirective } from '@graphql-tools/utils'
|
||||
import { mapSchema } from '@graphql-tools/utils'
|
||||
import { MapperKind } from '@graphql-tools/utils'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { defaultFieldResolver } from 'graphql'
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,8 @@ export const isOwner: GraphqlDirectiveBuilder = () => {
|
||||
const authUserId = context.userId
|
||||
|
||||
if (info.parentType?.name === 'User') {
|
||||
if (parentId !== authUserId) {
|
||||
// allow admins to query private user fields
|
||||
if (parentId !== authUserId && context.role !== Roles.Server.Admin) {
|
||||
throw new ForbiddenError(
|
||||
`You must be authenticated as the user whose '${fieldName}' value you wish to retrieve`
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
|
||||
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, VersionGraphQLReturn, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, LimitedUserGraphQLReturn, MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes';
|
||||
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, VersionGraphQLReturn, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, LimitedUserGraphQLReturn, MutationsObjectGraphQLReturn, GraphQLEmptyReturn } from '@/modules/core/helpers/graphTypes';
|
||||
import { StreamAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes';
|
||||
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
|
||||
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
|
||||
@@ -61,6 +61,37 @@ export type ActivityCollection = {
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminQueries = {
|
||||
__typename?: 'AdminQueries';
|
||||
projectList: ProjectCollection;
|
||||
serverStatistics: ServerStatistics;
|
||||
userList: AdminUserList;
|
||||
};
|
||||
|
||||
|
||||
export type AdminQueriesProjectListArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']>;
|
||||
limit?: Scalars['Int'];
|
||||
orderBy?: InputMaybe<Scalars['String']>;
|
||||
query?: InputMaybe<Scalars['String']>;
|
||||
visibility?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type AdminQueriesUserListArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']>;
|
||||
limit?: Scalars['Int'];
|
||||
query?: InputMaybe<Scalars['String']>;
|
||||
role?: InputMaybe<ServerRole>;
|
||||
};
|
||||
|
||||
export type AdminUserList = {
|
||||
__typename?: 'AdminUserList';
|
||||
cursor?: Maybe<Scalars['String']>;
|
||||
items: Array<LimitedUser>;
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminUsersListCollection = {
|
||||
__typename?: 'AdminUsersListCollection';
|
||||
items: Array<AdminUsersListItem>;
|
||||
@@ -1593,11 +1624,16 @@ export type Query = {
|
||||
_?: Maybe<Scalars['String']>;
|
||||
/** Gets the profile of the authenticated user or null if not authenticated */
|
||||
activeUser?: Maybe<User>;
|
||||
/** All the streams of the server. Available to admins only. */
|
||||
admin: AdminQueries;
|
||||
/**
|
||||
* All the streams of the server. Available to admins only.
|
||||
* @deprecated use admin.projectList instead
|
||||
*/
|
||||
adminStreams?: Maybe<StreamCollection>;
|
||||
/**
|
||||
* Get all (or search for specific) users, registered or invited, from the server in a paginated view.
|
||||
* The query looks for matches in name, company and email.
|
||||
* @deprecated use admin.UserList instead
|
||||
*/
|
||||
adminUsers?: Maybe<AdminUsersListCollection>;
|
||||
/** Gets a specific app from the server. */
|
||||
@@ -1627,6 +1663,7 @@ export type Query = {
|
||||
*/
|
||||
projectInvite?: Maybe<PendingStreamCollaborator>;
|
||||
serverInfo: ServerInfo;
|
||||
/** @deprecated use admin.serverStatistics instead */
|
||||
serverStats: ServerStats;
|
||||
/**
|
||||
* Returns a specific stream. Will throw an authorization error if active user isn't authorized
|
||||
@@ -1881,6 +1918,13 @@ export enum ServerRole {
|
||||
ServerUser = 'SERVER_USER'
|
||||
}
|
||||
|
||||
export type ServerStatistics = {
|
||||
__typename?: 'ServerStatistics';
|
||||
totalPendingInvites: Scalars['Int'];
|
||||
totalProjectCount: Scalars['Int'];
|
||||
totalUserCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type ServerStats = {
|
||||
__typename?: 'ServerStats';
|
||||
/** An array of objects currently structured as { created_month: Date, count: int }. */
|
||||
@@ -2712,6 +2756,8 @@ export type ResolversTypes = {
|
||||
ActiveUserMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
Activity: ResolverTypeWrapper<Activity>;
|
||||
ActivityCollection: ResolverTypeWrapper<ActivityCollection>;
|
||||
AdminQueries: ResolverTypeWrapper<GraphQLEmptyReturn>;
|
||||
AdminUserList: ResolverTypeWrapper<Omit<AdminUserList, 'items'> & { items: Array<ResolversTypes['LimitedUser']> }>;
|
||||
AdminUsersListCollection: ResolverTypeWrapper<Omit<AdminUsersListCollection, 'items'> & { items: Array<ResolversTypes['AdminUsersListItem']> }>;
|
||||
AdminUsersListItem: ResolverTypeWrapper<Omit<AdminUsersListItem, 'invitedUser' | 'registeredUser'> & { invitedUser?: Maybe<ResolversTypes['ServerInvite']>, registeredUser?: Maybe<ResolversTypes['User']> }>;
|
||||
ApiToken: ResolverTypeWrapper<ApiToken>;
|
||||
@@ -2821,6 +2867,7 @@ export type ResolversTypes = {
|
||||
ServerInvite: ResolverTypeWrapper<Omit<ServerInvite, 'invitedBy'> & { invitedBy: ResolversTypes['LimitedUser'] }>;
|
||||
ServerInviteCreateInput: ServerInviteCreateInput;
|
||||
ServerRole: ServerRole;
|
||||
ServerStatistics: ResolverTypeWrapper<GraphQLEmptyReturn>;
|
||||
ServerStats: ResolverTypeWrapper<ServerStats>;
|
||||
SmartTextEditorValue: ResolverTypeWrapper<SmartTextEditorValue>;
|
||||
SortDirection: SortDirection;
|
||||
@@ -2870,6 +2917,8 @@ export type ResolversParentTypes = {
|
||||
ActiveUserMutations: MutationsObjectGraphQLReturn;
|
||||
Activity: Activity;
|
||||
ActivityCollection: ActivityCollection;
|
||||
AdminQueries: GraphQLEmptyReturn;
|
||||
AdminUserList: Omit<AdminUserList, 'items'> & { items: Array<ResolversParentTypes['LimitedUser']> };
|
||||
AdminUsersListCollection: Omit<AdminUsersListCollection, 'items'> & { items: Array<ResolversParentTypes['AdminUsersListItem']> };
|
||||
AdminUsersListItem: Omit<AdminUsersListItem, 'invitedUser' | 'registeredUser'> & { invitedUser?: Maybe<ResolversParentTypes['ServerInvite']>, registeredUser?: Maybe<ResolversParentTypes['User']> };
|
||||
ApiToken: ApiToken;
|
||||
@@ -2969,6 +3018,7 @@ export type ResolversParentTypes = {
|
||||
ServerInfoUpdateInput: ServerInfoUpdateInput;
|
||||
ServerInvite: Omit<ServerInvite, 'invitedBy'> & { invitedBy: ResolversParentTypes['LimitedUser'] };
|
||||
ServerInviteCreateInput: ServerInviteCreateInput;
|
||||
ServerStatistics: GraphQLEmptyReturn;
|
||||
ServerStats: ServerStats;
|
||||
SmartTextEditorValue: SmartTextEditorValue;
|
||||
Stream: StreamGraphQLReturn;
|
||||
@@ -3069,6 +3119,20 @@ export type ActivityCollectionResolvers<ContextType = GraphQLContext, ParentType
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type AdminQueriesResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminQueries'] = ResolversParentTypes['AdminQueries']> = {
|
||||
projectList?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<AdminQueriesProjectListArgs, 'cursor' | 'limit'>>;
|
||||
serverStatistics?: Resolver<ResolversTypes['ServerStatistics'], ParentType, ContextType>;
|
||||
userList?: Resolver<ResolversTypes['AdminUserList'], ParentType, ContextType, RequireFields<AdminQueriesUserListArgs, 'cursor' | 'limit' | 'query' | 'role'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type AdminUserListResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminUserList'] = ResolversParentTypes['AdminUserList']> = {
|
||||
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
items?: Resolver<Array<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
|
||||
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type AdminUsersListCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminUsersListCollection'] = ResolversParentTypes['AdminUsersListCollection']> = {
|
||||
items?: Resolver<Array<ResolversTypes['AdminUsersListItem']>, ParentType, ContextType>;
|
||||
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
@@ -3579,6 +3643,7 @@ export type ProjectVersionsUpdatedMessageResolvers<ContextType = GraphQLContext,
|
||||
export type QueryResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = {
|
||||
_?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
activeUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
|
||||
admin?: Resolver<ResolversTypes['AdminQueries'], ParentType, ContextType>;
|
||||
adminStreams?: Resolver<Maybe<ResolversTypes['StreamCollection']>, ParentType, ContextType, RequireFields<QueryAdminStreamsArgs, 'limit' | 'offset'>>;
|
||||
adminUsers?: Resolver<Maybe<ResolversTypes['AdminUsersListCollection']>, ParentType, ContextType, RequireFields<QueryAdminUsersArgs, 'limit' | 'offset' | 'query'>>;
|
||||
app?: Resolver<Maybe<ResolversTypes['ServerApp']>, ParentType, ContextType, RequireFields<QueryAppArgs, 'id'>>;
|
||||
@@ -3673,6 +3738,13 @@ export type ServerInviteResolvers<ContextType = GraphQLContext, ParentType exten
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ServerStatisticsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ServerStatistics'] = ResolversParentTypes['ServerStatistics']> = {
|
||||
totalPendingInvites?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
totalProjectCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
totalUserCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ServerStatsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ServerStats'] = ResolversParentTypes['ServerStats']> = {
|
||||
commitHistory?: Resolver<Maybe<Array<Maybe<ResolversTypes['JSONObject']>>>, ParentType, ContextType>;
|
||||
objectHistory?: Resolver<Maybe<Array<Maybe<ResolversTypes['JSONObject']>>>, ParentType, ContextType>;
|
||||
@@ -3911,6 +3983,8 @@ export type Resolvers<ContextType = GraphQLContext> = {
|
||||
ActiveUserMutations?: ActiveUserMutationsResolvers<ContextType>;
|
||||
Activity?: ActivityResolvers<ContextType>;
|
||||
ActivityCollection?: ActivityCollectionResolvers<ContextType>;
|
||||
AdminQueries?: AdminQueriesResolvers<ContextType>;
|
||||
AdminUserList?: AdminUserListResolvers<ContextType>;
|
||||
AdminUsersListCollection?: AdminUsersListCollectionResolvers<ContextType>;
|
||||
AdminUsersListItem?: AdminUsersListItemResolvers<ContextType>;
|
||||
ApiToken?: ApiTokenResolvers<ContextType>;
|
||||
@@ -3968,6 +4042,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
|
||||
ServerAppListItem?: ServerAppListItemResolvers<ContextType>;
|
||||
ServerInfo?: ServerInfoResolvers<ContextType>;
|
||||
ServerInvite?: ServerInviteResolvers<ContextType>;
|
||||
ServerStatistics?: ServerStatisticsResolvers<ContextType>;
|
||||
ServerStats?: ServerStatsResolvers<ContextType>;
|
||||
SmartTextEditorValue?: SmartTextEditorValueResolvers<ContextType>;
|
||||
Stream?: StreamResolvers<ContextType>;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes'
|
||||
import { adminProjectList, adminUserList } from '@/modules/core/services/admin'
|
||||
import { getTotalStreamCount, getTotalUserCount } from '@/modules/stats/services'
|
||||
|
||||
export = {
|
||||
Query: {
|
||||
admin: () => ({})
|
||||
},
|
||||
AdminQueries: {
|
||||
async userList(_parent, { limit, cursor, query, role }) {
|
||||
return await adminUserList({
|
||||
limit,
|
||||
cursor,
|
||||
query,
|
||||
role: role ? mapServerRoleToValue(role) : null
|
||||
})
|
||||
},
|
||||
async projectList(_parent, args) {
|
||||
return await adminProjectList({
|
||||
query: args.query ?? null,
|
||||
orderBy: args.orderBy ?? null,
|
||||
visibility: args.visibility ?? null,
|
||||
limit: args.limit,
|
||||
cursor: args.cursor
|
||||
})
|
||||
},
|
||||
serverStatistics: () => ({})
|
||||
},
|
||||
ServerStatistics: {
|
||||
async totalProjectCount() {
|
||||
return await getTotalStreamCount()
|
||||
},
|
||||
|
||||
async totalUserCount() {
|
||||
return await getTotalUserCount()
|
||||
},
|
||||
async totalPendingInvites() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
} as Resolvers
|
||||
@@ -96,6 +96,12 @@ export type ModelsTreeItemGraphQLReturn = Omit<ModelsTreeItem, 'model' | 'childr
|
||||
*/
|
||||
export type MutationsObjectGraphQLReturn = Record<string, never>
|
||||
|
||||
/**
|
||||
* Use this to override the generated graphql type, in cases like graphql resolver
|
||||
* collection objects
|
||||
*/
|
||||
export type GraphQLEmptyReturn = Record<string, never>
|
||||
|
||||
/**
|
||||
* Map GQL StreamRole enum to the value types we use in the backend
|
||||
*/
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { ServerAcl, Users, knex } from '@/modules/core/dbSchema'
|
||||
import { LimitedUserRecord, UserRecord } from '@/modules/core/helpers/types'
|
||||
import { Nullable } from '@/modules/shared/helpers/typeHelper'
|
||||
import { isArray } from 'lodash'
|
||||
import { clamp, isArray } from 'lodash'
|
||||
import { metaHelpers } from '@/modules/core/helpers/meta'
|
||||
import { UserValidationError } from '@/modules/core/errors/user'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { Knex } from 'knex'
|
||||
import { Roles, ServerRoles } from '@speckle/shared'
|
||||
|
||||
export type UserWithOptionalRole<User extends LimitedUserRecord = UserRecord> = User & {
|
||||
/**
|
||||
* Available, if query joined this data from server_acl
|
||||
* (this can be the server role or stream role depending on how and where this was retrieved)
|
||||
*/
|
||||
role?: string
|
||||
role?: ServerRoles
|
||||
}
|
||||
|
||||
export type UserWithRole<User extends LimitedUserRecord = UserRecord> = User & {
|
||||
role: ServerRoles
|
||||
}
|
||||
|
||||
export type GetUserParams = Partial<{
|
||||
@@ -56,6 +61,62 @@ export async function getUsers(
|
||||
return (await q).map((u) => (skipClean ? u : sanitizeUserRecord(u)))
|
||||
}
|
||||
|
||||
type UserQuery = {
|
||||
query: string | null
|
||||
role: ServerRoles | null
|
||||
}
|
||||
|
||||
const getUsersBaseQuery = (q: Knex.QueryBuilder, { query, role }: UserQuery) => {
|
||||
if (query) {
|
||||
q.where((queryBuilder) => {
|
||||
queryBuilder
|
||||
.where('email', 'ILIKE', `%${query}%`)
|
||||
.orWhere('name', 'ILIKE', `%${query}%`)
|
||||
.orWhere('company', 'ILIKE', `%${query}%`)
|
||||
})
|
||||
}
|
||||
if (role) q.where({ role })
|
||||
return q
|
||||
}
|
||||
/**
|
||||
* List users
|
||||
*/
|
||||
export async function listUsers({
|
||||
limit,
|
||||
cursor,
|
||||
query,
|
||||
role
|
||||
}: {
|
||||
limit: number
|
||||
cursor: Date | null
|
||||
} & UserQuery): Promise<UserWithRole[]> {
|
||||
const sanitizedLimit = clamp(limit, 1, 200)
|
||||
const q = Users.knex<UserWithRole[]>()
|
||||
.orderBy(Users.col.createdAt, 'desc')
|
||||
.limit(sanitizedLimit)
|
||||
.columns([
|
||||
...Object.values(Users.col),
|
||||
// Getting first role from grouped results
|
||||
knex.raw(`(array_agg("server_acl"."role"))[1] as role`)
|
||||
])
|
||||
.leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id)
|
||||
.groupBy(Users.col.id)
|
||||
if (cursor) q.where(Users.col.createdAt, '<', cursor)
|
||||
const users: UserWithRole[] = await getUsersBaseQuery(q, { query, role })
|
||||
return users.map((u) => sanitizeUserRecord(u))
|
||||
}
|
||||
|
||||
export async function countUsers(args: UserQuery): Promise<number> {
|
||||
// const result = await getUsersBaseQuery(Users.knex(), args).countDistinct(Users.col.id)
|
||||
const q = Users.knex()
|
||||
.leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id)
|
||||
.countDistinct(Users.col.id)
|
||||
const result = await getUsersBaseQuery(q, args)
|
||||
// .groupBy(Users.col.id)
|
||||
// const result = await q
|
||||
return parseInt(result[0]['count'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { StreamRecord, UserRecord } from '@/modules/core/helpers/types'
|
||||
import { listUsers, countUsers } from '@/modules/core/repositories/users'
|
||||
import { getStreams } from '@/modules/core/services/streams'
|
||||
import { BaseError } from '@/modules/shared/errors/base'
|
||||
import { ServerRoles } from '@speckle/shared'
|
||||
|
||||
type HasCursor = {
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
type Collection<T> = HasCursor & {
|
||||
items: T[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
type AdminUserListArgs = HasCursor & {
|
||||
limit: number
|
||||
query: string | null
|
||||
role: ServerRoles | null
|
||||
}
|
||||
|
||||
export class CursorParsingError extends BaseError {
|
||||
static defaultMessage = 'Invalid cursor provided'
|
||||
static code = 'INVALID_CURSOR_VALUE'
|
||||
}
|
||||
|
||||
export const parseCursorToDate = (cursor: string): Date => {
|
||||
const timestamp = Date.parse(Buffer.from(cursor, 'base64').toString('utf-8'))
|
||||
if (isNaN(timestamp)) throw new CursorParsingError()
|
||||
return new Date(timestamp)
|
||||
}
|
||||
|
||||
export const convertDateToCursor = (date: Date): string =>
|
||||
Buffer.from(date.toISOString()).toString('base64')
|
||||
|
||||
export const adminUserList = async (
|
||||
args: AdminUserListArgs
|
||||
): Promise<Collection<UserRecord>> => {
|
||||
const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null
|
||||
const [items, totalCount] = await Promise.all([
|
||||
listUsers({
|
||||
role: args.role,
|
||||
cursor: parsedCursor,
|
||||
limit: args.limit,
|
||||
query: args.query ?? null
|
||||
}),
|
||||
countUsers(args)
|
||||
])
|
||||
const cursor = items.length ? convertDateToCursor(items.slice(-1)[0].createdAt) : null
|
||||
return { totalCount, items, cursor }
|
||||
}
|
||||
|
||||
type AdminProjectListArgs = HasCursor & {
|
||||
query: string | null
|
||||
orderBy: string | null
|
||||
visibility: string | null
|
||||
limit: number
|
||||
}
|
||||
|
||||
export const adminProjectList = async (
|
||||
args: AdminProjectListArgs
|
||||
): Promise<Collection<StreamRecord>> => {
|
||||
const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null
|
||||
const { streams, totalCount, cursorDate } = await getStreams({
|
||||
...args,
|
||||
searchQuery: args.query,
|
||||
cursor: parsedCursor
|
||||
})
|
||||
const cursor = cursorDate ? convertDateToCursor(cursorDate) : null
|
||||
return {
|
||||
cursor,
|
||||
items: streams,
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
@@ -73,16 +73,8 @@ module.exports = {
|
||||
return await deleteStreamFromDb(streamId)
|
||||
},
|
||||
|
||||
async getStreams({ offset, limit, orderBy, visibility, searchQuery }) {
|
||||
const query = knex
|
||||
.column(
|
||||
'streams.*',
|
||||
knex.raw('coalesce(sum(pg_column_size(objects.data)),0) as size')
|
||||
)
|
||||
.select()
|
||||
.from('streams')
|
||||
.leftJoin('objects', 'streams.id', 'objects.streamId')
|
||||
.groupBy('streams.id')
|
||||
async getStreams({ cursor, limit, orderBy, visibility, searchQuery }) {
|
||||
const query = knex.select().from('streams')
|
||||
|
||||
const countQuery = Streams.knex()
|
||||
|
||||
@@ -116,9 +108,12 @@ module.exports = {
|
||||
|
||||
const [columnName, order] = orderBy.split(',')
|
||||
|
||||
const rows = await query.orderBy(`${columnName}`, order).offset(offset).limit(limit)
|
||||
if (cursor) query.where(columnName, order === 'desc' ? '<' : '>', cursor)
|
||||
|
||||
return { streams: rows, totalCount: count }
|
||||
const rows = await query.orderBy(`${columnName}`, order).limit(limit)
|
||||
|
||||
const cursorDate = rows.length ? rows.slice(-1)[0][columnName] : null
|
||||
return { streams: rows, totalCount: count, cursorDate }
|
||||
},
|
||||
|
||||
async getStreamUsers({ streamId }) {
|
||||
|
||||
@@ -607,11 +607,6 @@ describe('GraphQL API Core @core-api', () => {
|
||||
expect(streamResults.body.data.adminStreams.totalCount).to.equal(10)
|
||||
expect(streamResults.body.data.adminStreams.items.length).to.equal(2)
|
||||
|
||||
streamResults = await sendRequest(userA.token, {
|
||||
query: '{ adminStreams(offset: 5) { totalCount items { id name } } }'
|
||||
})
|
||||
expect(streamResults.body.data.adminStreams.items.length).to.equal(5)
|
||||
|
||||
streamResults = await sendRequest(userA.token, {
|
||||
query: '{ adminStreams( query: "Admin" ) { totalCount items { id name } } }'
|
||||
})
|
||||
|
||||
@@ -52,6 +52,37 @@ export type ActivityCollection = {
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminQueries = {
|
||||
__typename?: 'AdminQueries';
|
||||
projectList: ProjectCollection;
|
||||
serverStatistics: ServerStatistics;
|
||||
userList: AdminUserList;
|
||||
};
|
||||
|
||||
|
||||
export type AdminQueriesProjectListArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']>;
|
||||
limit?: Scalars['Int'];
|
||||
orderBy?: InputMaybe<Scalars['String']>;
|
||||
query?: InputMaybe<Scalars['String']>;
|
||||
visibility?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type AdminQueriesUserListArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']>;
|
||||
limit?: Scalars['Int'];
|
||||
query?: InputMaybe<Scalars['String']>;
|
||||
role?: InputMaybe<ServerRole>;
|
||||
};
|
||||
|
||||
export type AdminUserList = {
|
||||
__typename?: 'AdminUserList';
|
||||
cursor?: Maybe<Scalars['String']>;
|
||||
items: Array<LimitedUser>;
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminUsersListCollection = {
|
||||
__typename?: 'AdminUsersListCollection';
|
||||
items: Array<AdminUsersListItem>;
|
||||
@@ -1584,11 +1615,16 @@ export type Query = {
|
||||
_?: Maybe<Scalars['String']>;
|
||||
/** Gets the profile of the authenticated user or null if not authenticated */
|
||||
activeUser?: Maybe<User>;
|
||||
/** All the streams of the server. Available to admins only. */
|
||||
admin: AdminQueries;
|
||||
/**
|
||||
* All the streams of the server. Available to admins only.
|
||||
* @deprecated use admin.projectList instead
|
||||
*/
|
||||
adminStreams?: Maybe<StreamCollection>;
|
||||
/**
|
||||
* Get all (or search for specific) users, registered or invited, from the server in a paginated view.
|
||||
* The query looks for matches in name, company and email.
|
||||
* @deprecated use admin.UserList instead
|
||||
*/
|
||||
adminUsers?: Maybe<AdminUsersListCollection>;
|
||||
/** Gets a specific app from the server. */
|
||||
@@ -1618,6 +1654,7 @@ export type Query = {
|
||||
*/
|
||||
projectInvite?: Maybe<PendingStreamCollaborator>;
|
||||
serverInfo: ServerInfo;
|
||||
/** @deprecated use admin.serverStatistics instead */
|
||||
serverStats: ServerStats;
|
||||
/**
|
||||
* Returns a specific stream. Will throw an authorization error if active user isn't authorized
|
||||
@@ -1872,6 +1909,13 @@ export enum ServerRole {
|
||||
ServerUser = 'SERVER_USER'
|
||||
}
|
||||
|
||||
export type ServerStatistics = {
|
||||
__typename?: 'ServerStatistics';
|
||||
totalPendingInvites: Scalars['Int'];
|
||||
totalProjectCount: Scalars['Int'];
|
||||
totalUserCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type ServerStats = {
|
||||
__typename?: 'ServerStats';
|
||||
/** An array of objects currently structured as { created_month: Date, count: int }. */
|
||||
|
||||
@@ -13,6 +13,9 @@ const {
|
||||
|
||||
module.exports = {
|
||||
Query: {
|
||||
/**
|
||||
* @deprecated('Use admin.serverStatistics')
|
||||
*/
|
||||
async serverStats(parent, args, context) {
|
||||
await validateServerRole(context, 'server:admin')
|
||||
await validateScopes(context.scopes, 'server:stats')
|
||||
|
||||
@@ -21,6 +21,8 @@ module.exports = {
|
||||
},
|
||||
|
||||
async getTotalUserCount() {
|
||||
// returns -1 for small tables, no good
|
||||
// const fastQuery = "SELECT reltuples::bigint AS estimate FROM pg_catalog.pg_class WHERE relname = 'users'"
|
||||
const query = 'SELECT COUNT(*) FROM users'
|
||||
const result = await knex.raw(query)
|
||||
return parseInt(result.rows[0].count)
|
||||
|
||||
@@ -52,6 +52,37 @@ export type ActivityCollection = {
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminQueries = {
|
||||
__typename?: 'AdminQueries';
|
||||
projectList: ProjectCollection;
|
||||
serverStatistics: ServerStatistics;
|
||||
userList: AdminUserList;
|
||||
};
|
||||
|
||||
|
||||
export type AdminQueriesProjectListArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']>;
|
||||
limit?: Scalars['Int'];
|
||||
orderBy?: InputMaybe<Scalars['String']>;
|
||||
query?: InputMaybe<Scalars['String']>;
|
||||
visibility?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type AdminQueriesUserListArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']>;
|
||||
limit?: Scalars['Int'];
|
||||
query?: InputMaybe<Scalars['String']>;
|
||||
role?: InputMaybe<ServerRole>;
|
||||
};
|
||||
|
||||
export type AdminUserList = {
|
||||
__typename?: 'AdminUserList';
|
||||
cursor?: Maybe<Scalars['String']>;
|
||||
items: Array<LimitedUser>;
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminUsersListCollection = {
|
||||
__typename?: 'AdminUsersListCollection';
|
||||
items: Array<AdminUsersListItem>;
|
||||
@@ -1584,11 +1615,16 @@ export type Query = {
|
||||
_?: Maybe<Scalars['String']>;
|
||||
/** Gets the profile of the authenticated user or null if not authenticated */
|
||||
activeUser?: Maybe<User>;
|
||||
/** All the streams of the server. Available to admins only. */
|
||||
admin: AdminQueries;
|
||||
/**
|
||||
* All the streams of the server. Available to admins only.
|
||||
* @deprecated use admin.projectList instead
|
||||
*/
|
||||
adminStreams?: Maybe<StreamCollection>;
|
||||
/**
|
||||
* Get all (or search for specific) users, registered or invited, from the server in a paginated view.
|
||||
* The query looks for matches in name, company and email.
|
||||
* @deprecated use admin.UserList instead
|
||||
*/
|
||||
adminUsers?: Maybe<AdminUsersListCollection>;
|
||||
/** Gets a specific app from the server. */
|
||||
@@ -1618,6 +1654,7 @@ export type Query = {
|
||||
*/
|
||||
projectInvite?: Maybe<PendingStreamCollaborator>;
|
||||
serverInfo: ServerInfo;
|
||||
/** @deprecated use admin.serverStatistics instead */
|
||||
serverStats: ServerStats;
|
||||
/**
|
||||
* Returns a specific stream. Will throw an authorization error if active user isn't authorized
|
||||
@@ -1872,6 +1909,13 @@ export enum ServerRole {
|
||||
ServerUser = 'SERVER_USER'
|
||||
}
|
||||
|
||||
export type ServerStatistics = {
|
||||
__typename?: 'ServerStatistics';
|
||||
totalPendingInvites: Scalars['Int'];
|
||||
totalProjectCount: Scalars['Int'];
|
||||
totalUserCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type ServerStats = {
|
||||
__typename?: 'ServerStats';
|
||||
/** An array of objects currently structured as { created_month: Date, count: int }. */
|
||||
|
||||
Reference in New Issue
Block a user