initial commit

This commit is contained in:
Sergey Krylov 2025-03-05 07:05:19 +03:00
commit 90904de1a1
170 changed files with 30416 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# full-stack-auth
[![Watch the video](https://img.youtube.com/vi/O5Qry8cBhG4/0.jpg)](https://www.youtube.com/watch?v=O5Qry8cBhG4)
### Функционал:
- Авторизация
- Отправка писем
- Fullstack
- Сесии
### Инструменты:
#### Server
- Docker
- Nest.js
- Prisma
- PostgreSQL
- Redis
#### Client
- Next.js
- Shad.cn
- React Query

50
nestjs-server/.env Normal file
View File

@ -0,0 +1,50 @@
# Environment settings
NODE_ENV='development'
# Application settings
APPLICATION_PORT=4000
APPLICATION_URL='http://localhost:${APPLICATION_PORT}'
ALLOWED_ORIGIN='http://localhost:3000'
# Session settings
COOKIES_SECRET='secret'
SESSION_SECRET='secret'
SESSION_NAME='session'
SESSION_DOMAIN='localhost'
SESSION_MAX_AGE='30d'
SESSION_HTTP_ONLY=true
SESSION_SECURE=false
SESSION_FOLDER='sessions'
# Postgres settings
POSTGRES_USER='root'
POSTGRES_PASSWORD='123456'
POSTGRES_HOST='localhost'
POSTGRES_PORT=5433
POSTGRES_DB='full-authorization'
POSTGRES_URI="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
# Redis settings
REDIS_USER='default'
REDIS_PASSWORD='123456'
REDIS_HOST='localhost'
REDIS_PORT=6379
REDIS_URI="redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}"
# Recaptcha
GOOGLE_RECAPTCHA_SECRET_KEY='6Lfu0OEqAAAAANOCwQx7cf_QeKiQ6Y0eMd75ix6e'
# OAuth Google
GOOGLE_CLIENT_ID='187382652585-p6kb68mhf1okpmootkldtrh8hbdp9jgi.apps.googleusercontent.com'
GOOGLE_CLIENT_SECRET='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'
# OAuth Yandex
YANDEX_CLIENT_ID='e551c3678339417dbc10755459a31639'
YANDEX_CLIENT_SECRET='ad441f80e9f44f9bb4c645871ea06f80'
# Email sending
MAIL_HOST='smtp.yandex.ru'
MAIL_PORT='25'
MAIL_LOGIN='svk741@yandex.ru'
MAIL_PASSWORD='atwljofoizgeeeys'

View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

35
nestjs-server/.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

19
nestjs-server/.prettierrc Normal file
View File

@ -0,0 +1,19 @@
{
"trailingComma": "none",
"tabWidth": 4,
"useTabs": true,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"arrowParens": "avoid",
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderCaseInsensitive": true,
"importOrderParserPlugins": [
"classProperties",
"decorators-legacy",
"typescript"
],
"importOrder": ["<THIRD_PARTY_MODULES>", "^@/(.*)$", "^../(.*)", "^./(.*)"],
"plugins": ["@trivago/prettier-plugin-sort-imports"]
}

73
nestjs-server/README.md Normal file
View File

@ -0,0 +1,73 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ yarn install
```
## Running the app
```bash
# development
$ yarn run start
# watch mode
$ yarn run start:dev
# production mode
$ yarn run start:prod
```
## Test
```bash
# unit tests
$ yarn run test
# e2e tests
$ yarn run test:e2e
# test coverage
$ yarn run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View File

@ -0,0 +1,36 @@
version: '3.7'
services:
db:
container_name: postgres
image: postgres:15.2
restart: always
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
ports:
- 5433:5432
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
redis:
container_name: redis
image: redis:5.0
restart: always
ports:
- 6379:6379
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- backend
volumes:
postgres_data:
redis_data:
networks:
backend:

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11127
nestjs-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
{
"name": "nestjs-server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.0.0",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^10.0.0",
"@nestlab/google-recaptcha": "^3.8.0",
"@prisma/client": "^5.19.0",
"@react-email/components": "^0.0.23",
"@react-email/html": "^0.0.10",
"argon2": "^0.41.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"connect-redis": "^7.1.1",
"cookie-parser": "^1.4.6",
"express-session": "^1.18.0",
"ioredis": "^5.4.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.17",
"@types/express-session": "^1.18.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/react": "^18.3.4",
"@types/supertest": "^2.0.12",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

1
nestjs-server/prisma/__generated__/default.d.ts generated vendored Normal file
View File

@ -0,0 +1 @@
export * from "./index"

View File

@ -0,0 +1 @@
module.exports = { ...require('.') }

1
nestjs-server/prisma/__generated__/edge.d.ts generated vendored Normal file
View File

@ -0,0 +1 @@
export * from "./default"

231
nestjs-server/prisma/__generated__/edge.js generated Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,223 @@
Object.defineProperty(exports, "__esModule", { value: true });
const {
Decimal,
objectEnumValues,
makeStrictEnum,
Public,
getRuntime
} = require('./runtime/index-browser.js')
const Prisma = {}
exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 5.19.0
* Query Engine version: a9055b89e58b4b5bfb59600785423b1db3d0e75d
*/
Prisma.prismaVersion = {
client: "5.19.0",
engine: "a9055b89e58b4b5bfb59600785423b1db3d0e75d"
}
Prisma.PrismaClientKnownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientKnownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)};
Prisma.PrismaClientUnknownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientUnknownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientRustPanicError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientRustPanicError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientInitializationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientInitializationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientValidationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientValidationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.NotFoundError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`NotFoundError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.Decimal = Decimal
/**
* Re-export of sql-template-tag
*/
Prisma.sql = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`sqltag is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.empty = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`empty is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.join = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`join is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.raw = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`raw is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.validator = Public.validator
/**
* Extensions
*/
Prisma.getExtensionContext = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.getExtensionContext is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.defineExtension = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.defineExtension is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
/**
* Shorthand utilities for JSON filtering
*/
Prisma.DbNull = objectEnumValues.instances.DbNull
Prisma.JsonNull = objectEnumValues.instances.JsonNull
Prisma.AnyNull = objectEnumValues.instances.AnyNull
Prisma.NullTypes = {
DbNull: objectEnumValues.classes.DbNull,
JsonNull: objectEnumValues.classes.JsonNull,
AnyNull: objectEnumValues.classes.AnyNull
}
/**
* Enums
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
});
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
email: 'email',
password: 'password',
displayName: 'displayName',
picture: 'picture',
role: 'role',
isVerified: 'isVerified',
isTwoFactorEnabled: 'isTwoFactorEnabled',
method: 'method',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.AccountScalarFieldEnum = {
id: 'id',
type: 'type',
provider: 'provider',
refreshToken: 'refreshToken',
accessToken: 'accessToken',
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
userId: 'userId'
};
exports.Prisma.TokenScalarFieldEnum = {
id: 'id',
email: 'email',
token: 'token',
type: 'type',
expiresIn: 'expiresIn',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.UserRole = exports.$Enums.UserRole = {
REGULAR: 'REGULAR',
ADMIN: 'ADMIN'
};
exports.AuthMethod = exports.$Enums.AuthMethod = {
CREDENTIALS: 'CREDENTIALS',
GOOGLE: 'GOOGLE',
YANDEX: 'YANDEX'
};
exports.TokenType = exports.$Enums.TokenType = {
VERIFICATION: 'VERIFICATION',
TWO_FACTOR: 'TWO_FACTOR',
PASSWORD_RESET: 'PASSWORD_RESET'
};
exports.Prisma.ModelName = {
User: 'User',
Account: 'Account',
Token: 'Token'
};
/**
* This is a stub Prisma Client that will error at runtime if called.
*/
class PrismaClient {
constructor() {
return new Proxy(this, {
get(target, prop) {
let message
const runtime = getRuntime()
if (runtime.isEdge) {
message = `PrismaClient is not configured to run in ${runtime.prettyName}. In order to run Prisma Client on edge runtime, either:
- Use Prisma Accelerate: https://pris.ly/d/accelerate
- Use Driver Adapters: https://pris.ly/d/driver-adapters
`;
} else {
message = 'PrismaClient is unable to run in this browser environment, or has been bundled for the browser (running in `' + runtime.prettyName + '`).'
}
message += `
If this is unexpected, please open an issue: https://pris.ly/prisma-prisma-bug-report`
throw new Error(message)
}
})
}
}
exports.PrismaClient = PrismaClient
Object.assign(exports, Prisma)

