add account deactivation

This commit is contained in:
Sergey Krylov 2025-07-05 10:32:24 +03:00
parent 7724bc460f
commit 706387b9ad
10 changed files with 280 additions and 14 deletions

View File

@ -9,20 +9,22 @@ datasource db {
}
model User {
id String @id @default(uuid())
email String @unique
id String @id @default(uuid())
email String @unique
password String
name String @unique
displayName String @map("display_name")
name String @unique
displayName String @map("display_name")
avatar String?
bio String?
token Token[]
isVerified Boolean @default(false) @map("is_verified")
isEmailVerified Boolean @default(false) @map("is_email_verified")
isTotpEnabled Boolean @default(false) @map("is_totp_enabled")
totpSecret String? @map("totp_secret")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
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")
@@map("users")
}
@ -43,6 +45,7 @@ model Token {
enum TokenType {
EMAIL_VERIFY
PASSWORD_RESET
DEACTIVATE_ACCOUNT
@@map("token_types")
}

View File

@ -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 {}

View File

@ -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!

View File

@ -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

View 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 {}

View 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)
}
}

View 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
}
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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>
)
}