feat(server admin): add FE2 admin page 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"
|
||||
|
||||
# Stream to be used as the demo/tutorial stream in onboarding flows
|
||||
# (if not set/valid, stream will be a blank one)
|
||||
@@ -63,7 +63,7 @@ S3_CREATE_BUCKET="true"
|
||||
############################################################
|
||||
EMAIL=true
|
||||
EMAIL_FROM="speckle@speckle.local"
|
||||
EMAIL_HOST="localhost"
|
||||
EMAIL_HOST="127.0.0.1"
|
||||
EMAIL_PORT="1025"
|
||||
|
||||
# EMAIL_HOST="-> FILL IN <-"
|
||||
@@ -124,4 +124,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,39 @@
|
||||
type AdminUserList {
|
||||
items: [BaseUser!]!
|
||||
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")
|
||||
|
||||
# inviteList(
|
||||
# limit: Int! = 25
|
||||
# offset: Int! = 0
|
||||
# )
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -71,6 +72,28 @@ type PasswordStrengthCheckFeedback {
|
||||
suggestions: [String!]!
|
||||
}
|
||||
|
||||
# TODO: this is a blatant duplication, should we have interfaces for this?
|
||||
type BaseUser {
|
||||
id: ID!
|
||||
"""
|
||||
E-mail can be null, if it's requested for a user other than the authenticated one
|
||||
and the user isn't an admin
|
||||
"""
|
||||
email: String
|
||||
name: String!
|
||||
bio: String
|
||||
company: String
|
||||
avatar: String
|
||||
verified: Boolean
|
||||
profiles: JSONObject
|
||||
role: String
|
||||
"""
|
||||
Whether post-sign up onboarding has been finished or skipped entirely
|
||||
"""
|
||||
isOnboardingFinished: Boolean @isOwner
|
||||
createdAt: DateTime @isOwner
|
||||
}
|
||||
|
||||
"""
|
||||
Full user type, should only be used in the context of admin operations or
|
||||
when a user is reading/writing info about himself
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
extend type Query {
|
||||
serverStats: ServerStats!
|
||||
serverStats: ServerStats! @deprecated(reason: "use admin.serverStatistics instead")
|
||||
}
|
||||
|
||||
type ServerStats {
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
@@ -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<BaseUser>;
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminUsersListCollection = {
|
||||
__typename?: 'AdminUsersListCollection';
|
||||
items: Array<AdminUsersListItem>;
|
||||
@@ -132,6 +163,26 @@ export type AuthStrategy = {
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
export type BaseUser = {
|
||||
__typename?: 'BaseUser';
|
||||
avatar?: Maybe<Scalars['String']>;
|
||||
bio?: Maybe<Scalars['String']>;
|
||||
company?: Maybe<Scalars['String']>;
|
||||
createdAt?: Maybe<Scalars['DateTime']>;
|
||||
/**
|
||||
* E-mail can be null, if it's requested for a user other than the authenticated one
|
||||
* and the user isn't an admin
|
||||
*/
|
||||
email?: Maybe<Scalars['String']>;
|
||||
id: Scalars['ID'];
|
||||
/** Whether post-sign up onboarding has been finished or skipped entirely */
|
||||
isOnboardingFinished?: Maybe<Scalars['Boolean']>;
|
||||
name: Scalars['String'];
|
||||
profiles?: Maybe<Scalars['JSONObject']>;
|
||||
role?: Maybe<Scalars['String']>;
|
||||
verified?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type BlobMetadata = {
|
||||
__typename?: 'BlobMetadata';
|
||||
createdAt: Scalars['DateTime'];
|
||||
@@ -1593,6 +1644,7 @@ export type Query = {
|
||||
_?: Maybe<Scalars['String']>;
|
||||
/** Gets the profile of the authenticated user or null if not authenticated */
|
||||
activeUser?: Maybe<User>;
|
||||
admin: AdminQueries;
|
||||
/** All the streams of the server. Available to admins only. */
|
||||
adminStreams?: Maybe<StreamCollection>;
|
||||
/**
|
||||
@@ -1881,6 +1933,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 +2771,8 @@ export type ResolversTypes = {
|
||||
ActiveUserMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
Activity: ResolverTypeWrapper<Activity>;
|
||||
ActivityCollection: ResolverTypeWrapper<ActivityCollection>;
|
||||
AdminQueries: ResolverTypeWrapper<Omit<AdminQueries, 'projectList'> & { projectList: ResolversTypes['ProjectCollection'] }>;
|
||||
AdminUserList: ResolverTypeWrapper<AdminUserList>;
|
||||
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>;
|
||||
@@ -2720,6 +2781,7 @@ export type ResolversTypes = {
|
||||
AppCreateInput: AppCreateInput;
|
||||
AppUpdateInput: AppUpdateInput;
|
||||
AuthStrategy: ResolverTypeWrapper<AuthStrategy>;
|
||||
BaseUser: ResolverTypeWrapper<BaseUser>;
|
||||
BigInt: ResolverTypeWrapper<Scalars['BigInt']>;
|
||||
BlobMetadata: ResolverTypeWrapper<BlobMetadata>;
|
||||
BlobMetadataCollection: ResolverTypeWrapper<BlobMetadataCollection>;
|
||||
@@ -2821,6 +2883,7 @@ export type ResolversTypes = {
|
||||
ServerInvite: ResolverTypeWrapper<Omit<ServerInvite, 'invitedBy'> & { invitedBy: ResolversTypes['LimitedUser'] }>;
|
||||
ServerInviteCreateInput: ServerInviteCreateInput;
|
||||
ServerRole: ServerRole;
|
||||
ServerStatistics: ResolverTypeWrapper<ServerStatistics>;
|
||||
ServerStats: ResolverTypeWrapper<ServerStats>;
|
||||
SmartTextEditorValue: ResolverTypeWrapper<SmartTextEditorValue>;
|
||||
SortDirection: SortDirection;
|
||||
@@ -2870,6 +2933,8 @@ export type ResolversParentTypes = {
|
||||
ActiveUserMutations: MutationsObjectGraphQLReturn;
|
||||
Activity: Activity;
|
||||
ActivityCollection: ActivityCollection;
|
||||
AdminQueries: Omit<AdminQueries, 'projectList'> & { projectList: ResolversParentTypes['ProjectCollection'] };
|
||||
AdminUserList: AdminUserList;
|
||||
AdminUsersListCollection: Omit<AdminUsersListCollection, 'items'> & { items: Array<ResolversParentTypes['AdminUsersListItem']> };
|
||||
AdminUsersListItem: Omit<AdminUsersListItem, 'invitedUser' | 'registeredUser'> & { invitedUser?: Maybe<ResolversParentTypes['ServerInvite']>, registeredUser?: Maybe<ResolversParentTypes['User']> };
|
||||
ApiToken: ApiToken;
|
||||
@@ -2878,6 +2943,7 @@ export type ResolversParentTypes = {
|
||||
AppCreateInput: AppCreateInput;
|
||||
AppUpdateInput: AppUpdateInput;
|
||||
AuthStrategy: AuthStrategy;
|
||||
BaseUser: BaseUser;
|
||||
BigInt: Scalars['BigInt'];
|
||||
BlobMetadata: BlobMetadata;
|
||||
BlobMetadataCollection: BlobMetadataCollection;
|
||||
@@ -2969,6 +3035,7 @@ export type ResolversParentTypes = {
|
||||
ServerInfoUpdateInput: ServerInfoUpdateInput;
|
||||
ServerInvite: Omit<ServerInvite, 'invitedBy'> & { invitedBy: ResolversParentTypes['LimitedUser'] };
|
||||
ServerInviteCreateInput: ServerInviteCreateInput;
|
||||
ServerStatistics: ServerStatistics;
|
||||
ServerStats: ServerStats;
|
||||
SmartTextEditorValue: SmartTextEditorValue;
|
||||
Stream: StreamGraphQLReturn;
|
||||
@@ -3069,6 +3136,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['BaseUser']>, 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>;
|
||||
@@ -3109,6 +3190,21 @@ export type AuthStrategyResolvers<ContextType = GraphQLContext, ParentType exten
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type BaseUserResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['BaseUser'] = ResolversParentTypes['BaseUser']> = {
|
||||
avatar?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
bio?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
company?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
createdAt?: Resolver<Maybe<ResolversTypes['DateTime']>, ParentType, ContextType>;
|
||||
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
isOnboardingFinished?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
profiles?: Resolver<Maybe<ResolversTypes['JSONObject']>, ParentType, ContextType>;
|
||||
role?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
verified?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export interface BigIntScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['BigInt'], any> {
|
||||
name: 'BigInt';
|
||||
}
|
||||
@@ -3579,6 +3675,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 +3770,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,11 +4015,14 @@ 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>;
|
||||
AppAuthor?: AppAuthorResolvers<ContextType>;
|
||||
AuthStrategy?: AuthStrategyResolvers<ContextType>;
|
||||
BaseUser?: BaseUserResolvers<ContextType>;
|
||||
BigInt?: GraphQLScalarType;
|
||||
BlobMetadata?: BlobMetadataResolvers<ContextType>;
|
||||
BlobMetadataCollection?: BlobMetadataCollectionResolvers<ContextType>;
|
||||
@@ -3968,6 +4075,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,59 @@
|
||||
import { Resolvers, ServerRole } 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'
|
||||
|
||||
type CursorAndLimit = {
|
||||
cursor: string | null
|
||||
limit: number
|
||||
}
|
||||
|
||||
type Query = {
|
||||
query: string | null
|
||||
}
|
||||
|
||||
type UserListArgs = CursorAndLimit &
|
||||
Query & {
|
||||
role: ServerRole | null
|
||||
}
|
||||
|
||||
type ProjectListArgs = CursorAndLimit &
|
||||
Query & {
|
||||
orderBy: string
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export = {
|
||||
Query: {
|
||||
admin: () => ({})
|
||||
},
|
||||
AdminQueries: {
|
||||
async userList(_parent, { limit, cursor, query, role }: UserListArgs) {
|
||||
return await adminUserList({
|
||||
limit,
|
||||
cursor,
|
||||
query,
|
||||
role: mapServerRoleToValue(role)
|
||||
})
|
||||
},
|
||||
// async inviteList() {
|
||||
|
||||
// }
|
||||
async projectList(_parent, args: ProjectListArgs) {
|
||||
return await adminProjectList(args)
|
||||
},
|
||||
serverStatistics: () => ({})
|
||||
},
|
||||
ServerStatistics: {
|
||||
async totalProjectCount() {
|
||||
return await getTotalStreamCount()
|
||||
},
|
||||
|
||||
async totalUserCount() {
|
||||
return await getTotalUserCount()
|
||||
},
|
||||
async totalPendingInvites() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
} as Resolvers
|
||||
@@ -1,16 +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 { ServerRoles } from '@speckle/shared'
|
||||
import { Knex } from 'knex'
|
||||
|
||||
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<{
|
||||
@@ -55,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
|
||||
}),
|
||||
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
|
||||
visibility: string
|
||||
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 } } }'
|
||||
})
|
||||
|
||||
@@ -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<BaseUser>;
|
||||
totalCount: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type AdminUsersListCollection = {
|
||||
__typename?: 'AdminUsersListCollection';
|
||||
items: Array<AdminUsersListItem>;
|
||||
@@ -123,6 +154,26 @@ export type AuthStrategy = {
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
export type BaseUser = {
|
||||
__typename?: 'BaseUser';
|
||||
avatar?: Maybe<Scalars['String']>;
|
||||
bio?: Maybe<Scalars['String']>;
|
||||
company?: Maybe<Scalars['String']>;
|
||||
createdAt?: Maybe<Scalars['DateTime']>;
|
||||
/**
|
||||
* E-mail can be null, if it's requested for a user other than the authenticated one
|
||||
* and the user isn't an admin
|
||||
*/
|
||||
email?: Maybe<Scalars['String']>;
|
||||
id: Scalars['ID'];
|
||||
/** Whether post-sign up onboarding has been finished or skipped entirely */
|
||||
isOnboardingFinished?: Maybe<Scalars['Boolean']>;
|
||||
name: Scalars['String'];
|
||||
profiles?: Maybe<Scalars['JSONObject']>;
|
||||
role?: Maybe<Scalars['String']>;
|
||||
verified?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type BlobMetadata = {
|
||||
__typename?: 'BlobMetadata';
|
||||
createdAt: Scalars['DateTime'];
|
||||
@@ -1584,6 +1635,7 @@ export type Query = {
|
||||
_?: Maybe<Scalars['String']>;
|
||||
/** Gets the profile of the authenticated user or null if not authenticated */
|
||||
activeUser?: Maybe<User>;
|
||||
admin: AdminQueries;
|
||||
/** All the streams of the server. Available to admins only. */
|
||||
adminStreams?: Maybe<StreamCollection>;
|
||||
/**
|
||||
@@ -1872,6 +1924,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