5519
nestjs-server/prisma/__generated__/index.d.ts generated vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -0,0 +1,97 @@
{
"name": "prisma-client-b1cc2a51e1070bb1bc8adb58400c3941c95098951d93e55cbe80bb0a786403ef",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",
"exports": {
"./package.json": "./package.json",
".": {
"require": {
"node": "./index.js",
"edge-light": "./wasm.js",
"workerd": "./wasm.js",
"worker": "./wasm.js",
"browser": "./index-browser.js",
"default": "./index.js"
},
"import": {
"node": "./index.js",
"edge-light": "./wasm.js",
"workerd": "./wasm.js",
"worker": "./wasm.js",
"browser": "./index-browser.js",
"default": "./index.js"
},
"default": "./index.js"
},
"./edge": {
"types": "./edge.d.ts",
"require": "./edge.js",
"import": "./edge.js",
"default": "./edge.js"
},
"./react-native": {
"types": "./react-native.d.ts",
"require": "./react-native.js",
"import": "./react-native.js",
"default": "./react-native.js"
},
"./extension": {
"types": "./extension.d.ts",
"require": "./extension.js",
"import": "./extension.js",
"default": "./extension.js"
},
"./index-browser": {
"types": "./index.d.ts",
"require": "./index-browser.js",
"import": "./index-browser.js",
"default": "./index-browser.js"
},
"./index": {
"types": "./index.d.ts",
"require": "./index.js",
"import": "./index.js",
"default": "./index.js"
},
"./wasm": {
"types": "./wasm.d.ts",
"require": "./wasm.js",
"import": "./wasm.js",
"default": "./wasm.js"
},
"./runtime/library": {
"types": "./runtime/library.d.ts",
"require": "./runtime/library.js",
"import": "./runtime/library.js",
"default": "./runtime/library.js"
},
"./runtime/binary": {
"types": "./runtime/binary.d.ts",
"require": "./runtime/binary.js",
"import": "./runtime/binary.js",
"default": "./runtime/binary.js"
},
"./generator-build": {
"require": "./generator-build/index.js",
"import": "./generator-build/index.js",
"default": "./generator-build/index.js"
},
"./sql": {
"require": {
"types": "./sql.d.ts",
"node": "./sql.js",
"default": "./sql.js"
},
"import": {
"types": "./sql.d.ts",
"node": "./sql.mjs",
"default": "./sql.mjs"
},
"default": "./sql.js"
},
"./*": "./*"
},
"version": "5.19.0",
"sideEffects": false
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,365 @@
declare class AnyNull extends NullTypesEnumValue {
}
declare type Args<T, F extends Operation> = T extends {
[K: symbol]: {
types: {
operations: {
[K in F]: {
args: any;
};
};
};
};
} ? T[symbol]['types']['operations'][F]['args'] : any;
declare class DbNull extends NullTypesEnumValue {
}
export declare namespace Decimal {
export type Constructor = typeof Decimal;
export type Instance = Decimal;
export type Rounding = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
export type Modulo = Rounding | 9;
export type Value = string | number | Decimal;
// http://mikemcl.github.io/decimal.js/#constructor-properties
export interface Config {
precision?: number;
rounding?: Rounding;
toExpNeg?: number;
toExpPos?: number;
minE?: number;
maxE?: number;
crypto?: boolean;
modulo?: Modulo;
defaults?: boolean;
}
}
export declare class Decimal {
readonly d: number[];
readonly e: number;
readonly s: number;
constructor(n: Decimal.Value);
absoluteValue(): Decimal;
abs(): Decimal;
ceil(): Decimal;
clampedTo(min: Decimal.Value, max: Decimal.Value): Decimal;
clamp(min: Decimal.Value, max: Decimal.Value): Decimal;
comparedTo(n: Decimal.Value): number;
cmp(n: Decimal.Value): number;
cosine(): Decimal;
cos(): Decimal;
cubeRoot(): Decimal;
cbrt(): Decimal;
decimalPlaces(): number;
dp(): number;
dividedBy(n: Decimal.Value): Decimal;
div(n: Decimal.Value): Decimal;
dividedToIntegerBy(n: Decimal.Value): Decimal;
divToInt(n: Decimal.Value): Decimal;
equals(n: Decimal.Value): boolean;
eq(n: Decimal.Value): boolean;
floor(): Decimal;
greaterThan(n: Decimal.Value): boolean;
gt(n: Decimal.Value): boolean;
greaterThanOrEqualTo(n: Decimal.Value): boolean;
gte(n: Decimal.Value): boolean;
hyperbolicCosine(): Decimal;
cosh(): Decimal;
hyperbolicSine(): Decimal;
sinh(): Decimal;
hyperbolicTangent(): Decimal;
tanh(): Decimal;
inverseCosine(): Decimal;
acos(): Decimal;
inverseHyperbolicCosine(): Decimal;
acosh(): Decimal;
inverseHyperbolicSine(): Decimal;
asinh(): Decimal;
inverseHyperbolicTangent(): Decimal;
atanh(): Decimal;
inverseSine(): Decimal;
asin(): Decimal;
inverseTangent(): Decimal;
atan(): Decimal;
isFinite(): boolean;
isInteger(): boolean;
isInt(): boolean;
isNaN(): boolean;
isNegative(): boolean;
isNeg(): boolean;
isPositive(): boolean;
isPos(): boolean;
isZero(): boolean;
lessThan(n: Decimal.Value): boolean;
lt(n: Decimal.Value): boolean;
lessThanOrEqualTo(n: Decimal.Value): boolean;
lte(n: Decimal.Value): boolean;
logarithm(n?: Decimal.Value): Decimal;
log(n?: Decimal.Value): Decimal;
minus(n: Decimal.Value): Decimal;
sub(n: Decimal.Value): Decimal;
modulo(n: Decimal.Value): Decimal;
mod(n: Decimal.Value): Decimal;
naturalExponential(): Decimal;
exp(): Decimal;
naturalLogarithm(): Decimal;
ln(): Decimal;
negated(): Decimal;
neg(): Decimal;
plus(n: Decimal.Value): Decimal;
add(n: Decimal.Value): Decimal;
precision(includeZeros?: boolean): number;
sd(includeZeros?: boolean): number;
round(): Decimal;
sine() : Decimal;
sin() : Decimal;
squareRoot(): Decimal;
sqrt(): Decimal;
tangent() : Decimal;
tan() : Decimal;
times(n: Decimal.Value): Decimal;
mul(n: Decimal.Value) : Decimal;
toBinary(significantDigits?: number): string;
toBinary(significantDigits: number, rounding: Decimal.Rounding): string;
toDecimalPlaces(decimalPlaces?: number): Decimal;
toDecimalPlaces(decimalPlaces: number, rounding: Decimal.Rounding): Decimal;
toDP(decimalPlaces?: number): Decimal;
toDP(decimalPlaces: number, rounding: Decimal.Rounding): Decimal;
toExponential(decimalPlaces?: number): string;
toExponential(decimalPlaces: number, rounding: Decimal.Rounding): string;
toFixed(decimalPlaces?: number): string;
toFixed(decimalPlaces: number, rounding: Decimal.Rounding): string;
toFraction(max_denominator?: Decimal.Value): Decimal[];
toHexadecimal(significantDigits?: number): string;
toHexadecimal(significantDigits: number, rounding: Decimal.Rounding): string;
toHex(significantDigits?: number): string;
toHex(significantDigits: number, rounding?: Decimal.Rounding): string;
toJSON(): string;
toNearest(n: Decimal.Value, rounding?: Decimal.Rounding): Decimal;
toNumber(): number;
toOctal(significantDigits?: number): string;
toOctal(significantDigits: number, rounding: Decimal.Rounding): string;
toPower(n: Decimal.Value): Decimal;
pow(n: Decimal.Value): Decimal;
toPrecision(significantDigits?: number): string;
toPrecision(significantDigits: number, rounding: Decimal.Rounding): string;
toSignificantDigits(significantDigits?: number): Decimal;
toSignificantDigits(significantDigits: number, rounding: Decimal.Rounding): Decimal;
toSD(significantDigits?: number): Decimal;
toSD(significantDigits: number, rounding: Decimal.Rounding): Decimal;
toString(): string;
truncated(): Decimal;
trunc(): Decimal;
valueOf(): string;
static abs(n: Decimal.Value): Decimal;
static acos(n: Decimal.Value): Decimal;
static acosh(n: Decimal.Value): Decimal;
static add(x: Decimal.Value, y: Decimal.Value): Decimal;
static asin(n: Decimal.Value): Decimal;
static asinh(n: Decimal.Value): Decimal;
static atan(n: Decimal.Value): Decimal;
static atanh(n: Decimal.Value): Decimal;
static atan2(y: Decimal.Value, x: Decimal.Value): Decimal;
static cbrt(n: Decimal.Value): Decimal;
static ceil(n: Decimal.Value): Decimal;
static clamp(n: Decimal.Value, min: Decimal.Value, max: Decimal.Value): Decimal;
static clone(object?: Decimal.Config): Decimal.Constructor;
static config(object: Decimal.Config): Decimal.Constructor;
static cos(n: Decimal.Value): Decimal;
static cosh(n: Decimal.Value): Decimal;
static div(x: Decimal.Value, y: Decimal.Value): Decimal;
static exp(n: Decimal.Value): Decimal;
static floor(n: Decimal.Value): Decimal;
static hypot(...n: Decimal.Value[]): Decimal;
static isDecimal(object: any): object is Decimal;
static ln(n: Decimal.Value): Decimal;
static log(n: Decimal.Value, base?: Decimal.Value): Decimal;
static log2(n: Decimal.Value): Decimal;
static log10(n: Decimal.Value): Decimal;
static max(...n: Decimal.Value[]): Decimal;
static min(...n: Decimal.Value[]): Decimal;
static mod(x: Decimal.Value, y: Decimal.Value): Decimal;
static mul(x: Decimal.Value, y: Decimal.Value): Decimal;
static noConflict(): Decimal.Constructor; // Browser only
static pow(base: Decimal.Value, exponent: Decimal.Value): Decimal;
static random(significantDigits?: number): Decimal;
static round(n: Decimal.Value): Decimal;
static set(object: Decimal.Config): Decimal.Constructor;
static sign(n: Decimal.Value): number;
static sin(n: Decimal.Value): Decimal;
static sinh(n: Decimal.Value): Decimal;
static sqrt(n: Decimal.Value): Decimal;
static sub(x: Decimal.Value, y: Decimal.Value): Decimal;
static sum(...n: Decimal.Value[]): Decimal;
static tan(n: Decimal.Value): Decimal;
static tanh(n: Decimal.Value): Decimal;
static trunc(n: Decimal.Value): Decimal;
static readonly default?: Decimal.Constructor;
static readonly Decimal?: Decimal.Constructor;
static readonly precision: number;
static readonly rounding: Decimal.Rounding;
static readonly toExpNeg: number;
static readonly toExpPos: number;
static readonly minE: number;
static readonly maxE: number;
static readonly crypto: boolean;
static readonly modulo: Decimal.Modulo;
static readonly ROUND_UP: 0;
static readonly ROUND_DOWN: 1;
static readonly ROUND_CEIL: 2;
static readonly ROUND_FLOOR: 3;
static readonly ROUND_HALF_UP: 4;
static readonly ROUND_HALF_DOWN: 5;
static readonly ROUND_HALF_EVEN: 6;
static readonly ROUND_HALF_CEIL: 7;
static readonly ROUND_HALF_FLOOR: 8;
static readonly EUCLID: 9;
}
declare type Exact<A, W> = (A extends unknown ? (W extends A ? {
[K in keyof A]: Exact<A[K], W[K]>;
} : W) : never) | (A extends Narrowable ? A : never);
export declare function getRuntime(): GetRuntimeOutput;
declare type GetRuntimeOutput = {
id: Runtime;
prettyName: string;
isEdge: boolean;
};
declare class JsonNull extends NullTypesEnumValue {
}
/**
* Generates more strict variant of an enum which, unlike regular enum,
* throws on non-existing property access. This can be useful in following situations:
* - we have an API, that accepts both `undefined` and `SomeEnumType` as an input
* - enum values are generated dynamically from DMMF.
*
* In that case, if using normal enums and no compile-time typechecking, using non-existing property
* will result in `undefined` value being used, which will be accepted. Using strict enum
* in this case will help to have a runtime exception, telling you that you are probably doing something wrong.
*
* Note: if you need to check for existence of a value in the enum you can still use either
* `in` operator or `hasOwnProperty` function.
*
* @param definition
* @returns
*/
export declare function makeStrictEnum<T extends Record<PropertyKey, string | number>>(definition: T): T;
declare type Narrowable = string | number | bigint | boolean | [];
declare class NullTypesEnumValue extends ObjectEnumValue {
_getNamespace(): string;
}
/**
* Base class for unique values of object-valued enums.
*/
declare abstract class ObjectEnumValue {
constructor(arg?: symbol);
abstract _getNamespace(): string;
_getName(): string;
toString(): string;
}
export declare const objectEnumValues: {
classes: {
DbNull: typeof DbNull;
JsonNull: typeof JsonNull;
AnyNull: typeof AnyNull;
};
instances: {
DbNull: DbNull;
JsonNull: JsonNull;
AnyNull: AnyNull;
};
};
declare type Operation = 'findFirst' | 'findFirstOrThrow' | 'findUnique' | 'findUniqueOrThrow' | 'findMany' | 'create' | 'createMany' | 'createManyAndReturn' | 'update' | 'updateMany' | 'upsert' | 'delete' | 'deleteMany' | 'aggregate' | 'count' | 'groupBy' | '$queryRaw' | '$executeRaw' | '$queryRawUnsafe' | '$executeRawUnsafe' | 'findRaw' | 'aggregateRaw' | '$runCommandRaw';
declare namespace Public {
export {
validator
}
}
export { Public }
declare type Runtime = "edge-routine" | "workerd" | "deno" | "lagon" | "react-native" | "netlify" | "electron" | "node" | "bun" | "edge-light" | "fastly" | "unknown";
declare function validator<V>(): <S>(select: Exact<S, V>) => S;
declare function validator<C, M extends Exclude<keyof C, `$${string}`>, O extends keyof C[M] & Operation>(client: C, model: M, operation: O): <S>(select: Exact<S, Args<C[M], O>>) => S;
declare function validator<C, M extends Exclude<keyof C, `$${string}`>, O extends keyof C[M] & Operation, P extends keyof Args<C[M], O>>(client: C, model: M, operation: O, prop: P): <S>(select: Exact<S, Args<C[M], O>[P]>) => S;
export { }

File diff suppressed because one or more lines are too long

3353
nestjs-server/prisma/__generated__/runtime/library.d.ts generated vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,82 @@
generator client {
provider = "prisma-client-js"
output = "./__generated__"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_URI")
}
model User {
id String @id @default(uuid())
email String @unique
password String
displayName String
picture String?
role UserRole @default(REGULAR)
isVerified Boolean @default(false) @map("is_verified")
isTwoFactorEnabled Boolean @default(false) @map("is_two_factor_enabled")
method AuthMethod
accounts Account[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Account {
id String @id @default(uuid())
type String
provider String
refreshToken String? @map("refresh_token")
accessToken String? @map("access_token")
expiresAt Int @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id])
userId String? @map("user_id")
@@map("accounts")
}
model Token {
id String @id @default(uuid())
email String
token String @unique
type TokenType
expiresIn DateTime @map("expires_in")
createdAt DateTime @default(now()) @map("created_at")
@@map("tokens")
}
enum UserRole {
REGULAR
ADMIN
}
enum AuthMethod {
CREDENTIALS
GOOGLE
YANDEX
}
enum TokenType {
VERIFICATION
TWO_FACTOR
PASSWORD_RESET
}

1
nestjs-server/prisma/__generated__/wasm.d.ts generated vendored Normal file
View File

@ -0,0 +1 @@
export * from "./index"

223
nestjs-server/prisma/__generated__/wasm.js generated Normal file
View File

@ -0,0 +1,223 @@
Object.defineProperty(exports, "__esModule", { value: true });
const {
Decimal,
objectEnumValues,
makeStrictEnum,
Public,
getRuntime
} = require('./runtime/index-browser.js')
const Prisma = {}
exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 5.19.0
* Query Engine version: a9055b89e58b4b5bfb59600785423b1db3d0e75d
*/
Prisma.prismaVersion = {
client: "5.19.0",
engine: "a9055b89e58b4b5bfb59600785423b1db3d0e75d"
}
Prisma.PrismaClientKnownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientKnownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)};
Prisma.PrismaClientUnknownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientUnknownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientRustPanicError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientRustPanicError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientInitializationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientInitializationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientValidationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientValidationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.NotFoundError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`NotFoundError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.Decimal = Decimal
/**
* Re-export of sql-template-tag
*/
Prisma.sql = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`sqltag is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.empty = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`empty is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.join = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`join is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.raw = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`raw is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.validator = Public.validator
/**
* Extensions
*/
Prisma.getExtensionContext = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.getExtensionContext is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.defineExtension = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.defineExtension is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
/**
* Shorthand utilities for JSON filtering
*/
Prisma.DbNull = objectEnumValues.instances.DbNull
Prisma.JsonNull = objectEnumValues.instances.JsonNull
Prisma.AnyNull = objectEnumValues.instances.AnyNull
Prisma.NullTypes = {
DbNull: objectEnumValues.classes.DbNull,
JsonNull: objectEnumValues.classes.JsonNull,
AnyNull: objectEnumValues.classes.AnyNull
}
/**
* Enums
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
});
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
email: 'email',
password: 'password',
displayName: 'displayName',
picture: 'picture',
role: 'role',
isVerified: 'isVerified',
isTwoFactorEnabled: 'isTwoFactorEnabled',
method: 'method',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.AccountScalarFieldEnum = {
id: 'id',
type: 'type',
provider: 'provider',
refreshToken: 'refreshToken',
accessToken: 'accessToken',
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
userId: 'userId'
};
exports.Prisma.TokenScalarFieldEnum = {
id: 'id',
email: 'email',
token: 'token',
type: 'type',
expiresIn: 'expiresIn',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.UserRole = exports.$Enums.UserRole = {
REGULAR: 'REGULAR',
ADMIN: 'ADMIN'
};
exports.AuthMethod = exports.$Enums.AuthMethod = {
CREDENTIALS: 'CREDENTIALS',
GOOGLE: 'GOOGLE',
YANDEX: 'YANDEX'
};
exports.TokenType = exports.$Enums.TokenType = {
VERIFICATION: 'VERIFICATION',
TWO_FACTOR: 'TWO_FACTOR',
PASSWORD_RESET: 'PASSWORD_RESET'
};
exports.Prisma.ModelName = {
User: 'User',
Account: 'Account',
Token: 'Token'
};
/**
* This is a stub Prisma Client that will error at runtime if called.
*/
class PrismaClient {
constructor() {
return new Proxy(this, {
get(target, prop) {
let message
const runtime = getRuntime()
if (runtime.isEdge) {
message = `PrismaClient is not configured to run in ${runtime.prettyName}. In order to run Prisma Client on edge runtime, either:
- Use Prisma Accelerate: https://pris.ly/d/accelerate
- Use Driver Adapters: https://pris.ly/d/driver-adapters
`;
} else {
message = 'PrismaClient is unable to run in this browser environment, or has been bundled for the browser (running in `' + runtime.prettyName + '`).'
}
message += `
If this is unexpected, please open an issue: https://pris.ly/prisma-prisma-bug-report`
throw new Error(message)
}
})
}
}
exports.PrismaClient = PrismaClient
Object.assign(exports, Prisma)

