feat(core/admin): server admin can mark user email as being verified (#5482)

This commit is contained in:
Iain Sproat
2025-09-24 12:17:30 +01:00
committed by GitHub
parent e0ac72070a
commit ec59dd160a
11 changed files with 466 additions and 18 deletions
@@ -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<Scalars['String']['input']>;
};
export type AdminUpdateEmailVerificationInput = {
email: Scalars['String']['input'];
/** Defaults to true. If set to false, the email will be marked as unverified. */
verified?: InputMaybe<Scalars['Boolean']['input']>;
};
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 = {
@@ -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)
}
@@ -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!
@@ -87,6 +87,16 @@ export type UpdateUserServerRole = (params: {
role: ServerRoles
}) => Promise<boolean>
export type AdminUpdateEmailVerification = (args: {
email: string
verified?: MaybeNullOrUndefined<boolean>
}) => Promise<boolean>
export type UpdateUserEmailVerification = (params: {
email: string
verified: boolean
}) => Promise<boolean>
export type MarkUserAsVerified = (email: string) => Promise<boolean>
export type MarkOnboardingComplete = (userId: string) => Promise<boolean>
@@ -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<Scalars['String']['input']>;
};
export type AdminUpdateEmailVerificationInput = {
email: Scalars['String']['input'];
/** Defaults to true. If set to false, the email will be marked as unverified. */
verified?: InputMaybe<Scalars['Boolean']['input']>;
};
export type AdminUpdateWorkspacePlanInput = {
plan: WorkspacePlans;
status: WorkspacePlanStatuses;
@@ -6149,6 +6166,7 @@ export type ResolversTypes = {
AdminInviteList: ResolverTypeWrapper<Omit<AdminInviteList, 'items'> & { items: Array<ResolversTypes['ServerInvite']> }>;
AdminMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
AdminQueries: ResolverTypeWrapper<GraphQLEmptyReturn>;
AdminUpdateEmailVerificationInput: AdminUpdateEmailVerificationInput;
AdminUpdateWorkspacePlanInput: AdminUpdateWorkspacePlanInput;
AdminUserList: ResolverTypeWrapper<AdminUserList>;
AdminUserListItem: ResolverTypeWrapper<AdminUserListItem>;
@@ -6550,6 +6568,7 @@ export type ResolversParentTypes = {
AdminInviteList: Omit<AdminInviteList, 'items'> & { items: Array<ResolversParentTypes['ServerInvite']> };
AdminMutations: MutationsObjectGraphQLReturn;
AdminQueries: GraphQLEmptyReturn;
AdminUpdateEmailVerificationInput: AdminUpdateEmailVerificationInput;
AdminUpdateWorkspacePlanInput: AdminUpdateWorkspacePlanInput;
AdminUserList: AdminUserList;
AdminUserListItem: AdminUserListItem;
@@ -7009,6 +7028,7 @@ export type AdminInviteListResolvers<ContextType = GraphQLContext, ParentType ex
export type AdminMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminMutations'] = ResolversParentTypes['AdminMutations']> = {
giveAccessToWorkspaceFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsGiveAccessToWorkspaceFeatureArgs, 'input'>>;
removeAccessToWorkspaceFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsRemoveAccessToWorkspaceFeatureArgs, 'input'>>;
updateEmailVerification?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsUpdateEmailVerificationArgs, 'input'>>;
updateWorkspacePlan?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsUpdateWorkspacePlanArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -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<string, unknown> | 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<string, unknown> | 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<SetIntelligenceCommunityStandUpBannerDismissedMutation, SetIntelligenceCommunityStandUpBannerDismissedMutationVariables>;
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<GetLegacyProjectsExplainerCollapsedQuery, GetLegacyProjectsExplainerCollapsedQueryVariables>;
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<SetLegacyProjectsExplainerCollapsedMutation, SetLegacyProjectsExplainerCollapsedMutationVariables>;
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<AdminMutationsMutation, AdminMutationsMutationVariables>;
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<GetLimitedPersonalProjectVersionsQuery, GetLimitedPersonalProjectVersionsQueryVariables>;
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<GetLimitedPersonalProjectVersionQuery, GetLimitedPersonalProjectVersionQueryVariables>;
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<GetLimitedPersonalStreamCommitsQuery, GetLimitedPersonalStreamCommitsQueryVariables>;
@@ -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
@@ -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
+39 -2
View File
@@ -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
}
@@ -269,3 +269,11 @@ export const setLegacyProjectsExplainerCollapsedMutation = gql`
}
}
`
export const updateEmailVerificationMutation = gql`
mutation AdminMutations($input: AdminUpdateEmailVerificationInput!) {
admin {
updateEmailVerification(input: $input)
}
}
`
@@ -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<Record<string, any>>
) => {
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)
})
})
})
})
})
@@ -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
})
})