add account deactivation
This commit is contained in:
parent
7724bc460f
commit
706387b9ad
@ -20,6 +20,8 @@ model User {
|
||||
isVerified Boolean @default(false) @map("is_verified")
|
||||
isEmailVerified Boolean @default(false) @map("is_email_verified")
|
||||
isTotpEnabled Boolean @default(false) @map("is_totp_enabled")
|
||||
isDeactivated Boolean @default(false) @map("is_deactivated")
|
||||
deactivatedAt DateTime? @map("deactivated_at")
|
||||
totpSecret String? @map("totp_secret")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@ -43,6 +45,7 @@ model Token {
|
||||
enum TokenType {
|
||||
EMAIL_VERIFY
|
||||
PASSWORD_RESET
|
||||
DEACTIVATE_ACCOUNT
|
||||
|
||||
@@map("token_types")
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { getGraphQLConfig } from '@/src/core/config/graphql.config'
|
||||
import { AccountModule } from '@/src/module/auth/account/account.module'
|
||||
import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module';
|
||||
import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module'
|
||||
import { SessionModule } from '@/src/module/auth/session/session.module'
|
||||
import { TotpModule } from '@/src/module/auth/totp/totp.module'
|
||||
@ -33,6 +34,7 @@ import { RedisModule } from './redis/redis.module'
|
||||
VerificationModule,
|
||||
PasswordRecoveryModule,
|
||||
TotpModule,
|
||||
DeactivateModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
||||
@ -18,6 +18,12 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
|
||||
"""
|
||||
scalar DateTime
|
||||
|
||||
input DeactivateAccountInput {
|
||||
email: String!
|
||||
password: String!
|
||||
pin: String
|
||||
}
|
||||
|
||||
type DeviceModel {
|
||||
browser: String!
|
||||
os: String!
|
||||
@ -45,6 +51,7 @@ input LoginInput {
|
||||
type Mutation {
|
||||
clearSessionCookie: Boolean!
|
||||
createUser(data: CreateUserInput!): UserModel!
|
||||
deactivateAccount(data: DeactivateAccountInput!): AuthModel!
|
||||
disableTotp: Boolean!
|
||||
enableTotp(data: EnableTotpInput!): Boolean!
|
||||
loginUser(data: LoginInput!): AuthModel!
|
||||
@ -94,9 +101,11 @@ type UserModel {
|
||||
avatar: String
|
||||
bio: String
|
||||
createdAt: DateTime!
|
||||
deactivatedAt: DateTime
|
||||
displayName: String!
|
||||
email: String!
|
||||
id: ID!
|
||||
isDeactivated: Boolean!
|
||||
isEmailVerified: Boolean!
|
||||
isTotpEnabled: Boolean!
|
||||
isVerified: Boolean!
|
||||
|
||||
@ -33,6 +33,12 @@ export class UserModel implements User {
|
||||
@Field(() => Boolean)
|
||||
isTotpEnabled: boolean
|
||||
|
||||
@Field(() => Boolean)
|
||||
isDeactivated: boolean
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
deactivatedAt: Date
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
totpSecret: string
|
||||
|
||||
|
||||
8
backend/src/module/auth/deactivate/deactivate.module.ts
Normal file
8
backend/src/module/auth/deactivate/deactivate.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DeactivateService } from './deactivate.service';
|
||||
import { DeactivateResolver } from './deactivate.resolver';
|
||||
|
||||
@Module({
|
||||
providers: [DeactivateResolver, DeactivateService],
|
||||
})
|
||||
export class DeactivateModule {}
|
||||
25
backend/src/module/auth/deactivate/deactivate.resolver.ts
Normal file
25
backend/src/module/auth/deactivate/deactivate.resolver.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { AuthModel } from '@/src/module/auth/account/models/auth.model'
|
||||
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input'
|
||||
import { Authorization } from '@/src/shared/decorators/auth.decorator'
|
||||
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
|
||||
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'
|
||||
import { GqlContext } from '@/src/shared/types/gql-context.types'
|
||||
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql'
|
||||
import { User } from '@prisma/generated'
|
||||
import { DeactivateService } from './deactivate.service'
|
||||
|
||||
@Resolver('Deactivate')
|
||||
export class DeactivateResolver {
|
||||
constructor(private readonly deactivateService: DeactivateService) {}
|
||||
|
||||
@Authorization()
|
||||
@Mutation(() => AuthModel, { name: 'deactivateAccount' })
|
||||
public async deactivate(
|
||||
@Context() { req }: GqlContext,
|
||||
@Args('data') input: DeactivateAccountInput,
|
||||
@UserAgent() userAgent: string,
|
||||
@Authorized() user: User,
|
||||
) {
|
||||
return this.deactivateService.deactivate(req, input, user, userAgent)
|
||||
}
|
||||
}
|
||||
94
backend/src/module/auth/deactivate/deactivate.service.ts
Normal file
94
backend/src/module/auth/deactivate/deactivate.service.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { PrismaService } from '@/src/core/prisma/prisma.service'
|
||||
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input'
|
||||
import { MailService } from '@/src/module/libs/mail/mail.service'
|
||||
import { SessionInfo } from '@/src/shared/types/session-metadata.types'
|
||||
import { generateToken } from '@/src/shared/util/generate-token.util'
|
||||
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'
|
||||
import { destroySession } from '@/src/shared/util/session.util'
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { TokenType, User } from '@prisma/generated'
|
||||
import { verify } from 'argon2'
|
||||
import { Request } from 'express'
|
||||
|
||||
@Injectable()
|
||||
export class DeactivateService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mailService: MailService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async deactivate(req: Request, input: DeactivateAccountInput, user: User, userAgent: string) {
|
||||
const { email, password, pin } = input
|
||||
|
||||
if (email !== user.email) {
|
||||
throw new BadRequestException('Неверная почта')
|
||||
}
|
||||
|
||||
const isValidPassword = await verify(user.password, password)
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new BadRequestException('Неверный пароль')
|
||||
}
|
||||
|
||||
if (!pin) {
|
||||
await this.sendDeactivationToken(req, user, userAgent)
|
||||
return { message: 'Требуется ввести код подтверждения' }
|
||||
}
|
||||
|
||||
await this.validateDeactivateToken(req, pin)
|
||||
|
||||
return { user }
|
||||
}
|
||||
|
||||
private async validateDeactivateToken(req: Request, token: string) {
|
||||
const existingToken = await this.prismaService.token.findUnique({
|
||||
where: { token, type: TokenType.DEACTIVATE_ACCOUNT },
|
||||
})
|
||||
|
||||
if (!existingToken) {
|
||||
throw new NotFoundException('Токен не найден')
|
||||
}
|
||||
|
||||
const hasExpired = new Date(existingToken.expiresIn) < new Date()
|
||||
|
||||
if (hasExpired) {
|
||||
throw new BadRequestException('Токен истек')
|
||||
}
|
||||
|
||||
await this.prismaService.user.update({
|
||||
where: {
|
||||
id: existingToken.userId!, // todo fixme
|
||||
},
|
||||
data: {
|
||||
isDeactivated: true,
|
||||
deactivatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await this.prismaService.token.delete({
|
||||
where: {
|
||||
id: existingToken.id,
|
||||
type: TokenType.DEACTIVATE_ACCOUNT,
|
||||
},
|
||||
})
|
||||
|
||||
return destroySession(req, this.configService)
|
||||
}
|
||||
|
||||
public async sendDeactivationToken(req: Request, user: User, userAgent: string) {
|
||||
const deactivateToken = await generateToken(
|
||||
this.prismaService,
|
||||
user,
|
||||
TokenType.DEACTIVATE_ACCOUNT,
|
||||
false,
|
||||
)
|
||||
|
||||
const metadata = getSessionMetadata(req, userAgent)
|
||||
await this.mailService.sendDeactivateToken(user.email, deactivateToken.token, metadata)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import { Field, InputType } from '@nestjs/graphql'
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, Length, MinLength } from 'class-validator'
|
||||
|
||||
@InputType()
|
||||
export class DeactivateAccountInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string
|
||||
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password: string
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@Length(6, 6)
|
||||
pin: string
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { DeactivateTemplate } from '@/src/module/libs/mail/templates/deactivate.template'
|
||||
import PasswordRecoveryTemplate from '@/src/module/libs/mail/templates/password-recovery.template'
|
||||
import { SessionInfo } from '@/src/shared/types/session-metadata.types'
|
||||
import { Token } from '@prisma/generated'
|
||||
@ -18,16 +19,20 @@ export class MailService {
|
||||
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
|
||||
const html = await render(VerificationTemplate({ domain, token }))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- todo fixme
|
||||
return this.sendMail(email, 'Верификация аккаунта', html)
|
||||
void this.sendMail(email, 'Верификация аккаунта', html)
|
||||
}
|
||||
|
||||
public async sendPasswordResetToken(email: string, token: Token['token'], metadata: SessionInfo) {
|
||||
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
|
||||
const html = await render(PasswordRecoveryTemplate({ domain, token, metadata }))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- todo fixme
|
||||
return this.sendMail(email, 'Сброс пароля', html)
|
||||
void this.sendMail(email, 'Сброс пароля', html)
|
||||
}
|
||||
|
||||
public async sendDeactivateToken(email: string, token: Token['token'], metadata: SessionInfo) {
|
||||
const html = await render(DeactivateTemplate({ token, metadata }))
|
||||
|
||||
void this.sendMail(email, 'Деактивация аккаунта', html)
|
||||
}
|
||||
|
||||
private sendMail(email: string, subject: string, html: string) {
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
import type { SessionInfo } from '@/src/shared/types/session-metadata.types'
|
||||
import { Body, Head, Heading, Link, Preview, Section, Tailwind, Text } from '@react-email/components'
|
||||
import { Html } from '@react-email/html'
|
||||
import * as React from 'react'
|
||||
|
||||
interface DeactivateTemplateProps {
|
||||
token: string
|
||||
metadata: SessionInfo
|
||||
}
|
||||
|
||||
export function DeactivateTemplate({ token, metadata }: DeactivateTemplateProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Деактивация аккаунта</Preview>
|
||||
<Tailwind>
|
||||
<Body className="max-w-2xl mx-auto p-6 bg-slate-50">
|
||||
<Section className="text-center mb-8">
|
||||
<Heading className="text-3xl text-black font-bold">
|
||||
Запрос на деактивацию аккаунта
|
||||
</Heading>
|
||||
<Text className="text-black text-base mt-2">
|
||||
Вы инициировали процесс деактивации вашего аккаунта на платформе
|
||||
{' '}
|
||||
<b>TeaStream</b>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-gray-100 rounded-lg p-6 text-center mb-6">
|
||||
<Heading className="text-2xl text-black font-semibold">
|
||||
Код подтверждения:
|
||||
</Heading>
|
||||
<Heading className="text-3xl text-black font-semibold">
|
||||
{token}
|
||||
</Heading>
|
||||
<Text className="text-black">
|
||||
Этот код действителен в течение 5 минут.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-gray-100 rounded-lg p-6 mb-6">
|
||||
<Heading
|
||||
className="text-xl font-semibold text-[#18B9AE]"
|
||||
>
|
||||
Информация о запросе:
|
||||
</Heading>
|
||||
<ul className="list-disc list-inside text-black mt-2">
|
||||
<li>
|
||||
🌍 Расположение:
|
||||
{metadata.location.country}
|
||||
,
|
||||
{metadata.location.city}
|
||||
</li>
|
||||
<li>
|
||||
📱 Операционная система:
|
||||
{metadata.device.os}
|
||||
</li>
|
||||
<li>
|
||||
🌐 Браузер:
|
||||
{metadata.device.browser}
|
||||
</li>
|
||||
<li>
|
||||
💻 IP-адрес:
|
||||
{metadata.ip}
|
||||
</li>
|
||||
</ul>
|
||||
<Text className="text-gray-600 mt-2">
|
||||
Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="text-center mt-8">
|
||||
<Text className="text-gray-600">
|
||||
Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу
|
||||
{' '}
|
||||
<Link
|
||||
href="mailto:help@teastream.ru"
|
||||
className="text-[#18b9ae] underline"
|
||||
>
|
||||
help@teastream.ru
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user