View File

@ -0,0 +1,82 @@
generator client {
provider = "prisma-client-js"
output = "./__generated__"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_URI")
}
model User {
id String @id @default(uuid())
email String @unique
password String
displayName String
picture String?
role UserRole @default(REGULAR)
isVerified Boolean @default(false) @map("is_verified")
isTwoFactorEnabled Boolean @default(false) @map("is_two_factor_enabled")
method AuthMethod
accounts Account[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Account {
id String @id @default(uuid())
type String
provider String
refreshToken String? @map("refresh_token")
accessToken String? @map("access_token")
expiresAt Int @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id])
userId String? @map("user_id")
@@map("accounts")
}
model Token {
id String @id @default(uuid())
email String
token String @unique
type TokenType
expiresIn DateTime @map("expires_in")
createdAt DateTime @default(now()) @map("created_at")
@@map("tokens")
}
enum UserRole {
REGULAR
ADMIN
}
enum AuthMethod {
CREDENTIALS
GOOGLE
YANDEX
}
enum TokenType {
VERIFICATION
TWO_FACTOR
PASSWORD_RESET
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { AuthModule } from './auth/auth.module'
import { EmailConfirmationModule } from './auth/email-confirmation/email-confirmation.module'
import { PasswordRecoveryModule } from './auth/password-recovery/password-recovery.module'
import { ProviderModule } from './auth/provider/provider.module'
import { TwoFactorAuthModule } from './auth/two-factor-auth/two-factor-auth.module'
import { IS_DEV_ENV } from './libs/common/utils/is-dev.util'
import { MailModule } from './libs/mail/mail.module'
import { PrismaModule } from './prisma/prisma.module'
import { UserModule } from './user/user.module'
@Module({
imports: [
ConfigModule.forRoot({
ignoreEnvFile: !IS_DEV_ENV,
isGlobal: true
}),
PrismaModule,
AuthModule,
UserModule,
ProviderModule,
MailModule,
EmailConfirmationModule,
PasswordRecoveryModule,
TwoFactorAuthModule
]
})
export class AppModule {}

View File

@ -0,0 +1,86 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
Req,
Res,
UseGuards
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Recaptcha } from '@nestlab/google-recaptcha'
import { Request, Response } from 'express'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { RegisterDto } from './dto/register.dto'
import { AuthProviderGuard } from './guards/provider.guard'
import { ProviderService } from './provider/provider.service'
@Controller('auth')
export class AuthController {
public constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
private readonly providerService: ProviderService
) {}
@Recaptcha()
@Post('register')
@HttpCode(HttpStatus.OK)
public async register(@Body() dto: RegisterDto) {
return this.authService.register(dto)
}
@Recaptcha()
@Post('login')
@HttpCode(HttpStatus.OK)
public async login(@Req() req: Request, @Body() dto: LoginDto) {
return this.authService.login(req, dto)
}
@UseGuards(AuthProviderGuard)
@Get('/oauth/callback/:provider')
public async callback(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@Query('code') code: string,
@Param('provider') provider: string
) {
if (!code) {
throw new BadRequestException(
'Не был предоставлен код авторизации.'
)
}
await this.authService.extractProfileFromCode(req, provider, code)
return res.redirect(
`${this.configService.getOrThrow<string>('ALLOWED_ORIGIN')}/dashboard/settings`
)
}
@UseGuards(AuthProviderGuard)
@Get('/oauth/connect/:provider')
public async connect(@Param('provider') provider: string) {
const providerInstance = this.providerService.findByService(provider)
return {
url: providerInstance.getAuthUrl()
}
}
@Post('logout')
@HttpCode(HttpStatus.OK)
public async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
return this.authService.logout(req, res)
}
}

