diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 70233abe3..8a2668443 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -151,6 +151,12 @@ export type AdminMutations = { __typename?: 'AdminMutations'; giveAccessToWorkspaceFeature: Scalars['Boolean']['output']; removeAccessToWorkspaceFeature: Scalars['Boolean']['output']; + /** + * A server administrator can update the verification status of an user's email. + * The server administrator is recommended to confirm ownership of the email address + * with the user before performing this action. + */ + updateEmailVerification: Scalars['Boolean']['output']; updateWorkspacePlan: Scalars['Boolean']['output']; }; @@ -165,6 +171,11 @@ export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = { }; +export type AdminMutationsUpdateEmailVerificationArgs = { + input: AdminUpdateEmailVerificationInput; +}; + + export type AdminMutationsUpdateWorkspacePlanArgs = { input: AdminUpdateWorkspacePlanInput; }; @@ -209,6 +220,12 @@ export type AdminQueriesWorkspaceListArgs = { query?: InputMaybe; }; +export type AdminUpdateEmailVerificationInput = { + email: Scalars['String']['input']; + /** Defaults to true. If set to false, the email will be marked as unverified. */ + verified?: InputMaybe; +}; + export type AdminUpdateWorkspacePlanInput = { plan: WorkspacePlans; status: WorkspacePlanStatuses; @@ -9306,6 +9323,7 @@ export type AdminInviteListFieldArgs = { export type AdminMutationsFieldArgs = { giveAccessToWorkspaceFeature: AdminMutationsGiveAccessToWorkspaceFeatureArgs, removeAccessToWorkspaceFeature: AdminMutationsRemoveAccessToWorkspaceFeatureArgs, + updateEmailVerification: AdminMutationsUpdateEmailVerificationArgs, updateWorkspacePlan: AdminMutationsUpdateWorkspacePlanArgs, } export type AdminQueriesFieldArgs = { diff --git a/packages/server/assets/core/typedefs/admin.graphql b/packages/server/assets/core/typedefs/admin.graphql index 48eb7b830..36b6f66e1 100644 --- a/packages/server/assets/core/typedefs/admin.graphql +++ b/packages/server/assets/core/typedefs/admin.graphql @@ -32,6 +32,14 @@ type ProjectCollection { items: [Project!]! } +input AdminUpdateEmailVerificationInput { + email: String! + """ + Defaults to true. If set to false, the email will be marked as unverified. + """ + verified: Boolean +} + type AdminQueries { userList( limit: Int! = 25 @@ -57,6 +65,19 @@ type AdminQueries { serverStatistics: ServerStatistics! @hasScope(scope: "server:stats") } +type AdminMutations { + """ + A server administrator can update the verification status of an user's email. + The server administrator is recommended to confirm ownership of the email address + with the user before performing this action. + """ + updateEmailVerification(input: AdminUpdateEmailVerificationInput!): Boolean! +} + extend type Query { admin: AdminQueries! @hasServerRole(role: SERVER_ADMIN) } + +extend type Mutation { + admin: AdminMutations! @hasServerRole(role: SERVER_ADMIN) +} diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index e8b488736..f9104fbf6 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -679,10 +679,6 @@ extend type Subscription { @hasScope(scope: "workspace:read") } -extend type Mutation { - admin: AdminMutations! @hasServerRole(role: SERVER_ADMIN) -} - input AdminUpdateWorkspacePlanInput { workspaceId: ID! plan: WorkspacePlans! @@ -700,7 +696,7 @@ input AdminAccessToWorkspaceFeatureInput { featureFlagName: WorkspaceFeatureFlagName! } -type AdminMutations { +extend type AdminMutations { updateWorkspacePlan(input: AdminUpdateWorkspacePlanInput!): Boolean! giveAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean! removeAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean! diff --git a/packages/server/modules/core/domain/users/operations.ts b/packages/server/modules/core/domain/users/operations.ts index ade0c69eb..b1397ecb4 100644 --- a/packages/server/modules/core/domain/users/operations.ts +++ b/packages/server/modules/core/domain/users/operations.ts @@ -87,6 +87,16 @@ export type UpdateUserServerRole = (params: { role: ServerRoles }) => Promise +export type AdminUpdateEmailVerification = (args: { + email: string + verified?: MaybeNullOrUndefined +}) => Promise + +export type UpdateUserEmailVerification = (params: { + email: string + verified: boolean +}) => Promise + export type MarkUserAsVerified = (email: string) => Promise export type MarkOnboardingComplete = (userId: string) => Promise diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index fd7fd7e4b..49f83d047 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -173,6 +173,12 @@ export type AdminMutations = { __typename?: 'AdminMutations'; giveAccessToWorkspaceFeature: Scalars['Boolean']['output']; removeAccessToWorkspaceFeature: Scalars['Boolean']['output']; + /** + * A server administrator can update the verification status of an user's email. + * The server administrator is recommended to confirm ownership of the email address + * with the user before performing this action. + */ + updateEmailVerification: Scalars['Boolean']['output']; updateWorkspacePlan: Scalars['Boolean']['output']; }; @@ -187,6 +193,11 @@ export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = { }; +export type AdminMutationsUpdateEmailVerificationArgs = { + input: AdminUpdateEmailVerificationInput; +}; + + export type AdminMutationsUpdateWorkspacePlanArgs = { input: AdminUpdateWorkspacePlanInput; }; @@ -231,6 +242,12 @@ export type AdminQueriesWorkspaceListArgs = { query?: InputMaybe; }; +export type AdminUpdateEmailVerificationInput = { + email: Scalars['String']['input']; + /** Defaults to true. If set to false, the email will be marked as unverified. */ + verified?: InputMaybe; +}; + export type AdminUpdateWorkspacePlanInput = { plan: WorkspacePlans; status: WorkspacePlanStatuses; @@ -6149,6 +6166,7 @@ export type ResolversTypes = { AdminInviteList: ResolverTypeWrapper & { items: Array }>; AdminMutations: ResolverTypeWrapper; AdminQueries: ResolverTypeWrapper; + AdminUpdateEmailVerificationInput: AdminUpdateEmailVerificationInput; AdminUpdateWorkspacePlanInput: AdminUpdateWorkspacePlanInput; AdminUserList: ResolverTypeWrapper; AdminUserListItem: ResolverTypeWrapper; @@ -6550,6 +6568,7 @@ export type ResolversParentTypes = { AdminInviteList: Omit & { items: Array }; AdminMutations: MutationsObjectGraphQLReturn; AdminQueries: GraphQLEmptyReturn; + AdminUpdateEmailVerificationInput: AdminUpdateEmailVerificationInput; AdminUpdateWorkspacePlanInput: AdminUpdateWorkspacePlanInput; AdminUserList: AdminUserList; AdminUserListItem: AdminUserListItem; @@ -7009,6 +7028,7 @@ export type AdminInviteListResolvers = { giveAccessToWorkspaceFeature?: Resolver>; removeAccessToWorkspaceFeature?: Resolver>; + updateEmailVerification?: Resolver>; updateWorkspacePlan?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -9438,6 +9458,13 @@ export type SetLegacyProjectsExplainerCollapsedMutationVariables = Exact<{ export type SetLegacyProjectsExplainerCollapsedMutation = { __typename?: 'Mutation', activeUserMutations: { __typename?: 'ActiveUserMutations', meta: { __typename?: 'UserMetaMutations', setLegacyProjectsExplainerCollapsed: boolean } } }; +export type AdminMutationsMutationVariables = Exact<{ + input: AdminUpdateEmailVerificationInput; +}>; + + +export type AdminMutationsMutation = { __typename?: 'Mutation', admin: { __typename?: 'AdminMutations', updateEmailVerification: boolean } }; + export type LimitedPersonalProjectCommentFragment = { __typename?: 'Comment', id: string, rawText?: string | null, createdAt: Date, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | null, type: string } | null }; export type LimitedPersonalProjectVersionFragment = { __typename?: 'Version', id: string, createdAt: Date, message?: string | null, referencedObject?: string | null, commentThreads: { __typename?: 'CommentCollection', totalCount: number, items: Array<{ __typename?: 'Comment', id: string, rawText?: string | null, createdAt: Date, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | null, type: string } | null }> } }; @@ -10776,6 +10803,7 @@ export const GetIntelligenceCommunityStandUpBannerDismissedDocument = {"kind":"D export const SetIntelligenceCommunityStandUpBannerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetIntelligenceCommunityStandUpBannerDismissed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setIntelligenceCommunityStandUpBannerDismissed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; export const GetLegacyProjectsExplainerCollapsedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLegacyProjectsExplainerCollapsed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"legacyProjectsExplainerCollapsed"}}]}}]}}]}}]} as unknown as DocumentNode; export const SetLegacyProjectsExplainerCollapsedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetLegacyProjectsExplainerCollapsed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setLegacyProjectsExplainerCollapsed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; +export const AdminMutationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AdminMutations"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminUpdateEmailVerificationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"admin"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateEmailVerification"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; export const GetLimitedPersonalProjectVersionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalProjectVersions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Version"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"commentThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetLimitedPersonalProjectVersionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalProjectVersion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Version"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"commentThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetLimitedPersonalStreamCommitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalStreamCommits"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalStreamCommit"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalStreamCommit"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Commit"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/modules/core/graph/resolvers/admin.ts b/packages/server/modules/core/graph/resolvers/admin.ts index 7c5151dd3..edd762f44 100644 --- a/packages/server/modules/core/graph/resolvers/admin.ts +++ b/packages/server/modules/core/graph/resolvers/admin.ts @@ -1,33 +1,43 @@ -import { db } from '@/db/knex' +import { db as mainDb } from '@/db/knex' import type { Resolvers } from '@/modules/core/graph/generated/graphql' import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes' import { toProjectIdWhitelist } from '@/modules/core/helpers/token' import { legacyGetStreamsFactory } from '@/modules/core/repositories/streams' -import { countUsersFactory, listUsersFactory } from '@/modules/core/repositories/users' import { + countUsersFactory, + listUsersFactory, + updateUserEmailVerificationFactory +} from '@/modules/core/repositories/users' +import { + adminUpdateEmailVerificationFactory, adminInviteListFactory, adminProjectListFactory, adminUserListFactory } from '@/modules/core/services/admin' +import { deleteVerificationsFactory } from '@/modules/emails/repositories' import { countServerInvitesFactory, queryServerInvitesFactory } from '@/modules/serverinvites/repositories/serverInvites' +import { asMultiregionalOperation } from '@/modules/shared/command' import { getTotalStreamCountFactory, getTotalUserCountFactory } from '@/modules/stats/repositories' +import { updateUserEmailFactory } from '@/modules/core/repositories/userEmails' +import { ensureError } from '@speckle/shared' +import { getAllRegisteredDbs } from '@/modules/multiregion/utils/dbSelector' const adminUserList = adminUserListFactory({ - listUsers: listUsersFactory({ db }), - countUsers: countUsersFactory({ db }) + listUsers: listUsersFactory({ db: mainDb }), + countUsers: countUsersFactory({ db: mainDb }) }) const adminInviteList = adminInviteListFactory({ - countServerInvites: countServerInvitesFactory({ db }), - queryServerInvites: queryServerInvitesFactory({ db }) + countServerInvites: countServerInvitesFactory({ db: mainDb }), + queryServerInvites: queryServerInvitesFactory({ db: mainDb }) }) const adminProjectList = adminProjectListFactory({ - getStreams: legacyGetStreamsFactory({ db }) + getStreams: legacyGetStreamsFactory({ db: mainDb }) }) export default { @@ -58,13 +68,56 @@ export default { return await adminInviteList(args) } }, + Mutation: { + admin: () => ({}) + }, + AdminMutations: { + async updateEmailVerification(_parent, args, ctx) { + try { + return await asMultiregionalOperation( + async ({ mainDb, allDbs }) => { + const updateEmailVerification = adminUpdateEmailVerificationFactory({ + deleteVerifications: deleteVerificationsFactory({ db: mainDb }), + // this updates the users table + updateUserVerification: async (...params) => { + const emailVerified = await Promise.all( + allDbs.map((db) => + updateUserEmailVerificationFactory({ + db + })(...params) + ) + ) + return emailVerified.every(Boolean) + }, + // this updates the user_emails table + updateEmail: updateUserEmailFactory({ db: mainDb }) + }) + + return await updateEmailVerification({ + email: args.input.email.toLowerCase().trim(), + verified: args.input.verified + }) + }, + { + logger: ctx.log, + name: 'adminUpdateEmailVerification', + description: 'Email verification updated by a server admin', + dbs: await getAllRegisteredDbs() + } + ) + } catch (e) { + const err = ensureError(e, 'Unknown error while updating email verification') + ctx.log.info({ err }, 'Email verification by Admin failed.') + } + } + }, ServerStatistics: { async totalProjectCount() { - return await getTotalStreamCountFactory({ db })() + return await getTotalStreamCountFactory({ db: mainDb })() }, async totalUserCount() { - return await getTotalUserCountFactory({ db })() + return await getTotalUserCountFactory({ db: mainDb })() }, async totalPendingInvites() { return 0 diff --git a/packages/server/modules/core/repositories/users.ts b/packages/server/modules/core/repositories/users.ts index 242d67da0..64fbea179 100644 --- a/packages/server/modules/core/repositories/users.ts +++ b/packages/server/modules/core/repositories/users.ts @@ -48,6 +48,7 @@ import type { StoreUser, StoreUserAcl, UpdateUser, + UpdateUserEmailVerification, UpdateUserServerRole } from '@/modules/core/domain/users/operations' import { removePrivateFields } from '@/modules/core/helpers/userHelper' @@ -219,14 +220,20 @@ export const getUserByEmailFactory = */ export const markUserAsVerifiedFactory = (deps: { db: Knex }): MarkUserAsVerified => - async (email: string) => { + async (email: string) => + updateUserEmailVerificationFactory(deps)({ email, verified: true }) + +export const updateUserEmailVerificationFactory = + (deps: { db: Knex }): UpdateUserEmailVerification => + async (args) => { + const { email, verified } = args const UserCols = Users.with({ withoutTablePrefix: true }).col const usersUpdate = await tables .users(deps.db) .whereRaw('lower(email) = lower(?)', [email]) .update({ - [UserCols.verified]: true + [UserCols.verified]: verified }) return !!usersUpdate diff --git a/packages/server/modules/core/services/admin.ts b/packages/server/modules/core/services/admin.ts index 160f5c011..2fb35dbc8 100644 --- a/packages/server/modules/core/services/admin.ts +++ b/packages/server/modules/core/services/admin.ts @@ -4,18 +4,22 @@ import type { } from '@/modules/core/domain/streams/operations' import type { AdminGetInviteList, + AdminUpdateEmailVerification, AdminUserList, CountUsers, - ListPaginatedUsersPage + ListPaginatedUsersPage, + UpdateUserEmailVerification } from '@/modules/core/domain/users/operations' import type { ProjectRecordVisibility } from '@/modules/core/helpers/types' +import type { DeleteVerifications } from '@/modules/emails/domain/operations' import type { CountServerInvites, QueryServerInvites } from '@/modules/serverinvites/domain/operations' import type { ServerInviteRecord } from '@/modules/serverinvites/domain/types' import { BaseError } from '@/modules/shared/errors/base' -import type { Nullable } from '@speckle/shared' +import { type Nullable } from '@speckle/shared' +import type { UpdateUserEmail } from '@/modules/core/domain/userEmails/operations' class CursorParsingError extends BaseError { static defaultMessage = 'Invalid cursor provided' @@ -103,3 +107,36 @@ export const adminProjectListFactory = totalCount } } + +export const adminUpdateEmailVerificationFactory = + (deps: { + deleteVerifications: DeleteVerifications + updateUserVerification: UpdateUserEmailVerification + updateEmail: UpdateUserEmail + }): AdminUpdateEmailVerification => + async (args) => { + const { email } = args + let { verified } = args + if (verified === undefined || verified === null) { + verified = true + } + + if (verified) { + await deps.deleteVerifications(email) + } + + const result = await Promise.all([ + // this updates the 'users' table + deps.updateUserVerification({ + email, + verified + }), + // this updates the 'user_emails' table + deps.updateEmail({ + query: { email }, + update: { verified } + }) + ]) + + return result[0] && !!result[1]?.verified + } diff --git a/packages/server/modules/core/tests/helpers/graphql.ts b/packages/server/modules/core/tests/helpers/graphql.ts index 2d25ed860..09438fdb9 100644 --- a/packages/server/modules/core/tests/helpers/graphql.ts +++ b/packages/server/modules/core/tests/helpers/graphql.ts @@ -269,3 +269,11 @@ export const setLegacyProjectsExplainerCollapsedMutation = gql` } } ` + +export const updateEmailVerificationMutation = gql` + mutation AdminMutations($input: AdminUpdateEmailVerificationInput!) { + admin { + updateEmailVerification(input: $input) + } + } +` diff --git a/packages/server/modules/core/tests/integration/admin.graph.spec.ts b/packages/server/modules/core/tests/integration/admin.graph.spec.ts new file mode 100644 index 000000000..a30523b36 --- /dev/null +++ b/packages/server/modules/core/tests/integration/admin.graph.spec.ts @@ -0,0 +1,147 @@ +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' +import type { ExecuteOperationResponse, TestApolloServer } from '@/test/graphqlHelper' +import { testApolloServer } from '@/test/graphqlHelper' +import { beforeEachContext } from '@/test/hooks' +import { expect } from 'chai' +import { Roles } from '@speckle/shared' +import { AdminMutationsDocument } from '@/modules/core/graph/generated/graphql' +import { createRandomEmail } from '@/modules/core/helpers/testHelpers' +import gql from 'graphql-tag' + +const testForbiddenResponse = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result: ExecuteOperationResponse> +) => { + expect(result.errors, 'This should have failed').to.exist + expect(result.errors!.length).to.be.above(0) + expect(result.errors![0].extensions!.code, JSON.stringify(result.errors)).to.match( + /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED_ACCESS_ERROR)/ + ) +} + +const getActiveUserVerifiedQuery = gql` + query GetActiveUser { + activeUser { + verified + emails { + verified + } + } + } +` + +describe('Admin @core-admin Graphql', () => { + const serverAdminUser: BasicTestUser = { + id: '', + email: createRandomEmail(), + name: 'I am Admin', + role: Roles.Server.Admin + } + + const regularServerUser = { + id: '', + email: createRandomEmail(), + name: 'regular server user', + role: Roles.Server.User + } + const archivedUser = { + id: '', + email: createRandomEmail(), + name: 'archived user', + role: Roles.Server.ArchivedUser + } + const unaffiliatedUser = { + id: '', + email: createRandomEmail(), + name: 'unaffiliated user', + role: Roles.Server.Guest + } + + before(async () => { + await beforeEachContext() + await createTestUser(serverAdminUser) + await createTestUser(regularServerUser) + await createTestUser(archivedUser) + await createTestUser(unaffiliatedUser) + }) + + describe('updateEmailVerification', () => { + describe('when attempting to verify another users email', () => { + const testCases = [ + { user: serverAdminUser, canVerify: true }, + { user: regularServerUser, canVerify: false }, + { user: archivedUser, canVerify: false }, + { user: unaffiliatedUser, canVerify: false } + ] + + testCases.forEach(({ user: testUser, canVerify }) => { + let apollo: TestApolloServer + before(async () => { + // we are purposefully not using the helper to create the token, as we want to test with multiple users + apollo = await testApolloServer() + }) + + it(`a ${testUser.role} is ${canVerify ? 'allowed' : 'forbidden'}`, async () => { + const userToVerify = { + id: '', + email: createRandomEmail(), + name: 'unverified user', + role: Roles.Server.User, + verified: false + } + await createTestUser(userToVerify) + + const preCheckRes = await apollo.execute( + getActiveUserVerifiedQuery, + {}, + { + authUserId: userToVerify.id, + assertNoErrors: true + } + ) + expect(preCheckRes.data?.activeUser).to.exist + expect(preCheckRes.data?.activeUser?.verified).to.equal(false) + expect( + preCheckRes.data?.activeUser.emails.some( + (email: { verified: boolean }) => email.verified + ) + ).to.be.false + + const verifyRes = await apollo.execute( + AdminMutationsDocument, + { + input: { email: userToVerify.email } + }, + { + authUserId: testUser.id // auth as the test user + } + ) + if (!canVerify) { + testForbiddenResponse(verifyRes) + return + } + + expect(verifyRes).to.not.haveGraphQLErrors() + expect(verifyRes.data?.admin.updateEmailVerification).to.equal(canVerify) + + const postCheckRes = await apollo.execute( + getActiveUserVerifiedQuery, + {}, + { + authUserId: userToVerify.id, + assertNoErrors: true + } + ) + expect(postCheckRes.data?.activeUser.verified).to.equal(canVerify) + expect(postCheckRes.data?.activeUser.emails).to.have.length(1) + expect( + postCheckRes.data?.activeUser.emails.every( + (email: { verified: boolean }) => email.verified + ) + ).to.equal(canVerify) + }) + }) + }) + }) +}) diff --git a/packages/server/modules/core/tests/integration/admin.spec.ts b/packages/server/modules/core/tests/integration/admin.spec.ts new file mode 100644 index 000000000..00aa49996 --- /dev/null +++ b/packages/server/modules/core/tests/integration/admin.spec.ts @@ -0,0 +1,123 @@ +import { + findEmailFactory, + updateUserEmailFactory +} from '@/modules/core/repositories/userEmails' +import { db } from '@/db/knex' +import { + createRandomEmail, + createRandomPassword +} from '@/modules/core/helpers/testHelpers' +import { expect } from 'chai' +import { createTestUser } from '@/test/authHelper' +import { adminUpdateEmailVerificationFactory } from '@/modules/core/services/admin' +import { + getUserFactory, + updateUserEmailVerificationFactory +} from '@/modules/core/repositories/users' +import { + deleteVerificationsFactory, + getPendingVerificationByEmailFactory +} from '@/modules/emails/repositories' + +describe('Admin @core-admin', () => { + it('can mark users as verified', async () => { + const email = createRandomEmail() + + const testUser = await createTestUser({ + name: 'John', + email, + password: createRandomPassword(), + verified: false + }) + + await adminUpdateEmailVerificationFactory({ + updateEmail: updateUserEmailFactory({ db }), + deleteVerifications: deleteVerificationsFactory({ db }), + updateUserVerification: updateUserEmailVerificationFactory({ db }) + })({ + email + //verified: true // defaults to true + }) + + const userEmail = await findEmailFactory({ db })({ email }) + expect(userEmail).to.be.ok + expect(userEmail!.verified).to.be.true + + const pendingVerifications = await getPendingVerificationByEmailFactory({ + db, + verificationTimeoutMinutes: 100 // minutes; we don't care for this test + })({ email }) + expect(pendingVerifications).to.be.undefined + + const user = await getUserFactory({ db })(testUser.id) + expect(user).to.be.ok + expect(user!.verified).to.be.true + }) + + it('idempotent when marking already verified users as verified', async () => { + const email = createRandomEmail() + + const testUser = await createTestUser({ + name: 'John', + email, + password: createRandomPassword(), + verified: true + }) + + await adminUpdateEmailVerificationFactory({ + updateEmail: updateUserEmailFactory({ db }), + deleteVerifications: deleteVerificationsFactory({ db }), + updateUserVerification: updateUserEmailVerificationFactory({ db }) + })({ + email, + verified: true + }) + + const userEmail = await findEmailFactory({ db })({ email }) + expect(userEmail).to.be.ok + expect(userEmail!.verified).to.be.true + + const pendingVerifications = await getPendingVerificationByEmailFactory({ + db, + verificationTimeoutMinutes: 100 // minutes + })({ email }) + expect(pendingVerifications).to.be.undefined + + const user = await getUserFactory({ db })(testUser.id) + expect(user).to.be.ok + expect(user!.verified).to.be.true + }) + it('can mark verified users as unverified', async () => { + const email = createRandomEmail() + + const testUser = await createTestUser({ + name: 'John', + email, + password: createRandomPassword(), + verified: true + }) + + await adminUpdateEmailVerificationFactory({ + updateEmail: updateUserEmailFactory({ db }), + deleteVerifications: deleteVerificationsFactory({ db }), + updateUserVerification: updateUserEmailVerificationFactory({ db }) + })({ + email, + verified: false + }) + + const userEmail = await findEmailFactory({ db })({ email }) + expect(userEmail).to.be.ok + expect(userEmail!.verified).to.be.false + + const pendingVerifications = await getPendingVerificationByEmailFactory({ + db, + verificationTimeoutMinutes: 100 // minutes + })({ email }) + expect(pendingVerifications).to.be.undefined + + const user = await getUserFactory({ db })(testUser.id) + expect(user).to.be.ok + expect(user!.verified).to.be.false + }) +})