add account deactivation
This commit is contained in:
parent
7724bc460f
commit
706387b9ad
@ -9,20 +9,22 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
name String @unique
|
name String @unique
|
||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
avatar String?
|
avatar String?
|
||||||
bio String?
|
bio String?
|
||||||
token Token[]
|
token Token[]
|
||||||
isVerified Boolean @default(false) @map("is_verified")
|
isVerified Boolean @default(false) @map("is_verified")
|
||||||
isEmailVerified Boolean @default(false) @map("is_email_verified")
|
isEmailVerified Boolean @default(false) @map("is_email_verified")
|
||||||
isTotpEnabled Boolean @default(false) @map("is_totp_enabled")
|
isTotpEnabled Boolean @default(false) @map("is_totp_enabled")
|
||||||
totpSecret String? @map("totp_secret")
|
isDeactivated Boolean @default(false) @map("is_deactivated")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
deactivatedAt DateTime? @map("deactivated_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
totpSecret String? @map("totp_secret")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -43,6 +45,7 @@ model Token {
|
|||||||
enum TokenType {
|
enum TokenType {
|
||||||
EMAIL_VERIFY
|
EMAIL_VERIFY
|
||||||
PASSWORD_RESET
|
PASSWORD_RESET
|
||||||
|
DEACTIVATE_ACCOUNT
|
||||||
|
|
||||||
@@map("token_types")
|
@@map("token_types")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { getGraphQLConfig } from '@/src/core/config/graphql.config'
|
import { getGraphQLConfig } from '@/src/core/config/graphql.config'
|
||||||
import { AccountModule } from '@/src/module/auth/account/account.module'
|
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 { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module'
|
||||||
import { SessionModule } from '@/src/module/auth/session/session.module'
|
import { SessionModule } from '@/src/module/auth/session/session.module'
|
||||||
import { TotpModule } from '@/src/module/auth/totp/totp.module'
|
import { TotpModule } from '@/src/module/auth/totp/totp.module'
|
||||||
@ -33,6 +34,7 @@ import { RedisModule } from './redis/redis.module'
|
|||||||
VerificationModule,
|
VerificationModule,
|
||||||
PasswordRecoveryModule,
|
PasswordRecoveryModule,
|
||||||
TotpModule,
|
TotpModule,
|
||||||
|
DeactivateModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule {}
|
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
|
scalar DateTime
|
||||||
|
|
||||||
|
input DeactivateAccountInput {
|
||||||
|
email: String!
|
||||||
|
password: String!
|
||||||
|
pin: String
|
||||||
|
}
|
||||||
|
|
||||||
type DeviceModel {
|
type DeviceModel {
|
||||||
browser: String!
|
browser: String!
|
||||||
os: String!
|
os: String!
|
||||||
@ -45,6 +51,7 @@ input LoginInput {
|
|||||||
type Mutation {
|
type Mutation {
|
||||||
clearSessionCookie: Boolean!
|
clearSessionCookie: Boolean!
|
||||||
createUser(data: CreateUserInput!): UserModel!
|
createUser(data: CreateUserInput!): UserModel!
|
||||||
|
deactivateAccount(data: DeactivateAccountInput!): AuthModel!
|
||||||
disableTotp: Boolean!
|
disableTotp: Boolean!
|
||||||
enableTotp(data: EnableTotpInput!): Boolean!
|
enableTotp(data: EnableTotpInput!): Boolean!
|
||||||
loginUser(data: LoginInput!): AuthModel!
|
loginUser(data: LoginInput!): AuthModel!
|
||||||
@ -94,9 +101,11 @@ type UserModel {
|
|||||||
avatar: String
|
avatar: String
|
||||||
bio: String
|
bio: String
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
|
deactivatedAt: DateTime
|
||||||
displayName: String!
|
displayName: String!
|
||||||
email: String!
|
email: String!
|
||||||
id: ID!
|
id: ID!
|
||||||
|
isDeactivated: Boolean!
|
||||||
isEmailVerified: Boolean!
|
isEmailVerified: Boolean!
|
||||||
isTotpEnabled: Boolean!
|
isTotpEnabled: Boolean!
|
||||||
isVerified: Boolean!
|
isVerified: Boolean!
|
||||||
|
|||||||
@ -33,6 +33,12 @@ export class UserModel implements User {
|
|||||||
@Field(() => Boolean)
|
@Field(() => Boolean)
|
||||||
isTotpEnabled: boolean
|
isTotpEnabled: boolean
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
isDeactivated: boolean
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
deactivatedAt: Date
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
totpSecret: string
|
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 PasswordRecoveryTemplate from '@/src/module/libs/mail/templates/password-recovery.template'
|
||||||
import { SessionInfo } from '@/src/shared/types/session-metadata.types'
|
import { SessionInfo } from '@/src/shared/types/session-metadata.types'
|
||||||
import { Token } from '@prisma/generated'
|
import { Token } from '@prisma/generated'
|
||||||
@ -18,16 +19,20 @@ export class MailService {
|
|||||||
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
|
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
|
||||||
const html = await render(VerificationTemplate({ domain, token }))
|
const html = await render(VerificationTemplate({ domain, token }))
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- todo fixme
|
void this.sendMail(email, 'Верификация аккаунта', html)
|
||||||
return this.sendMail(email, 'Верификация аккаунта', html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async sendPasswordResetToken(email: string, token: Token['token'], metadata: SessionInfo) {
|
public async sendPasswordResetToken(email: string, token: Token['token'], metadata: SessionInfo) {
|
||||||
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
|
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
|
||||||
const html = await render(PasswordRecoveryTemplate({ domain, token, metadata }))
|
const html = await render(PasswordRecoveryTemplate({ domain, token, metadata }))
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- todo fixme
|
void this.sendMail(email, 'Сброс пароля', html)
|
||||||
return 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) {
|
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