View File

@ -0,0 +1,34 @@
import { forwardRef, Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { GoogleRecaptchaModule } from '@nestlab/google-recaptcha'
import { getProvidersConfig } from '@/config/providers.config'
import { getRecaptchaConfig } from '@/config/recaptcha.config'
import { MailService } from '@/libs/mail/mail.service'
import { UserService } from '@/user/user.service'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { EmailConfirmationModule } from './email-confirmation/email-confirmation.module'
import { ProviderModule } from './provider/provider.module'
import { TwoFactorAuthService } from './two-factor-auth/two-factor-auth.service'
@Module({
imports: [
ProviderModule.registerAsync({
imports: [ConfigModule],
useFactory: getProvidersConfig,
inject: [ConfigService]
}),
GoogleRecaptchaModule.forRootAsync({
imports: [ConfigModule],
useFactory: getRecaptchaConfig,
inject: [ConfigService]
}),
forwardRef(() => EmailConfirmationModule)
],
controllers: [AuthController],
providers: [AuthService, UserService, MailService, TwoFactorAuthService],
exports: [AuthService]
})
export class AuthModule {}

View File

@ -0,0 +1,189 @@
import {
ConflictException,
Injectable,
InternalServerErrorException,
NotFoundException,
UnauthorizedException
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { AuthMethod, User } from '@prisma/__generated__'
import { verify } from 'argon2'
import { Request, Response } from 'express'
import { PrismaService } from '@/prisma/prisma.service'
import { UserService } from '@/user/user.service'
import { LoginDto } from './dto/login.dto'
import { RegisterDto } from './dto/register.dto'
import { EmailConfirmationService } from './email-confirmation/email-confirmation.service'
import { ProviderService } from './provider/provider.service'
import { TwoFactorAuthService } from './two-factor-auth/two-factor-auth.service'
@Injectable()
export class AuthService {
public constructor(
private readonly prismaService: PrismaService,
private readonly userService: UserService,
private readonly configService: ConfigService,
private readonly providerService: ProviderService,
private readonly emailConfirmationService: EmailConfirmationService,
private readonly twoFactorAuthService: TwoFactorAuthService
) {}
public async register(dto: RegisterDto) {
const isExists = await this.userService.findByEmail(dto.email)
if (isExists) {
throw new ConflictException(
'Регистрация не удалась. Пользователь с таким email уже существует. Пожалуйста, используйте другой email или войдите в систему.'
)
}
const newUser = await this.userService.create(
dto.email,
dto.password,
dto.name,
'',
AuthMethod.CREDENTIALS,
false
)
await this.emailConfirmationService.sendVerificationToken(newUser.email)
return {
message:
'Вы успешно зарегистрировались. Пожалуйста, подтвердите ваш email. Сообщение было отправлено на ваш почтовый адрес.'
}
}
public async login(req: Request, dto: LoginDto) {
const user = await this.userService.findByEmail(dto.email)
if (!user || !user.password) {
throw new NotFoundException(
'Пользователь не найден. Пожалуйста, проверьте введенные данные'
)
}
const isValidPassword = await verify(user.password, dto.password)
if (!isValidPassword) {
throw new UnauthorizedException(
'Неверный пароль. Пожалуйста, попробуйте еще раз или восстановите пароль, если забыли его.'
)
}
if (!user.isVerified) {
await this.emailConfirmationService.sendVerificationToken(
user.email
)
throw new UnauthorizedException(
'Ваш email не подтвержден. Пожалуйста, проверьте вашу почту и подтвердите адрес.'
)
}
if (user.isTwoFactorEnabled) {
if (!dto.code) {
await this.twoFactorAuthService.sendTwoFactorToken(user.email)
return {
message:
'Проверьте вашу почту. Требуется код двухфакторной аутентификации.'
}
}
await this.twoFactorAuthService.validateTwoFactorToken(
user.email,
dto.code
)
}
return this.saveSession(req, user)
}
public async extractProfileFromCode(
req: Request,
provider: string,
code: string
) {
const providerInstance = this.providerService.findByService(provider)
const profile = await providerInstance.findUserByCode(code)
const account = await this.prismaService.account.findFirst({
where: {
id: profile.id,
provider: profile.provider
}
})
let user = account?.userId
? await this.userService.findById(account.userId)
: null
if (user) {
return this.saveSession(req, user)
}
user = await this.userService.create(
profile.email,
'',
profile.name,
profile.picture,
AuthMethod[profile.provider.toUpperCase()],
true
)
if (!account) {
await this.prismaService.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: profile.provider,
accessToken: profile.access_token,
refreshToken: profile.refresh_token,
expiresAt: profile.expires_at
}
})
}
return this.saveSession(req, user)
}
public async logout(req: Request, res: Response): Promise<void> {
return new Promise((resolve, reject) => {
req.session.destroy(err => {
if (err) {
return reject(
new InternalServerErrorException(
'Не удалось завершить сессию. Возможно, возникла проблема с сервером или сессия уже была завершена.'
)
)
}
res.clearCookie(
this.configService.getOrThrow<string>('SESSION_NAME')
)
resolve()
})
})
}
public async saveSession(req: Request, user: User) {
return new Promise((resolve, reject) => {
req.session.userId = user.id
req.session.save(err => {
if (err) {
return reject(
new InternalServerErrorException(
'Не удалось сохранить сессию. Проверьте, правильно ли настроены параметры сессии.'
)
)
}
resolve({
user
})
})
})
}
}

View File

@ -0,0 +1,18 @@
import { applyDecorators, UseGuards } from '@nestjs/common'
import { UserRole } from '@prisma/__generated__'
import { AuthGuard } from '../guards/auth.guard'
import { RolesGuard } from '../guards/roles.guard'
import { Roles } from './roles.decorator'
export function Authorization(...roles: UserRole[]) {
if (roles.length > 0) {
return applyDecorators(
Roles(...roles),
UseGuards(AuthGuard, RolesGuard)
)
}
return applyDecorators(UseGuards(AuthGuard))
}

View File

@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import { User } from '@prisma/__generated__'
export const Authorized = createParamDecorator(
(data: keyof User, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest()
const user = request.user
return data ? user[data] : user
}
)

View File

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common'
import { UserRole } from '@prisma/__generated__'
export const ROLES_KEY = 'roles'
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)

View File

@ -0,0 +1,23 @@
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength
} from 'class-validator'
export class LoginDto {
@IsString({ message: 'Email должен быть строкой.' })
@IsEmail({}, { message: 'Некорректный формат email.' })
@IsNotEmpty({ message: 'Email обязателен для заполнения.' })
email: string
@IsString({ message: 'Пароль должен быть строкой.' })
@IsNotEmpty({ message: 'Поле пароль не может быть пустым.' })
@MinLength(6, { message: 'Пароль должен содержать не менее 6 символов.' })
password: string
@IsOptional()
@IsString()
code: string
}

View File

