diff --git a/packages/backend/server/src/core/auth/config.ts b/packages/backend/server/src/core/auth/config.ts index b63b888a8e..658e8e8b8b 100644 --- a/packages/backend/server/src/core/auth/config.ts +++ b/packages/backend/server/src/core/auth/config.ts @@ -39,6 +39,12 @@ export interface AuthRuntimeConfigurations { * Whether allow anonymous users to sign up */ allowSignup: boolean; + + /** + * Whether require email verification before access restricted resources + */ + requireEmailVerification: boolean; + /** * The minimum and maximum length of the password when registering new users */ @@ -70,6 +76,10 @@ defineRuntimeConfig('auth', { desc: 'Whether allow new registrations', default: true, }, + requireEmailVerification: { + desc: 'Whether require email verification before accessing restricted resources', + default: true, + }, 'password.min': { desc: 'The minimum length of user password', default: 8, diff --git a/packages/backend/server/src/core/features/resolver.ts b/packages/backend/server/src/core/features/resolver.ts index 2be5425811..65a4496eaf 100644 --- a/packages/backend/server/src/core/features/resolver.ts +++ b/packages/backend/server/src/core/features/resolver.ts @@ -4,13 +4,13 @@ import { Context, Int, Mutation, + Parent, Query, registerEnumType, ResolveField, Resolver, } from '@nestjs/graphql'; -import { CurrentUser } from '../auth/current-user'; import { sessionUser } from '../auth/service'; import { Admin } from '../common'; import { UserService } from '../user/service'; @@ -33,7 +33,7 @@ export class FeatureManagementResolver { name: 'features', description: 'Enabled features of a user', }) - async userFeatures(@CurrentUser() user: CurrentUser) { + async userFeatures(@Parent() user: UserType) { return this.feature.getActivatedUserFeatures(user.id); } diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 96591a82fd..86795ab9a8 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -1,6 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { Args, + Field, + InputType, Int, Mutation, Query, @@ -11,11 +13,12 @@ import { PrismaClient } from '@prisma/client'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { isNil, omitBy } from 'lodash-es'; -import type { FileUpload } from '../../fundamentals'; -import { EventEmitter, Throttle } from '../../fundamentals'; +import type { Config, CryptoHelper, FileUpload } from '../../fundamentals'; +import { Throttle } from '../../fundamentals'; import { CurrentUser } from '../auth/current-user'; import { Public } from '../auth/guard'; import { sessionUser } from '../auth/service'; +import { Admin } from '../common'; import { AvatarStorage } from '../storage'; import { validators } from '../utils/validators'; import { UserService } from './service'; @@ -32,8 +35,7 @@ export class UserResolver { constructor( private readonly prisma: PrismaClient, private readonly storage: AvatarStorage, - private readonly users: UserService, - private readonly event: EventEmitter + private readonly users: UserService ) {} @Throttle('strict') @@ -132,8 +134,113 @@ export class UserResolver { async deleteAccount( @CurrentUser() user: CurrentUser ): Promise { - const deletedUser = await this.users.deleteUser(user.id); - this.event.emit('user.deleted', deletedUser); + await this.users.deleteUser(user.id); + return { success: true }; + } +} + +@InputType() +class ListUserInput { + @Field(() => Int, { nullable: true, defaultValue: 0 }) + skip!: number; + + @Field(() => Int, { nullable: true, defaultValue: 20 }) + first!: number; +} + +@InputType() +class CreateUserInput { + @Field(() => String) + email!: string; + + @Field(() => String, { nullable: true }) + name!: string | null; + + @Field(() => String, { nullable: true }) + password!: string | null; + + @Field(() => Boolean, { nullable: true, defaultValue: true }) + requireEmailVerification!: boolean; +} + +@Admin() +@Resolver(() => UserType) +export class UserManagementResolver { + constructor( + private readonly db: PrismaClient, + private readonly user: UserService, + private readonly crypto: CryptoHelper, + private readonly config: Config + ) {} + + @Query(() => [UserType], { + description: 'List registered users', + }) + async users( + @Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput + ): Promise { + const users = await this.db.user.findMany({ + select: { ...this.user.defaultUserSelect, password: true }, + skip: input.skip, + take: input.first, + }); + + return users.map(sessionUser); + } + + @Query(() => UserType, { + name: 'userById', + description: 'Get user by id', + }) + async getUser(@Args('id') id: string) { + const user = await this.db.user.findUnique({ + select: { ...this.user.defaultUserSelect, password: true }, + where: { + id, + }, + }); + + if (!user) { + return null; + } + + return sessionUser(user); + } + + @Mutation(() => UserType, { + description: 'Create a new user', + }) + async createUser( + @Args({ name: 'input', type: () => CreateUserInput }) input: CreateUserInput + ) { + validators.assertValidEmail(input.email); + if (input.password) { + const config = await this.config.runtime.fetchAll({ + 'auth/password.max': true, + 'auth/password.min': true, + }); + validators.assertValidPassword(input.password, { + max: config['auth/password.max'], + min: config['auth/password.min'], + }); + } + + const { id } = await this.user.createAnonymousUser(input.email, { + password: input.password + ? await this.crypto.encryptPassword(input.password) + : undefined, + registered: true, + }); + + // data returned by `createUser` does not satisfies `UserType` + return this.getUser(id); + } + + @Mutation(() => DeleteAccount, { + description: 'Delete a user account', + }) + async deleteUser(@Args('id') id: string): Promise { + await this.user.deleteUser(id); return { success: true }; } } diff --git a/packages/backend/server/src/core/user/service.ts b/packages/backend/server/src/core/user/service.ts index d854360810..e111767204 100644 --- a/packages/backend/server/src/core/user/service.ts +++ b/packages/backend/server/src/core/user/service.ts @@ -170,7 +170,8 @@ export class UserService { } async deleteUser(id: string) { - return this.prisma.user.delete({ where: { id } }); + const user = await this.prisma.user.delete({ where: { id } }); + this.emitter.emit('user.deleted', user); } @OnEvent('user.updated')