@ -0,0 +1,37 @@
import {
IsEmail,
IsNotEmpty,
IsString,
MinLength,
Validate
} from 'class-validator'
import { IsPasswordsMatchingConstraint } from '@/libs/common/decorators/is-passwords-matching-constraint.decorator'
export class RegisterDto {
@IsString({ message: 'Имя должно быть строкой.' })
@IsNotEmpty({ message: 'Имя обязательно для заполнения.' })
name: string
@IsString({ message: 'Email должен быть строкой.' })
@IsEmail({}, { message: 'Некорректный формат email.' })
@IsNotEmpty({ message: 'Email обязателен для заполнения.' })
email: string
@IsString({ message: 'Пароль должен быть строкой.' })
@IsNotEmpty({ message: 'Пароль обязателен для заполнения.' })
@MinLength(6, {
message: 'Пароль должен содержать минимум 6 символов.'
})
password: string
@IsString({ message: 'Пароль подтверждения должен быть строкой.' })
@IsNotEmpty({ message: 'Поле подтверждения пароля не может быть пустым.' })
@MinLength(6, {
message: 'Пароль подтверждения должен содержать не менее 6 символов.'
})
@Validate(IsPasswordsMatchingConstraint, {
message: 'Пароли не совпадают.'
})
passwordRepeat: string
}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator'
export class ConfirmationDto {
@IsString({ message: 'Токен должен быть строкой.' })
@IsNotEmpty({ message: 'Поле токен не может быть пустым.' })
token: string
}

View File

@ -0,0 +1,28 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req
} from '@nestjs/common'
import { Request } from 'express'
import { ConfirmationDto } from './dto/confirmation.dto'
import { EmailConfirmationService } from './email-confirmation.service'
@Controller('auth/email-confirmation')
export class EmailConfirmationController {
constructor(
private readonly emailConfirmationService: EmailConfirmationService
) {}
@Post()
@HttpCode(HttpStatus.OK)
public async newVerification(
@Req() req: Request,
@Body() dto: ConfirmationDto
) {
return this.emailConfirmationService.newVerification(req, dto)
}
}

View File

@ -0,0 +1,18 @@
import { forwardRef, Module } from '@nestjs/common'
import { MailModule } from '@/libs/mail/mail.module'
import { MailService } from '@/libs/mail/mail.service'
import { UserService } from '@/user/user.service'
import { AuthModule } from '../auth.module'
import { EmailConfirmationController } from './email-confirmation.controller'
import { EmailConfirmationService } from './email-confirmation.service'
@Module({
imports: [MailModule, forwardRef(() => AuthModule)],
controllers: [EmailConfirmationController],
providers: [EmailConfirmationService, UserService, MailService],
exports: [EmailConfirmationService]
})
export class EmailConfirmationModule {}

View File

@ -0,0 +1,123 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common'
import { TokenType } from '@prisma/__generated__'
import { Request } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { MailService } from '@/libs/mail/mail.service'
import { PrismaService } from '@/prisma/prisma.service'
import { UserService } from '@/user/user.service'
import { AuthService } from '../auth.service'
import { ConfirmationDto } from './dto/confirmation.dto'
@Injectable()
export class EmailConfirmationService {
public constructor(
private readonly prismaService: PrismaService,
private readonly mailService: MailService,
private readonly userService: UserService,
@Inject(forwardRef(() => AuthService))
private readonly authService: AuthService
) {}
public async newVerification(req: Request, dto: ConfirmationDto) {
const existingToken = await this.prismaService.token.findUnique({
where: {
token: dto.token,
type: TokenType.VERIFICATION
}
})
if (!existingToken) {
throw new NotFoundException(
'Токен подтверждения не найден. Пожалуйста, убедитесь, что у вас правильный токен.'
)
}
const hasExpired = new Date(existingToken.expiresIn) < new Date()
if (hasExpired) {
throw new BadRequestException(
'Токен подтверждения истек. Пожалуйста, запросите новый токен для подтверждения.'
)
}
const existingUser = await this.userService.findByEmail(
existingToken.email
)
if (!existingUser) {
throw new NotFoundException(
'Пользователь не найден. Пожалуйста, проверьте введенный адрес электронной почты и попробуйте снова.'
)
}
await this.prismaService.user.update({
where: {
id: existingUser.id
},
data: {
isVerified: true
}
})
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.VERIFICATION
}
})
return this.authService.saveSession(req, existingUser)
}
public async sendVerificationToken(email: string) {
const verificationToken = await this.generateVerificationToken(email)
await this.mailService.sendConfirmationEmail(
verificationToken.email,
verificationToken.token
)
return true
}
private async generateVerificationToken(email: string) {
const token = uuidv4()
const expiresIn = new Date(new Date().getTime() + 3600 * 1000)
const existingToken = await this.prismaService.token.findFirst({
where: {
email,
type: TokenType.VERIFICATION
}
})
if (existingToken) {
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.VERIFICATION
}
})
}
const verificationToken = await this.prismaService.token.create({
data: {
email,
token,
expiresIn,
type: TokenType.VERIFICATION
}
})
return verificationToken
}
}

View File

@ -0,0 +1,29 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException
} from '@nestjs/common'
import { UserService } from '@/user/user.service'
@Injectable()
export class AuthGuard implements CanActivate {
public constructor(private readonly userService: UserService) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest()
if (typeof request.session.userId === 'undefined') {
throw new UnauthorizedException(
'Пользователь не авторизован. Пожалуйста, войдите в систему, чтобы получить доступ.'
)
}
const user = await this.userService.findById(request.session.userId)
request.user = user
return true
}
}

View File

@ -0,0 +1,30 @@
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException
} from '@nestjs/common'
import { Request } from 'express'
import { ProviderService } from '../provider/provider.service'
@Injectable()
export class AuthProviderGuard implements CanActivate {
public constructor(private readonly providerService: ProviderService) {}
public canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest() as Request
const provider = request.params.provider
const providerInstance = this.providerService.findByService(provider)
if (!providerInstance) {
throw new NotFoundException(
`Провайдер "${provider}" не найден. Пожалуйста, проверьте правильность введенных данных.`
)
}
return true
}
}

View File

@ -0,0 +1,33 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { UserRole } from '@prisma/__generated__'
import { ROLES_KEY } from '../decorators/roles.decorator'
@Injectable()
export class RolesGuard implements CanActivate {
public constructor(private readonly reflector: Reflector) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass()
])
const request = context.switchToHttp().getRequest()
if (!roles) return true
if (!roles.includes(request.user.role)) {
throw new ForbiddenException(
'Недостаточно прав. У вас нет прав доступа к этому ресурсу.'
)
}
return true
}
}

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator'
export class NewPasswordDto {
@IsString({ message: 'Пароль должен быть строкой.' })
@MinLength(6, { message: 'Пароль должен содержать не менее 6 символов.' })
@IsNotEmpty({ message: 'Поле новый пароль не может быть пустым.' })
password: string
}

View File

@ -0,0 +1,7 @@
import { IsEmail, IsNotEmpty } from 'class-validator'
export class ResetPasswordDto {
@IsEmail({}, { message: 'Введите корректный адрес электронной почты.' })
@IsNotEmpty({ message: 'Поле email не может быть пустым.' })
email: string
}

View File

@ -0,0 +1,37 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Post
} from '@nestjs/common'
import { Recaptcha } from '@nestlab/google-recaptcha'
import { NewPasswordDto } from './dto/new-password.dto'
import { ResetPasswordDto } from './dto/reset-password.dto'
import { PasswordRecoveryService } from './password-recovery.service'
@Controller('auth/password-recovery')
export class PasswordRecoveryController {
constructor(
private readonly passwordRecoveryService: PasswordRecoveryService
) {}
@Recaptcha()
@Post('reset')
@HttpCode(HttpStatus.OK)
public async resetPassword(@Body() dto: ResetPasswordDto) {
return this.passwordRecoveryService.resetPassword(dto)
}
@Recaptcha()
@Post('new/:token')
@HttpCode(HttpStatus.OK)
public async newPassword(
@Body() dto: NewPasswordDto,
@Param('token') token: string
) {
return this.passwordRecoveryService.newPassword(dto, token)
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { MailService } from '@/libs/mail/mail.service'
import { UserService } from '@/user/user.service'
import { PasswordRecoveryController } from './password-recovery.controller'
import { PasswordRecoveryService } from './password-recovery.service'
@Module({
controllers: [PasswordRecoveryController],
providers: [PasswordRecoveryService, UserService, MailService]
})
export class PasswordRecoveryModule {}

View File

@ -0,0 +1,128 @@
import {
BadRequestException,
Injectable,
NotFoundException
} from '@nestjs/common'
import { TokenType } from '@prisma/__generated__'
import { hash } from 'argon2'
import { v4 as uuidv4 } from 'uuid'
import { MailService } from '@/libs/mail/mail.service'
import { PrismaService } from '@/prisma/prisma.service'
import { UserService } from '@/user/user.service'
import { NewPasswordDto } from './dto/new-password.dto'
import { ResetPasswordDto } from './dto/reset-password.dto'
@Injectable()
export class PasswordRecoveryService {
public constructor(
private readonly prismaService: PrismaService,
private readonly userService: UserService,
private readonly mailService: MailService
) {}
public async resetPassword(dto: ResetPasswordDto) {
const existingUser = await this.userService.findByEmail(dto.email)
if (!existingUser) {
throw new NotFoundException(
'Пользователь не найден. Пожалуйста, проверьте введенный адрес электронной почты и попробуйте снова.'
)
}
const passwordResetToken = await this.generatePasswordResetToken(
existingUser.email
)
await this.mailService.sendPasswordResetEmail(
passwordResetToken.email,
passwordResetToken.token
)
return true
}
public async newPassword(dto: NewPasswordDto, token: string) {
const existingToken = await this.prismaService.token.findFirst({
where: {
token,
type: TokenType.PASSWORD_RESET
}
})
if (!existingToken) {
throw new NotFoundException(
'Токен не найден. Пожалуйста, проверьте правильность введенного токена или запросите новый.'
)
}
const hasExpired = new Date(existingToken.expiresIn) < new Date()
if (hasExpired) {
throw new BadRequestException(
'Токен истек. Пожалуйста, запросите новый токен для подтверждения сброса пароля.'
)
}
const existingUser = await this.userService.findByEmail(
existingToken.email
)
if (!existingUser) {
throw new NotFoundException(
'Пользователь не найден. Пожалуйста, проверьте введенный адрес электронной почты и попробуйте снова.'
)
}
await this.prismaService.user.update({
where: {
id: existingUser.id
},
data: {
password: await hash(dto.password)
}
})
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.PASSWORD_RESET
}
})
return true
}
private async generatePasswordResetToken(email: string) {
const token = uuidv4()
const expiresIn = new Date(new Date().getTime() + 3600 * 1000)
const existingToken = await this.prismaService.token.findFirst({
where: {
email,
type: TokenType.PASSWORD_RESET
}
})
if (existingToken) {
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.PASSWORD_RESET
}
})
}
const passwordResetToken = await this.prismaService.token.create({
data: {
email,
token,
expiresIn,
type: TokenType.PASSWORD_RESET
}
})
return passwordResetToken
}
}

View File

@ -0,0 +1,13 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common'
import { BaseOAuthService } from './services/base-oauth.service'
export const ProviderOptionsSymbol = Symbol()
export type TypeOptions = {
baseUrl: string
services: BaseOAuthService[]
}
export type TypeAsyncOptions = Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider<TypeOptions>, 'useFactory' | 'inject'>

View File

@ -0,0 +1,41 @@
import { DynamicModule, Module } from '@nestjs/common'
import {
ProviderOptionsSymbol,
TypeAsyncOptions,
TypeOptions
} from './provider.constants'
import { ProviderService } from './provider.service'
@Module({})
export class ProviderModule {
public static register(options: TypeOptions): DynamicModule {
return {
module: ProviderModule,
providers: [
{
useValue: options.services,
provide: ProviderOptionsSymbol
},
ProviderService
],
exports: [ProviderService]
}
}
public static registerAsync(options: TypeAsyncOptions): DynamicModule {
return {
module: ProviderModule,
imports: options.imports,
providers: [
{
useFactory: options.useFactory,
provide: ProviderOptionsSymbol,
inject: options.inject
},
ProviderService
],
exports: [ProviderService]
}
}
}

View File

@ -0,0 +1,21 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common'
import { ProviderOptionsSymbol, TypeOptions } from './provider.constants'
import { BaseOAuthService } from './services/base-oauth.service'
@Injectable()
export class ProviderService implements OnModuleInit {
public constructor(
@Inject(ProviderOptionsSymbol) private readonly options: TypeOptions
) {}
public onModuleInit() {
for (const provider of this.options.services) {
provider.baseUrl = this.options.baseUrl
}
}
public findByService(service: string): BaseOAuthService | null {
return this.options.services.find(s => s.name === service) ?? null
}
}

View File

@ -0,0 +1,118 @@
import {
BadRequestException,
Injectable,
UnauthorizedException
} from '@nestjs/common'
import { TypeBaseProviderOptions } from './types/base-provider-options.types'
import { TypeUserInfo } from './types/user-info.types'
@Injectable()
export class BaseOAuthService {
private BASE_URL: string
public constructor(private readonly options: TypeBaseProviderOptions) {}
protected async extractUserInfo(data: any): Promise<TypeUserInfo> {
return {
...data,
provider: this.options.name
}
}
public getAuthUrl() {
const query = new URLSearchParams({
response_type: 'code',
client_id: this.options.client_id,
redirect_uri: this.getRedirectUrl(),
scope: (this.options.scopes ?? []).join(' '),
access_type: 'offline',
prompt: 'select_account'
})
return `${this.options.authorize_url}?${query}`
}
public async findUserByCode(code: string): Promise<TypeUserInfo> {
const client_id = this.options.client_id
const client_secret = this.options.client_secret
const tokenQuery = new URLSearchParams({
client_id,
client_secret,
code,
redirect_uri: this.getRedirectUrl(),
grant_type: 'authorization_code'
})
const tokensRequest = await fetch(this.options.access_url, {
method: 'POST',
body: tokenQuery,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
}
})
if (!tokensRequest.ok) {
throw new BadRequestException(
`Не удалось получить пользователя с ${this.options.profile_url}. Проверьте правильность токена доступа.`
)
}
const tokens = await tokensRequest.json()
if (!tokens.access_token) {
throw new BadRequestException(
`Нет токенов с ${this.options.access_url}. Убедитесь, что код авторизации действителен.`
)
}
const userRequest = await fetch(this.options.profile_url, {
headers: {
Authorization: `Bearer ${tokens.access_token}`
}
})
if (!userRequest.ok) {
throw new UnauthorizedException(
`Не удалось получить пользователя с ${this.options.profile_url}. Проверьте правильность токена доступа.`
)
}
const user = await userRequest.json()
const userData = await this.extractUserInfo(user)
return {
...userData,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_at || tokens.expires_in,
provider: this.options.name
}
}
public getRedirectUrl() {
return `${this.BASE_URL}/auth/oauth/callback/${this.options.name}`
}
set baseUrl(value: string) {
this.BASE_URL = value
}
get name() {
return this.options.name
}
get access_url() {
return this.options.access_url
}
get profile_url() {
return this.options.profile_url
}
get scopes() {
return this.options.scopes
}
}

View File

@ -0,0 +1,46 @@
import { BaseOAuthService } from './base-oauth.service'
import { TypeProviderOptions } from './types/provider-options.types'
import { TypeUserInfo } from './types/user-info.types'
export class GoogleProvider extends BaseOAuthService {
public constructor(options: TypeProviderOptions) {
super({
name: 'google',
authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth',
access_url: 'https://oauth2.googleapis.com/token',
profile_url: 'https://www.googleapis.com/oauth2/v3/userinfo',
scopes: options.scopes,
client_id: options.client_id,
client_secret: options.client_secret
})
}
public async extractUserInfo(data: GoogleProfile): Promise<TypeUserInfo> {
return super.extractUserInfo({
email: data.email,
name: data.name,
picture: data.picture
})
}
}
interface GoogleProfile extends Record<string, any> {
aud: string
azp: string
email: string
email_verified: boolean
exp: number
family_name?: string
given_name: string
hd?: string
iat: number
iss: string
jti?: string
locale?: string
name: string
nbf?: number
picture: string
sub: string
access_token: string
refresh_token?: string
}

View File

@ -0,0 +1,9 @@
export type TypeBaseProviderOptions = {
name: string
authorize_url: string
access_url: string
profile_url: string
scopes: string[]
client_id: string
client_secret: string
}

View File

@ -0,0 +1,5 @@
export type TypeProviderOptions = {
scopes: string[]
client_id: string
client_secret: string
}

View File

@ -0,0 +1,10 @@
export type TypeUserInfo = {
id: string
picture: string
name: string
email: string
access_token?: string | null
refresh_token?: string
expires_at?: number
provider: string
}

View File

@ -0,0 +1,47 @@
import { BaseOAuthService } from './base-oauth.service'
import { TypeProviderOptions } from './types/provider-options.types'
import { TypeUserInfo } from './types/user-info.types'
export class YandexProvider extends BaseOAuthService {
public constructor(options: TypeProviderOptions) {
super({
name: 'yandex',
authorize_url: 'https://oauth.yandex.ru/authorize',
access_url: 'https://oauth.yandex.ru/token',
profile_url: 'https://login.yandex.ru/info?format=json',
scopes: options.scopes,
client_id: options.client_id,
client_secret: options.client_secret
})
}
public async extractUserInfo(data: YandexProfile): Promise<TypeUserInfo> {
return super.extractUserInfo({
email: data.emails[0],
name: data.display_name,
picture: data.default_avatar_id
? `https://avatars.yandex.net/get-yapic/${data.default_avatar_id}/islands-200`
: undefined
})
}
}
interface YandexProfile {
login: string
id: string
client_id: string
psuid: string
emails?: string[]
default_email?: string
is_avatar_empty?: boolean
default_avatar_id?: string
birthday?: string | null
first_name?: string
last_name?: string
display_name?: string
real_name?: string
sex?: 'male' | 'female' | null
default_phone?: { id: number; number: string }
access_token: string
refresh_token?: string
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { MailService } from '@/libs/mail/mail.service'
import { TwoFactorAuthService } from './two-factor-auth.service'
@Module({
providers: [TwoFactorAuthService, MailService]
})
export class TwoFactorAuthModule {}

View File

@ -0,0 +1,100 @@
import {
BadRequestException,
Injectable,
NotFoundException
} from '@nestjs/common'
import { TokenType } from '@prisma/__generated__'
import { MailService } from '@/libs/mail/mail.service'
import { PrismaService } from '@/prisma/prisma.service'
@Injectable()
export class TwoFactorAuthService {
public constructor(
private readonly prismaService: PrismaService,
private readonly mailService: MailService
) {}
public async validateTwoFactorToken(email: string, code: string) {
const existingToken = await this.prismaService.token.findFirst({
where: {
email,
type: TokenType.TWO_FACTOR
}
})
if (!existingToken) {
throw new NotFoundException(
'Токен двухфакторной аутентификации не найден. Убедитесь, что вы запрашивали токен для данного адреса электронной почты.'
)
}
if (existingToken.token !== code) {
throw new BadRequestException(
'Неверный код двухфакторной аутентификации. Пожалуйста, проверьте введенный код и попробуйте снова.'
)
}
const hasExpired = new Date(existingToken.expiresIn) < new Date()
if (hasExpired) {
throw new BadRequestException(
'Срок действия токена двухфакторной аутентификации истек. Пожалуйста, запросите новый токен.'
)
}
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.TWO_FACTOR
}
})
return true
}
public async sendTwoFactorToken(email: string) {
const twoFactorToken = await this.generateTwoFactorToken(email)
await this.mailService.sendTwoFactorTokenEmail(
twoFactorToken.email,
twoFactorToken.token
)
return true
}
private async generateTwoFactorToken(email: string) {
const token = Math.floor(
Math.random() * (1000000 - 100000) + 100000
).toString()
const expiresIn = new Date(new Date().getTime() + 300000)
const existingToken = await this.prismaService.token.findFirst({
where: {
email,
type: TokenType.TWO_FACTOR
}
})
if (existingToken) {
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.TWO_FACTOR
}
})
}
const twoFactorToken = await this.prismaService.token.create({
data: {
email,
token,
expiresIn,
type: TokenType.TWO_FACTOR
}
})
return twoFactorToken
}
}

View File

@ -0,0 +1,21 @@
import { MailerOptions } from '@nestjs-modules/mailer'
import { ConfigService } from '@nestjs/config'
import { isDev } from '@/libs/common/utils/is-dev.util'
export const getMailerConfig = async (
configService: ConfigService
): Promise<MailerOptions> => ({
transport: {
host: configService.getOrThrow<string>('MAIL_HOST'),
port: configService.getOrThrow<number>('MAIL_PORT'),
secure: !isDev(configService),
auth: {
user: configService.getOrThrow<string>('MAIL_LOGIN'),
pass: configService.getOrThrow<string>('MAIL_PASSWORD')
}
},
defaults: {
from: `"TeaCoder Team" ${configService.getOrThrow<string>('MAIL_LOGIN')}`
}
})

View File

@ -0,0 +1,27 @@
import { ConfigService } from '@nestjs/config'
import { TypeOptions } from '@/auth/provider/provider.constants'
import { GoogleProvider } from '@/auth/provider/services/google.provider'
import { YandexProvider } from '@/auth/provider/services/yandex.provider'
export const getProvidersConfig = async (
configService: ConfigService
): Promise<TypeOptions> => ({
baseUrl: configService.getOrThrow<string>('APPLICATION_URL'),
services: [
new GoogleProvider({
client_id: configService.getOrThrow<string>('GOOGLE_CLIENT_ID'),
client_secret: configService.getOrThrow<string>(
'GOOGLE_CLIENT_SECRET'
),
scopes: ['email', 'profile']
}),
new YandexProvider({
client_id: configService.getOrThrow<string>('YANDEX_CLIENT_ID'),
client_secret: configService.getOrThrow<string>(
'YANDEX_CLIENT_SECRET'
),
scopes: ['login:email', 'login:avatar', 'login:info']
})
]
})

View File

@ -0,0 +1,12 @@
import { ConfigService } from '@nestjs/config'
import { GoogleRecaptchaModuleOptions } from '@nestlab/google-recaptcha'
import { isDev } from '@/libs/common/utils/is-dev.util'
export const getRecaptchaConfig = async (
configService: ConfigService
): Promise<GoogleRecaptchaModuleOptions> => ({
secretKey: configService.getOrThrow<string>('GOOGLE_RECAPTCHA_SECRET_KEY'),
response: req => req.headers.recaptcha,
skipIf: isDev(configService)
})

View File

@ -0,0 +1,7 @@
import 'express-session'
declare module 'express-session' {
interface SessionData {
userId?: string
}
}

View File

@ -0,0 +1,21 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator'
import { RegisterDto } from '@/auth/dto/register.dto'
@ValidatorConstraint({ name: 'IsPasswordsMatching', async: false })
export class IsPasswordsMatchingConstraint
implements ValidatorConstraintInterface
{
public validate(passwordRepeat: string, args: ValidationArguments) {
const obj = args.object as RegisterDto
return obj.password === passwordRepeat
}
public defaultMessage(validationArguments?: ValidationArguments) {
return 'Пароли не совпадают'
}
}

View File

@ -0,0 +1,9 @@
import { ConfigService } from '@nestjs/config'
import * as dotenv from 'dotenv'
dotenv.config()
export const isDev = (configService: ConfigService) =>
configService.getOrThrow('NODE_ENV') === 'development'
export const IS_DEV_ENV = process.env.NODE_ENV === 'development'

View File

@ -0,0 +1,114 @@
const s = 1000
const m = s * 60
const h = m * 60
const d = h * 24
const w = d * 7
const y = d * 365.25
type Unit =
| 'Years'
| 'Year'
| 'Yrs'
| 'Yr'
| 'Y'
| 'Weeks'
| 'Week'
| 'W'
| 'Days'
| 'Day'
| 'D'
| 'Hours'
| 'Hour'
| 'Hrs'
| 'Hr'
| 'H'
| 'Minutes'
| 'Minute'
| 'Mins'
| 'Min'
| 'M'
| 'Seconds'
| 'Second'
| 'Secs'
| 'Sec'
| 's'
| 'Milliseconds'
| 'Millisecond'
| 'Msecs'
| 'Msec'
| 'Ms'
type UnitAnyCase = Unit | Uppercase<Unit> | Lowercase<Unit>
export type StringValue =
| `${number}`
| `${number}${UnitAnyCase}`
| `${number} ${UnitAnyCase}`
// ms('1 minute'); // вернет 60000
// ms('2 hours'); // вернет 7200000
// ms('500 ms'); // вернет 500
export function ms(str: StringValue): number {
if (typeof str !== 'string' || str.length === 0 || str.length > 100) {
throw new Error(
'Value provided to ms() must be a string with length between 1 and 99.'
)
}
const match =
/^(?<value>-?(?:\d+)?\.?\d+) *(?<type>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(
str
)
const groups = match?.groups as { value: string; type?: string } | undefined
if (!groups) {
return NaN
}
const n = parseFloat(groups.value)
const type = (groups.type || 'ms').toLowerCase() as Lowercase<Unit>
switch (type) {
case 'years':
case 'year':
case 'yrs':
case 'yr':
case 'y':
return n * y
case 'weeks':
case 'week':
case 'w':
return n * w
case 'days':
case 'day':
case 'd':
return n * d
case 'hours':
case 'hour':
case 'hrs':
case 'hr':
case 'h':
return n * h
case 'minutes':
case 'minute':
case 'mins':
case 'min':
case 'm':
return n * m
case 'seconds':
case 'second':
case 'secs':
case 'sec':
case 's':
return n * s
case 'milliseconds':
case 'millisecond':
case 'msecs':
case 'msec':
case 'ms':
return n
default:
throw new Error(
`Ошибка: единица времени ${type} была распознана, но не существует соответствующего случая. Пожалуйста, проверьте введенные данные.`
)
}
}

View File

@ -0,0 +1,19 @@
export function parseBoolean(value: string): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
const lowerValue = value.trim().toLowerCase()
if (lowerValue === 'true') {
return true
}
if (lowerValue === 'false') {
return false
}
}
throw new Error(
`Не удалось преобразовать значение "${value}" в логическое значение.`
)
}

View File

@ -0,0 +1,19 @@
import { MailerModule } from '@nestjs-modules/mailer'
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { getMailerConfig } from '@/config/mailer.config'
import { MailService } from './mail.service'
@Module({
imports: [
MailerModule.forRootAsync({
imports: [ConfigModule],
useFactory: getMailerConfig,
inject: [ConfigService]
})
],
providers: [MailService]
})
export class MailModule {}

View File

@ -0,0 +1,44 @@
import { MailerService } from '@nestjs-modules/mailer'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { render } from '@react-email/components'
import { ConfirmationTemplate } from './templates/confirmation.template'
import { ResetPasswordTemplate } from './templates/reset-password.template'
import { TwoFactorAuthTemplate } from './templates/two-factor-auth.template'
@Injectable()
export class MailService {
public constructor(
private readonly mailerService: MailerService,
private readonly configService: ConfigService
) {}
public async sendConfirmationEmail(email: string, token: string) {
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
const html = await render(ConfirmationTemplate({ domain, token }))
return this.sendMail(email, 'Подтверждение почты', html)
}
public async sendPasswordResetEmail(email: string, token: string) {
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN')
const html = await render(ResetPasswordTemplate({ domain, token }))
return this.sendMail(email, 'Сброс пароля', html)
}
public async sendTwoFactorTokenEmail(email: string, token: string) {
const html = await render(TwoFactorAuthTemplate({ token }))
return this.sendMail(email, 'Подтверждение вашей личности', html)
}
private sendMail(email: string, subject: string, html: string) {
return this.mailerService.sendMail({
to: email,
subject,
html
})
}
}

View File

@ -0,0 +1,32 @@
import { Body, Heading, Link, Tailwind, Text } from "@react-email/components"
import { Html } from "@react-email/html"
import * as React from 'react'
interface ConfirmationTemplateProps {
domain: string
token: string
}
export function ConfirmationTemplate({
domain,
token
}: ConfirmationTemplateProps) {
const confirmLink = `${domain}/auth/new-verification?token=${token}`
return (
<Tailwind>
<Html>
<Body className='text-black'>
<Heading>Подтверждение почты</Heading>
<Text>
Привет! Чтобы подтвердить свой адрес электронной почты, пожалуйста, перейдите по следующей ссылке:
</Text>
<Link href={confirmLink}>Подтвердить почту</Link>
<Text>
Эта ссылка действительна в течение 1 часа. Если вы не запрашивали подтверждение, просто проигнорируйте это сообщение.
</Text>
</Body>
</Html>
</Tailwind>
)
}

View File

@ -0,0 +1,35 @@
import {
Body,
Heading,
Link,
Tailwind,
Text
} from '@react-email/components';
import { Html } from '@react-email/html';
import * as React from 'react';
interface ResetPasswordTemplateProps {
domain: string;
token: string;
}
export function ResetPasswordTemplate({ domain, token }: ResetPasswordTemplateProps) {
const resetLink = `${domain}/auth/new-password?token=${token}`;
return (
<Tailwind>
<Html>
<Body className='text-black'>
<Heading>Сброс пароля</Heading>
<Text>
Привет! Вы запросили сброс пароля. Пожалуйста, перейдите по следующей ссылке, чтобы создать новый пароль:
</Text>
<Link href={resetLink}>Подтвердить сброс пароля</Link>
<Text>
Эта ссылка действительна в течение 1 часа. Если вы не запрашивали сброс пароля, просто проигнорируйте это сообщение.
</Text>
</Body>
</Html>
</Tailwind>
);
}

View File

@ -0,0 +1,31 @@
import {
Body,
Heading,
Tailwind,
Text
} from '@react-email/components';
import { Html } from '@react-email/html';
import * as React from 'react';
interface TwoFactorAuthTemplateProps {
token: string;
}
export function TwoFactorAuthTemplate({ token }: TwoFactorAuthTemplateProps) {
return (
<Tailwind>
<Html>
<Body className='text-black'>
<Heading>Двухфакторная аутентификация</Heading>
<Text>Ваш код двухфакторной аутентификации: <strong>{token}</strong></Text>
<Text>
Пожалуйста, введите этот код в приложении для завершения процесса аутентификации.
</Text>
<Text>
Если вы не запрашивали этот код, просто проигнорируйте это сообщение.
</Text>
</Body>
</Html>
</Tailwind>
);
}

59
nestjs-server/src/main.ts Normal file
View File

@ -0,0 +1,59 @@
import { ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
import RedisStore from 'connect-redis'
import * as cookieParser from 'cookie-parser'
import * as session from 'express-session'
import IORedis from 'ioredis'
import { AppModule } from './app.module'
import { ms, StringValue } from './libs/common/utils/ms.util'
import { parseBoolean } from './libs/common/utils/parse-boolean.util'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const config = app.get(ConfigService)
const redis = new IORedis(config.getOrThrow('REDIS_URI'))
app.use(cookieParser(config.getOrThrow<string>('COOKIES_SECRET')))
app.useGlobalPipes(
new ValidationPipe({
transform: true
})
)
app.use(
session({
secret: config.getOrThrow<string>('SESSION_SECRET'),
name: config.getOrThrow<string>('SESSION_NAME'),
resave: true,
saveUninitialized: false,
cookie: {
domain: config.getOrThrow<string>('SESSION_DOMAIN'),
maxAge: ms(config.getOrThrow<StringValue>('SESSION_MAX_AGE')),
httpOnly: parseBoolean(
config.getOrThrow<string>('SESSION_HTTP_ONLY')
),
secure: parseBoolean(
config.getOrThrow<string>('SESSION_SECURE')
),
sameSite: 'lax'
},
store: new RedisStore({
client: redis,
prefix: config.getOrThrow<string>('SESSION_FOLDER')
})
})
)
app.enableCors({
origin: config.getOrThrow<string>('ALLOWED_ORIGIN'),
credentials: true,
exposedHeaders: ['set-cookie']
})
await app.listen(config.getOrThrow<number>('APPLICATION_PORT'))
}
bootstrap()

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common'
import { PrismaService } from './prisma.service'
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}

View File

@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
import { PrismaClient } from '@prisma/__generated__'
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
public async onModuleInit(): Promise<void> {
await this.$connect()
}
public async onModuleDestroy(): Promise<void> {
await this.$disconnect
}
}

View File

@ -0,0 +1,15 @@
import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator'
export class UpdateUserDto {
@IsString({ message: 'Имя должно быть строкой.' })
@IsNotEmpty({ message: 'Имя обязательно для заполнения.' })
name: string
@IsString({ message: 'Email должен быть строкой.' })
@IsEmail({}, { message: 'Некорректный формат email.' })
@IsNotEmpty({ message: 'Email обязателен для заполнения.' })
email: string
@IsBoolean({ message: 'isTwoFactorEnabled должно быть булевым значением.' })
isTwoFactorEnabled: boolean
}

View File

@ -0,0 +1,45 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Patch
} from '@nestjs/common'
import { UserRole } from '@prisma/__generated__'
import { Authorization } from '@/auth/decorators/auth.decorator'
import { Authorized } from '@/auth/decorators/authorized.decorator'
import { UpdateUserDto } from './dto/update-user.dto'
import { UserService } from './user.service'
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Authorization()
@HttpCode(HttpStatus.OK)
@Get('profile')
public async findProfile(@Authorized('id') userId: string) {
return this.userService.findById(userId)
}
@Authorization(UserRole.ADMIN)
@HttpCode(HttpStatus.OK)
@Get('by-id/:id')
public async findById(@Param('id') id: string) {
return this.userService.findById(id)
}
@Authorization()
@HttpCode(HttpStatus.OK)
@Patch('profile')
public async updateProfile(
@Authorized('id') userId: string,
@Body() dto: UpdateUserDto
) {
return this.userService.update(userId, dto)
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { UserController } from './user.controller'
import { UserService } from './user.service'
@Module({
controllers: [UserController],
providers: [UserService]
})
export class UserModule {}

View File

@ -0,0 +1,86 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import { AuthMethod } from '@prisma/__generated__'
import { hash } from 'argon2'
import { PrismaService } from '@/prisma/prisma.service'
import { UpdateUserDto } from './dto/update-user.dto'
@Injectable()
export class UserService {
public constructor(private readonly prismaService: PrismaService) {}
public async findById(id: string) {
const user = await this.prismaService.user.findUnique({
where: {
id
},
include: {
accounts: true
}
})
if (!user) {
throw new NotFoundException(
'Пользователь не найден. Пожалуйста, проверьте введенные данные.'
)
}
return user
}
public async findByEmail(email: string) {
const user = await this.prismaService.user.findUnique({
where: {
email
},
include: {
accounts: true
}
})
return user
}
public async create(
email: string,
password: string,
displayName: string,
picture: string,
method: AuthMethod,
isVerified: boolean
) {
const user = await this.prismaService.user.create({
data: {
email,
password: password ? await hash(password) : '',
displayName,
picture,
method,
isVerified
},
include: {
accounts: true
}
})
return user
}
public async update(userId: string, dto: UpdateUserDto) {
const user = await this.findById(userId)
const updatedUser = await this.prismaService.user.update({
where: {
id: user.id
},
data: {
email: dto.email,
displayName: dto.name,
isTwoFactorEnabled: dto.isTwoFactorEnabled
}
})
return updatedUser
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@prisma/__generated__": ["prisma/__generated__"],
"@prisma/__generated__/*": ["prisma/__generated__/*"]
},
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"jsx": "react"
}
}

2
nextjs-client/.env Normal file
View File

@ -0,0 +1,2 @@
SERVER_URL='http://localhost:4000'
GOOGLE_RECAPTCHA_SITE_KEY='6Lfu0OEqAAAAAEzPtIHYhNikyXWcEwhyECcKD6n-'

36
nextjs-client/.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

23
nextjs-client/.prettierrc Normal file
View File

@ -0,0 +1,23 @@
{
"trailingComma": "none",
"tabWidth": 4,
"useTabs": true,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"arrowParens": "avoid",
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrder": [
"<THIRD_PARTY_MODULES>",
"^@/app/(.*)$",
"^@/features/(.*)$",
"^@/shared/(.*)$",
"^../(.*)$",
"^./(.*)$"
],
"plugins": [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
]
}

36
nextjs-client/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/shared/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared/components",
"ui": "@/shared/components/ui",
"utils": "@/shared/utils"
}
}

View File

@ -0,0 +1,24 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
missingSuspenseWithCSRBailout: false
},
env: {
SERVER_URL: process.env.SERVER_URL,
GOOGLE_RECAPTCHA_SITE_KEY: process.env.GOOGLE_RECAPTCHA_SITE_KEY
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
{
protocol: 'https',
hostname: 'avatars.yandex.net'
}
]
}
};
export default nextConfig;

3360
nextjs-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
{
"name": "nextjs-client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@tanstack/react-query": "^5.55.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"lucide-react": "^0.439.0",
"next": "14.2.3",
"next-themes": "^0.3.0",
"react": "^18",
"react-dom": "^18",
"react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.53.0",
"react-icons": "^5.3.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-google-recaptcha": "^2.1.9",
"postcss": "^8",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@ -0,0 +1,11 @@
import type { Metadata } from 'next'
import { LoginForm } from '@/features/auth/components'
export const metadata: Metadata = {
title: 'Войти в аккаунт'
}
export default function LoginPage() {
return <LoginForm />
}

View File

@ -0,0 +1,11 @@
import type { Metadata } from 'next'
import { NewPasswordForm } from '@/features/auth/components'
export const metadata: Metadata = {
title: 'Новый пароль'
}
export default function NewPasswordPage() {
return <NewPasswordForm />
}

View File

@ -0,0 +1,11 @@
import type { Metadata } from 'next'
import { NewVerificationForm } from '@/features/auth/components'
export const metadata: Metadata = {
title: 'Подтверждение почты'
}
export default function NewVerificationPage() {
return <NewVerificationForm />
}

View File

@ -0,0 +1,11 @@
import type { Metadata } from 'next'
import { RegisterForm } from '@/features/auth/components'
export const metadata: Metadata = {
title: 'Создать аккаунт'
}
export default function RegisterPage() {
return <RegisterForm />
}

View File

@ -0,0 +1,11 @@
import type { Metadata } from 'next'
import { ResetPasswordForm } from '@/features/auth/components'
export const metadata: Metadata = {
title: 'Сброс пароля'
}
export default function ResetPasswordPage() {
return <ResetPasswordForm />
}

Some files were not shown because too many files have changed in this diff Show More