Compare commits
66 Commits
project/es
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cde4b0385 | ||
|
|
1d8e40535e | ||
|
|
e0e84a7638 | ||
|
|
658b678d80 | ||
|
|
67019e3aba | ||
|
|
40d5771845 | ||
|
|
fd43833aca | ||
|
|
e41d6e3c46 | ||
|
|
cc8b59011c | ||
|
|
3bdfbbbfb1 | ||
|
|
4eaf62da0b | ||
|
|
2d41e403ce | ||
|
|
06ced758d1 | ||
|
|
5d1dba3a2f | ||
|
|
712142a474 | ||
|
|
26a7092d74 | ||
|
|
e8824d6b8c | ||
|
|
8dbdbd8053 | ||
|
|
ab4612ff12 | ||
|
|
f8c78bfa40 | ||
|
|
01f4378e11 | ||
|
|
76c1e0f8d1 | ||
|
|
fd8837c835 | ||
|
|
85f1976386 | ||
|
|
69ff7a8ff7 | ||
|
|
946d977db8 | ||
|
|
7ac3f0cc6a | ||
|
|
0643080e68 | ||
|
|
2f3f0183e5 | ||
|
|
b28c5a4f3f | ||
|
|
1a7cf3f3c5 | ||
|
|
6e323803bd | ||
|
|
741a1d69ee | ||
|
|
bca8a64efd | ||
|
|
5c2aacdb11 | ||
|
|
8637ed2565 | ||
|
|
3bbdf1b8a7 | ||
|
|
7b73758d70 | ||
|
|
79d8e89874 | ||
|
|
426d9c80a1 | ||
|
|
dc40b71faa | ||
|
|
d63a6ed199 | ||
|
|
2932db30eb | ||
|
|
2f1dc2030c | ||
|
|
c536ba630e | ||
|
|
9085fa39c8 | ||
|
|
18e28c52e0 | ||
|
|
a683bc30cc | ||
|
|
43126e3425 | ||
|
|
68fc67913f | ||
|
|
b3641554e1 | ||
|
|
80c57e6440 | ||
|
|
e8772073a6 | ||
|
|
9dc49758fc | ||
|
|
45d30e1263 | ||
|
|
e6ff4ab199 | ||
|
|
27ad1e96dd | ||
|
|
ef071bbd8a | ||
|
|
2a840c1949 | ||
|
|
70d1a1b10c | ||
|
|
993126cfa5 | ||
|
|
490fdef2ce | ||
|
|
9e890044b5 | ||
|
|
1423575379 | ||
|
|
fd5972f17a | ||
|
|
6be2af972c |
17
.env
17
.env
@ -1,17 +0,0 @@
|
|||||||
COMPOSE_PROJECT_NAME=
|
|
||||||
NETWORK_NAME=
|
|
||||||
|
|
||||||
WEB_APP_BASE_PATH=/login
|
|
||||||
WEB_APP_TITLE=
|
|
||||||
WEB_APP_DESCRIPTION=
|
|
||||||
|
|
||||||
LDAP_BIND_DN=
|
|
||||||
LDAP_BIND_CREDENTIALS=
|
|
||||||
LDAP_DOMAIN=
|
|
||||||
LDAP_URL=
|
|
||||||
LDAP_BASE=
|
|
||||||
LDAP_ATTRIBUTE=
|
|
||||||
|
|
||||||
API_SECRET=
|
|
||||||
API_TOKEN_TTL=
|
|
||||||
API_CACHE_TTL=
|
|
||||||
6
.eslintignore
Normal file
6
.eslintignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.config.*
|
||||||
|
next-env.d.ts
|
||||||
|
.next
|
||||||
|
.eslintrc.js
|
||||||
@ -1,10 +1,11 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
// This tells ESLint to load the config from the package `eslint-config-custom`
|
|
||||||
extends: ["custom/common"],
|
|
||||||
settings: {
|
settings: {
|
||||||
next: {
|
next: {
|
||||||
rootDir: ["apps/*/"],
|
rootDir: ['apps/*/'],
|
||||||
|
},
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
43
.vscode/settings.json
vendored
43
.vscode/settings.json
vendored
@ -1,12 +1,33 @@
|
|||||||
{
|
{
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/.git": true,
|
"**/.git": true,
|
||||||
"**/.svn": true,
|
"**/.svn": true,
|
||||||
"**/.hg": true,
|
"**/.hg": true,
|
||||||
"**/CVS": true,
|
"**/CVS": true,
|
||||||
"**/.DS_Store": true,
|
"**/.DS_Store": true,
|
||||||
"**/Thumbs.db": true,
|
"**/Thumbs.db": true,
|
||||||
"**/node_modules": true
|
"**/node_modules": true
|
||||||
},
|
},
|
||||||
"explorerExclude.backup": {}
|
"explorerExclude.backup": null,
|
||||||
}
|
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"workbench.editor.labelFormat": "short",
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{ "directory": "apps/web", "changeProcessCWD": true }
|
||||||
|
],
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"json",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact",
|
||||||
|
"yaml"
|
||||||
|
],
|
||||||
|
"eslint.lintTask.enable": true,
|
||||||
|
"editor.inlineSuggest.showToolbar": "always"
|
||||||
|
}
|
||||||
|
|||||||
28
README.md
28
README.md
@ -1,24 +1,32 @@
|
|||||||
# Turborepo starter
|
# Turborepo starter
|
||||||
|
|
||||||
This is an official Yarn v1 starter turborepo.
|
This is an official starter Turborepo.
|
||||||
|
|
||||||
|
## Using this example
|
||||||
|
|
||||||
|
Run the following command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx create-turbo@latest
|
||||||
|
```
|
||||||
|
|
||||||
## What's inside?
|
## What's inside?
|
||||||
|
|
||||||
This turborepo uses [Yarn](https://classic.yarnpkg.com/) as a package manager. It includes the following packages/apps:
|
This Turborepo includes the following packages/apps:
|
||||||
|
|
||||||
### Apps and Packages
|
### Apps and Packages
|
||||||
|
|
||||||
- `docs`: a [Next.js](https://nextjs.org/) app
|
- `docs`: a [Next.js](https://nextjs.org/) app
|
||||||
- `web`: another [Next.js](https://nextjs.org/) app
|
- `web`: another [Next.js](https://nextjs.org/) app
|
||||||
- `ui`: a stub React component library shared by both `web` and `docs` applications
|
- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
|
||||||
- `eslint-config-custom`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
|
- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
|
||||||
- `tsconfig`: `tsconfig.json`s used throughout the monorepo
|
- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
|
||||||
|
|
||||||
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
|
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
|
||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
|
|
||||||
This turborepo has some additional tools already setup for you:
|
This Turborepo has some additional tools already setup for you:
|
||||||
|
|
||||||
- [TypeScript](https://www.typescriptlang.org/) for static type checking
|
- [TypeScript](https://www.typescriptlang.org/) for static type checking
|
||||||
- [ESLint](https://eslint.org/) for code linting
|
- [ESLint](https://eslint.org/) for code linting
|
||||||
@ -30,7 +38,7 @@ To build all apps and packages, run the following command:
|
|||||||
|
|
||||||
```
|
```
|
||||||
cd my-turborepo
|
cd my-turborepo
|
||||||
yarn run build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Develop
|
### Develop
|
||||||
@ -39,7 +47,7 @@ To develop all apps and packages, run the following command:
|
|||||||
|
|
||||||
```
|
```
|
||||||
cd my-turborepo
|
cd my-turborepo
|
||||||
yarn run dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Remote Caching
|
### Remote Caching
|
||||||
@ -55,7 +63,7 @@ npx turbo login
|
|||||||
|
|
||||||
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
|
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
|
||||||
|
|
||||||
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your turborepo:
|
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
|
||||||
|
|
||||||
```
|
```
|
||||||
npx turbo link
|
npx turbo link
|
||||||
@ -65,7 +73,7 @@ npx turbo link
|
|||||||
|
|
||||||
Learn more about the power of Turborepo:
|
Learn more about the power of Turborepo:
|
||||||
|
|
||||||
- [Pipelines](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
|
- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
|
||||||
- [Caching](https://turbo.build/repo/docs/core-concepts/caching)
|
- [Caching](https://turbo.build/repo/docs/core-concepts/caching)
|
||||||
- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
|
- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
|
||||||
- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)
|
- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
@ -1,61 +1,13 @@
|
|||||||
module.exports = {
|
const { createConfig } = require('@vchikalkin/eslint-config-awesome');
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
|
module.exports = createConfig('typescript', {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: 'tsconfig.json',
|
project: './tsconfig.json',
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
sourceType: 'module',
|
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint/eslint-plugin', 'prettier', 'unicorn'],
|
ignorePatterns: ['*.config.js', '.eslintrc.js'],
|
||||||
extends: [
|
|
||||||
'prettier',
|
|
||||||
'airbnb-base',
|
|
||||||
'airbnb-typescript/base',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:unicorn/recommended',
|
|
||||||
],
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
jest: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: ['.eslintrc.js'],
|
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/interface-name-prefix': 'off',
|
'import/no-duplicates': 'off',
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'import/consistent-type-specifier-style': 'off',
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
|
|
||||||
'linebreak-style': ['error', 'windows'],
|
|
||||||
'comma-dangle': 'off',
|
|
||||||
'@typescript-eslint/comma-dangle': ['off'],
|
|
||||||
|
|
||||||
'import/extensions': 'off',
|
|
||||||
'object-curly-newline': [
|
|
||||||
'warn',
|
|
||||||
{
|
|
||||||
ObjectExpression: 'always',
|
|
||||||
ObjectPattern: { multiline: true },
|
|
||||||
ImportDeclaration: 'never',
|
|
||||||
ExportDeclaration: { multiline: true, minProperties: 3 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'lines-between-class-members': 'off',
|
|
||||||
'@typescript-eslint/lines-between-class-members': ['off'],
|
|
||||||
indent: 'off',
|
|
||||||
'@typescript-eslint/indent': ['off'],
|
|
||||||
|
|
||||||
'newline-before-return': 'warn',
|
|
||||||
'@typescript-eslint/consistent-type-imports': 'error',
|
|
||||||
// Airbnb prefers forEach
|
|
||||||
'unicorn/no-array-for-each': 'off',
|
|
||||||
'unicorn/prevent-abbreviations': 'off',
|
|
||||||
'unicorn/no-null': 'off',
|
|
||||||
'unicorn/prefer-node-protocol': 'off',
|
|
||||||
'unicorn/no-array-reduce': 'off',
|
|
||||||
'unicorn/prefer-module': 'off',
|
|
||||||
'unicorn/text-encoding-identifier-case': 'off',
|
|
||||||
'import/no-unresolved': 'warn',
|
|
||||||
'import/prefer-default-export': 'off',
|
|
||||||
'class-methods-use-this': 'off',
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
|
# This Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
|
||||||
# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs.
|
# Make sure you update both files!
|
||||||
|
|
||||||
FROM node:16-alpine AS builder
|
FROM node:alpine AS builder
|
||||||
|
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||||
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN yarn global add turbo
|
RUN pnpm add -g turbo@1.12.4 dotenv-cli
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN turbo prune --scope=api --docker
|
RUN turbo prune --scope=api --docker
|
||||||
|
|
||||||
# Add lockfile and package.json's of isolated subworkspace
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
FROM node:16-alpine AS installer
|
FROM node:alpine AS installer
|
||||||
|
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||||
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@ -20,15 +24,16 @@ WORKDIR /app
|
|||||||
# First install dependencies (as they change less often)
|
# First install dependencies (as they change less often)
|
||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
COPY --from=builder /app/out/json/ .
|
COPY --from=builder /app/out/json/ .
|
||||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN yarn install
|
COPY --from=builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
# Build the project and its dependencies
|
# Build the project and its dependencies
|
||||||
COPY --from=builder /app/out/full/ .
|
COPY --from=builder /app/out/full/ .
|
||||||
COPY turbo.json turbo.json
|
COPY turbo.json turbo.json
|
||||||
RUN yarn turbo run build --filter=api...
|
RUN pnpm dotenv -e .env turbo run build --filter=api...
|
||||||
|
|
||||||
FROM node:16-alpine AS runner
|
FROM node:alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Don't run production as root
|
# Don't run production as root
|
||||||
|
|||||||
@ -1,5 +1,20 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src"
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "@nestjs/swagger/plugin",
|
||||||
|
"options": {
|
||||||
|
"dtoFileNameSuffix": [".entity.ts", ".dto.ts"],
|
||||||
|
"controllerFileNameSuffix": [".controller.ts"],
|
||||||
|
"classValidatorShim": true,
|
||||||
|
"dtoKeyOfComment": "description",
|
||||||
|
"controllerKeyOfComment": "description",
|
||||||
|
"introspectComments": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,52 +18,61 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"lint:fix": "eslint --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^8.0.0",
|
"@fastify/cookie": "^9.1.0",
|
||||||
"@nestjs/cli": "^9.0.0",
|
"@fastify/static": "^6.12.0",
|
||||||
"@nestjs/common": "^9.0.0",
|
"@nestjs/cache-manager": "^2.1.0",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/cli": "^10.1.18",
|
||||||
"@nestjs/core": "^9.0.0",
|
"@nestjs/common": "^10.2.7",
|
||||||
"@nestjs/jwt": "^9.0.0",
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/core": "^10.2.7",
|
||||||
|
"@nestjs/jwt": "^10.1.1",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/platform-express": "^9.0.0",
|
"@nestjs/mongoose": "^10.0.1",
|
||||||
"@nestjs/platform-fastify": "^9.0.11",
|
"@nestjs/platform-fastify": "^10.2.7",
|
||||||
"cache-manager": "^4.1.0",
|
"@nestjs/platform-socket.io": "^10.3.8",
|
||||||
|
"@nestjs/swagger": "^7.1.14",
|
||||||
|
"@nestjs/websockets": "^10.3.8",
|
||||||
|
"axios": "^1.5.1",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cache-manager": "^5.2.4",
|
||||||
"cache-manager-ioredis": "^2.1.0",
|
"cache-manager-ioredis": "^2.1.0",
|
||||||
"ldap-authentication": "^2.3.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"ldap-authentication": "2.3.1",
|
||||||
|
"mongoose": "^7.6.3",
|
||||||
|
"radash": "^11.0.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^5.0.5",
|
||||||
"rxjs": "^7.2.0"
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.7.5",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/schematics": "^9.0.0",
|
"@nestjs/schematics": "^10.0.2",
|
||||||
"@nestjs/testing": "^9.0.0",
|
"@nestjs/testing": "^10.2.7",
|
||||||
"@types/cache-manager": "^4.0.1",
|
"@types/bcrypt": "^5.0.1",
|
||||||
|
"@types/cache-manager": "^4.0.3",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
"@types/jest": "28.1.4",
|
"@types/jest": "29.5.5",
|
||||||
"@types/ldap-authentication": "^2.2.0",
|
"@types/ldap-authentication": "^2.2.1",
|
||||||
"@types/node": "^16.0.0",
|
"@types/node": "^20.8.6",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.14",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
"@vchikalkin/eslint-config-awesome": "^1.1.6",
|
||||||
"@typescript-eslint/parser": "^5.0.0",
|
"eslint": "^8.51.0",
|
||||||
"eslint": "^8.28.0",
|
"fastify": "4.24.3",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"jest": "29.7.0",
|
||||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
"prettier": "^3.0.3",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"source-map-support": "^0.5.21",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"supertest": "^6.3.3",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"ts-jest": "29.1.1",
|
||||||
"eslint-plugin-unicorn": "^43.0.2",
|
"ts-loader": "^9.5.0",
|
||||||
"jest": "28.1.2",
|
"ts-node": "^10.9.1",
|
||||||
"prettier": "^2.3.2",
|
"tsconfig-paths": "4.2.0",
|
||||||
"source-map-support": "^0.5.20",
|
"typescript": "5.3.2"
|
||||||
"supertest": "^6.1.3",
|
|
||||||
"ts-jest": "28.0.5",
|
|
||||||
"ts-loader": "^9.2.3",
|
|
||||||
"ts-node": "^10.0.0",
|
|
||||||
"tsconfig-paths": "4.0.0",
|
|
||||||
"typescript": "^4.3.5"
|
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
175
apps/api/src/account/account.controller.ts
Normal file
175
apps/api/src/account/account.controller.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
|
||||||
|
/* eslint-disable class-methods-use-this */
|
||||||
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
|
import { AccountService } from './account.service';
|
||||||
|
import { CreateAccountDto } from './dto/create-account.dto';
|
||||||
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
|
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { cookieOptions } from 'src/config/cookie';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
import { AuthParams, Params } from 'src/decorators/auth-mode.decorator';
|
||||||
|
import { AuthToken } from 'src/decorators/token.decorator';
|
||||||
|
import { Credentials } from 'src/dto/credentials';
|
||||||
|
import { Account } from 'src/schemas/account.schema';
|
||||||
|
import type { BaseAuthController } from 'src/types/auth-controller';
|
||||||
|
|
||||||
|
@Controller('account')
|
||||||
|
@ApiTags('account')
|
||||||
|
export class AccountController implements BaseAuthController {
|
||||||
|
constructor(private readonly accountService: AccountService) {}
|
||||||
|
|
||||||
|
private clearCookies(req, reply) {
|
||||||
|
if (req.cookies) {
|
||||||
|
Object.keys(req.cookies).forEach((cookieName) => {
|
||||||
|
reply.clearCookie(cookieName, {
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/create')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.CREATED,
|
||||||
|
type: Account,
|
||||||
|
})
|
||||||
|
async create(@Body() createAccountDto: CreateAccountDto, @Res() reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const createdAccount = await this.accountService.create(createAccountDto);
|
||||||
|
|
||||||
|
return reply.status(HttpStatus.CREATED).send(createdAccount);
|
||||||
|
} catch (error) {
|
||||||
|
throw new HttpException(error, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll() {
|
||||||
|
return this.accountService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/delete')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
type: Account,
|
||||||
|
})
|
||||||
|
// @ApiQuery({ name: 'username', type: CreateAccountDto['username'] })
|
||||||
|
async delete(@Query('username') username: string) {
|
||||||
|
return this.accountService.delete(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('/update')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
type: Account,
|
||||||
|
})
|
||||||
|
async update(@Body() updateAccountDto: UpdateAccountDto, @Res() reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const updatedAccount = await this.accountService.update(updateAccountDto);
|
||||||
|
|
||||||
|
return reply.status(HttpStatus.OK).send(updatedAccount);
|
||||||
|
} catch (error) {
|
||||||
|
throw new HttpException(error, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/reset-password')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
type: Account,
|
||||||
|
})
|
||||||
|
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Res() reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const updatedAccount = await this.accountService.resetPassword(resetPasswordDto);
|
||||||
|
|
||||||
|
return reply.status(HttpStatus.OK).send(updatedAccount);
|
||||||
|
} catch (error) {
|
||||||
|
throw new HttpException(error, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/login')
|
||||||
|
async login(
|
||||||
|
@Body() credentials: Credentials,
|
||||||
|
@Req() _req: FastifyRequest,
|
||||||
|
@Res() reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const token = await this.accountService.login(credentials);
|
||||||
|
|
||||||
|
return reply
|
||||||
|
.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions)
|
||||||
|
.status(200)
|
||||||
|
.send({ token });
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/logout')
|
||||||
|
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||||
|
this.clearCookies(req, reply);
|
||||||
|
|
||||||
|
return reply.status(302).redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/refresh-token')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async refreshToken(
|
||||||
|
@AuthToken() token: string,
|
||||||
|
@AuthParams() { refreshToken }: Params,
|
||||||
|
@Res() reply: FastifyReply
|
||||||
|
) {
|
||||||
|
if (!refreshToken) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||||
|
|
||||||
|
const newToken = await this.accountService.refreshToken(token);
|
||||||
|
|
||||||
|
reply.header('Authorization', `Bearer ${newToken}`);
|
||||||
|
|
||||||
|
return reply.setCookie(env.COOKIE_TOKEN_NAME, newToken, cookieOptions).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/get-user')
|
||||||
|
async getUser(
|
||||||
|
@Req() req: FastifyRequest,
|
||||||
|
@Res() reply: FastifyReply,
|
||||||
|
@AuthToken() token: string
|
||||||
|
) {
|
||||||
|
const account = await this.accountService.getUser(token);
|
||||||
|
if (!account) throw new UnauthorizedException('Account not found');
|
||||||
|
|
||||||
|
return reply.send(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/check-auth')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async checkAuth(@AuthToken() token: string, @Res() reply: FastifyReply) {
|
||||||
|
const { authId } = await this.accountService.parseToken(token, { ignoreExpiration: true });
|
||||||
|
|
||||||
|
if (authId) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||||
|
|
||||||
|
const user = await this.accountService.getUser(token, { ignoreExpiration: true });
|
||||||
|
|
||||||
|
return reply.status(200).send(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/account/account.module.ts
Normal file
14
apps/api/src/account/account.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { AccountController } from './account.controller';
|
||||||
|
import { AccountService } from './account.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { Account, AccountSchema } from 'src/schemas/account.schema';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AccountController],
|
||||||
|
exports: [AccountService],
|
||||||
|
imports: [MongooseModule.forFeature([{ name: Account.name, schema: AccountSchema }])],
|
||||||
|
providers: [AccountService],
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||||
|
export class AccountModule {}
|
||||||
135
apps/api/src/account/account.service.ts
Normal file
135
apps/api/src/account/account.service.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import type { CreateAccountDto } from './dto/create-account.dto';
|
||||||
|
import type { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
|
import type { UpdateAccountDto } from './dto/update-account.dto';
|
||||||
|
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { JwtVerifyOptions } from '@nestjs/jwt';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { omit } from 'radash';
|
||||||
|
import type { Credentials } from 'src/dto/credentials';
|
||||||
|
import { Account } from 'src/schemas/account.schema';
|
||||||
|
import type { DecodedToken, TokenPayload } from 'src/types/jwt';
|
||||||
|
import { generatePassword } from 'src/utils/password';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountService {
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
@InjectModel(Account.name) private accountModel: Model<Account>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async create(createAccountDto: CreateAccountDto): Promise<Account> {
|
||||||
|
const isExist = await this.accountModel.exists({ username: createAccountDto.username }).exec();
|
||||||
|
|
||||||
|
if (isExist)
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Account with username '${createAccountDto.username}' already exists`
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.keys(createAccountDto).forEach((field) => {
|
||||||
|
if (['_id', '__v'].includes(field))
|
||||||
|
throw new BadRequestException(`Prop ${field} is not allowed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = createAccountDto.password || generatePassword();
|
||||||
|
|
||||||
|
const createdAccount = new this.accountModel({ ...createAccountDto, password });
|
||||||
|
|
||||||
|
createdAccount.save();
|
||||||
|
|
||||||
|
return { ...createdAccount.toJSON(), password };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findAll(): Promise<Account[]> {
|
||||||
|
return this.accountModel.find().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(username: string) {
|
||||||
|
return this.accountModel.findOneAndDelete({ username }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update({ username, ...props }: UpdateAccountDto): Promise<Account> {
|
||||||
|
Object.keys(props).forEach((field) => {
|
||||||
|
if (['_id', '__v', 'password'].includes(field))
|
||||||
|
throw new BadRequestException(`Prop ${field} is not allowed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.accountModel.findOneAndUpdate({ username }, props).exec();
|
||||||
|
|
||||||
|
return this.accountModel.findOne({ username });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resetPassword({ username }: ResetPasswordDto): Promise<Account> {
|
||||||
|
const account = await this.accountModel.findOne({ username });
|
||||||
|
if (!account) throw new UnauthorizedException('Account not found');
|
||||||
|
|
||||||
|
const new_password = generatePassword();
|
||||||
|
await this.accountModel.findOneAndUpdate({ username }, { password: new_password }).exec();
|
||||||
|
|
||||||
|
return { password: new_password, username };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async login({ login, password }: Credentials) {
|
||||||
|
try {
|
||||||
|
const account = await this.accountModel.findOne({ username: login });
|
||||||
|
if (!account) {
|
||||||
|
throw new UnauthorizedException('Account not found');
|
||||||
|
}
|
||||||
|
const passwordMatch = await bcrypt.compare(password, account.password);
|
||||||
|
if (!passwordMatch) {
|
||||||
|
throw new UnauthorizedException('Invalid login credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: TokenPayload = {
|
||||||
|
username: login,
|
||||||
|
...omit(account.toJSON(), ['password', '_id', '__v']),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.jwtService.sign(payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshToken(token: string) {
|
||||||
|
try {
|
||||||
|
const { username } = this.jwtService.verify<DecodedToken>(token, { ignoreExpiration: true });
|
||||||
|
|
||||||
|
const account = await this.accountModel.findOne({ username });
|
||||||
|
if (!account) {
|
||||||
|
throw new UnauthorizedException('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: TokenPayload = {
|
||||||
|
username,
|
||||||
|
...omit(account.toJSON(), ['password', '_id', '__v']),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.jwtService.sign(payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUser(token: string, options?: JwtVerifyOptions) {
|
||||||
|
try {
|
||||||
|
const { username } = this.jwtService.verify<DecodedToken>(token, options);
|
||||||
|
|
||||||
|
return this.accountModel.findOne({
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseToken(token: string, options?: JwtVerifyOptions) {
|
||||||
|
try {
|
||||||
|
return this.jwtService.verify<TokenPayload>(token, options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/account/dto/create-account.dto.ts
Normal file
17
apps/api/src/account/dto/create-account.dto.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccountDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
public readonly username: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
@IsOptional()
|
||||||
|
public readonly password: string;
|
||||||
|
|
||||||
|
readonly [key: string]: unknown;
|
||||||
|
}
|
||||||
9
apps/api/src/account/dto/reset-password.dto.ts
Normal file
9
apps/api/src/account/dto/reset-password.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
public readonly username: string;
|
||||||
|
}
|
||||||
11
apps/api/src/account/dto/update-account.dto.ts
Normal file
11
apps/api/src/account/dto/update-account.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAccountDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
public readonly username: string;
|
||||||
|
|
||||||
|
readonly [key: string]: unknown;
|
||||||
|
}
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import type { TestingModule } from '@nestjs/testing';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { AppController } from './app.controller';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
|
|
||||||
describe('AppController', () => {
|
|
||||||
let appController: AppController;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const app: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
appController = app.get<AppController>(AppController);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('root', () => {
|
|
||||||
it('should return "Hello World!"', () => {
|
|
||||||
expect(appController.getHello()).toBe('Hello World!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,12 +1,51 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { AuthParams, Params } from './decorators/auth-mode.decorator';
|
||||||
|
import { AuthToken } from './decorators/token.decorator';
|
||||||
|
import { Controller, Get, HttpStatus, Req, Res } from '@nestjs/common';
|
||||||
|
import { ApiExcludeController, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ApiExcludeController()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
@Get()
|
@Get('auth')
|
||||||
getHello(): string {
|
public async auth(
|
||||||
return this.appService.getHello();
|
@Req() req: FastifyRequest,
|
||||||
|
@Res() reply: FastifyReply,
|
||||||
|
@AuthToken() token: string,
|
||||||
|
@AuthParams() { authMode }: Params
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { aud } = this.appService.checkToken(token);
|
||||||
|
const originalUri = req.headers['x-original-uri'];
|
||||||
|
|
||||||
|
if (
|
||||||
|
authMode === 'ldap-tfa' &&
|
||||||
|
aud === 'auth' &&
|
||||||
|
!['/auth', '/login', '/socket.io'].some((x) => originalUri.includes(x))
|
||||||
|
) {
|
||||||
|
return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
return reply.send();
|
||||||
|
} catch (error) {
|
||||||
|
return reply.status(HttpStatus.UNAUTHORIZED).send({ message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/check-auth')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
public async checkAuth(
|
||||||
|
@AuthParams() { authMode }: Params,
|
||||||
|
@Req() req: FastifyRequest,
|
||||||
|
@Res() reply: FastifyReply
|
||||||
|
) {
|
||||||
|
return reply.redirect(308, `${req.protocol}://${req.headers.host}/${authMode}/check-auth`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,44 @@
|
|||||||
|
import { AccountModule } from './account/account.module';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
import { env } from './config/env';
|
||||||
|
import { LdapModule } from './ldap/ldap.module';
|
||||||
|
import { LdapTfaModule } from './ldap-tfa/ldap-tfa.module';
|
||||||
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { AppController } from './app.controller';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { AppService } from './app.service';
|
import * as redisStore from 'cache-manager-ioredis';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import type { RedisOptions } from 'ioredis';
|
||||||
import { UsersModule } from './users/users.module';
|
|
||||||
import { LdapModule } from './ldap/ldap.module';
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [AppController],
|
||||||
|
exports: [JwtModule],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.SECRET,
|
secret: env.API_SECRET,
|
||||||
signOptions: {
|
signOptions: {
|
||||||
expiresIn: process.env.TOKEN_TTL,
|
expiresIn: env.API_TOKEN_TTL,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
AuthModule,
|
|
||||||
UsersModule,
|
|
||||||
LdapModule,
|
LdapModule,
|
||||||
|
AccountModule,
|
||||||
|
LdapTfaModule,
|
||||||
|
MongooseModule.forRoot(`mongodb://${env.MONGO_HOST}`),
|
||||||
|
CacheModule.register<RedisOptions>({
|
||||||
|
host: env.REDIS_HOST,
|
||||||
|
isGlobal: true,
|
||||||
|
port: env.REDIS_PORT,
|
||||||
|
store: redisStore,
|
||||||
|
ttl: env.API_CACHE_TTL,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
exports: [JwtModule],
|
|
||||||
})
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -1,8 +1,19 @@
|
|||||||
|
import type { DecodedToken } from './types/jwt';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { omit } from 'radash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppService {
|
export class AppService {
|
||||||
getHello(): string {
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
return 'Hello World!';
|
|
||||||
|
public checkToken(token: string) {
|
||||||
|
return this.jwtService.verify<DecodedToken>(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public refreshToken(token: string) {
|
||||||
|
const payload = this.jwtService.decode<DecodedToken>(token);
|
||||||
|
|
||||||
|
return this.jwtService.sign(omit(payload, ['iat', 'exp']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
import type { TestingModule } from '@nestjs/testing';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
describe('AuthController', () => {
|
|
||||||
let controller: AuthController;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [AuthController],
|
|
||||||
providers: [AuthService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<AuthController>(AuthController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
/* eslint-disable class-methods-use-this */
|
|
||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import { Body, Controller, Get, HttpException, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
|
||||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { COOKIE_TOKEN_NAME } from './lib/constants';
|
|
||||||
import { Credentials } from './types/request';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class AuthController {
|
|
||||||
cookieOptions: { maxAge: number; path: string };
|
|
||||||
constructor(private readonly authService: AuthService) {
|
|
||||||
this.cookieOptions = {
|
|
||||||
maxAge: Number.parseInt(process.env.TOKEN_TTL, 10),
|
|
||||||
path: '/',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearCookies(req, reply) {
|
|
||||||
if (req.cookies) {
|
|
||||||
Object.keys(req.cookies).forEach((cookieName) => {
|
|
||||||
reply.clearCookie(cookieName, {
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('/signin')
|
|
||||||
async login(@Body() credentials: Credentials, @Res() reply: FastifyReply) {
|
|
||||||
const { login, password } = credentials;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = await this.authService.login(login, password);
|
|
||||||
|
|
||||||
return await reply.setCookie(COOKIE_TOKEN_NAME, token, this.cookieOptions).status(200).send();
|
|
||||||
} catch {
|
|
||||||
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/logout')
|
|
||||||
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
|
||||||
const token = req.cookies[COOKIE_TOKEN_NAME];
|
|
||||||
if (token) await this.authService.logout(token);
|
|
||||||
|
|
||||||
this.clearCookies(req, reply);
|
|
||||||
|
|
||||||
return reply.status(302).redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/auth')
|
|
||||||
async auth(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
|
||||||
const token = req.cookies[COOKIE_TOKEN_NAME];
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.authService.checkToken(token);
|
|
||||||
|
|
||||||
return await reply.send();
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'TokenExpiredError') {
|
|
||||||
const newToken = this.authService.refreshToken(token);
|
|
||||||
|
|
||||||
return await reply.setCookie(COOKIE_TOKEN_NAME, newToken, this.cookieOptions).send();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { LdapModule } from '../ldap/ldap.module';
|
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [UsersModule, LdapModule],
|
|
||||||
controllers: [AuthController],
|
|
||||||
providers: [AuthService],
|
|
||||||
})
|
|
||||||
export class AuthModule {}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import type { TestingModule } from '@nestjs/testing';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
describe('AuthService', () => {
|
|
||||||
let service: AuthService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [AuthService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<AuthService>(AuthService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { LdapService } from '../ldap/ldap.service';
|
|
||||||
import { UsersCache } from '../users/users.cache';
|
|
||||||
import type { DecodedToken, TokenPayload } from './types/jwt';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
constructor(
|
|
||||||
private readonly ldapService: LdapService,
|
|
||||||
private readonly usersCache: UsersCache,
|
|
||||||
private readonly jwtService: JwtService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async login(login: string, password: string) {
|
|
||||||
const user = await this.ldapService.authenticate(login, password);
|
|
||||||
const { username } = user;
|
|
||||||
|
|
||||||
await this.usersCache.addUser(username, user);
|
|
||||||
|
|
||||||
const payload: TokenPayload = {
|
|
||||||
username,
|
|
||||||
domain: process.env.LDAP_DOMAIN,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.jwtService.sign(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async logout(token: string) {
|
|
||||||
const { username } = this.jwtService.decode(token) as DecodedToken;
|
|
||||||
await this.usersCache.deleteUser(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
public checkToken(token: string) {
|
|
||||||
this.jwtService.verify(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public refreshToken(token: string) {
|
|
||||||
const { exp, iat, ...payload } = this.jwtService.decode(token) as DecodedToken;
|
|
||||||
|
|
||||||
return this.jwtService.sign(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export const COOKIE_TOKEN_NAME = 'token';
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
export type TokenPayload = {
|
|
||||||
username: string;
|
|
||||||
domain: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DecodedToken = {
|
|
||||||
exp: number;
|
|
||||||
iat: number;
|
|
||||||
} & TokenPayload;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export type Credentials = {
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
9
apps/api/src/config/cookie.ts
Normal file
9
apps/api/src/config/cookie.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { CookieSerializeOptions } from '@fastify/cookie';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
|
||||||
|
export const cookieOptions: CookieSerializeOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: env.COOKIE_TOKEN_MAX_AGE,
|
||||||
|
path: '/',
|
||||||
|
secure: true,
|
||||||
|
};
|
||||||
3
apps/api/src/config/env.ts
Normal file
3
apps/api/src/config/env.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import envSchema from './schema/env';
|
||||||
|
|
||||||
|
export const env = envSchema.parse(process.env);
|
||||||
35
apps/api/src/config/schema/env.ts
Normal file
35
apps/api/src/config/schema/env.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
API_CACHE_TTL: z.string().transform((val) => Number.parseInt(val, 10)),
|
||||||
|
API_PORT: z.number().optional().default(3001),
|
||||||
|
API_SECRET: z.string(),
|
||||||
|
API_TOKEN_TFA_TTL: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => Number.parseInt(val, 10))
|
||||||
|
.default('300'),
|
||||||
|
API_TOKEN_TTL: z.string().transform((val) => Number.parseInt(val, 10)),
|
||||||
|
COOKIE_TOKEN_MAX_AGE: z.string().transform((val) => Number.parseInt(val, 10)),
|
||||||
|
COOKIE_TOKEN_NAME: z.string().default('token'),
|
||||||
|
LDAP_ATTRIBUTE: z.string(),
|
||||||
|
LDAP_BASE: z.string(),
|
||||||
|
LDAP_BIND_CREDENTIALS: z.string(),
|
||||||
|
LDAP_BIND_DN: z.string(),
|
||||||
|
LDAP_DOMAIN: z.string(),
|
||||||
|
LDAP_URL: z.string().url(),
|
||||||
|
MONGO_HOST: z.string(),
|
||||||
|
MONGO_PORT: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => Number.parseInt(val, 10))
|
||||||
|
.default('27017'),
|
||||||
|
REDIS_HOST: z.string(),
|
||||||
|
REDIS_PORT: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => Number.parseInt(val, 10))
|
||||||
|
.default('6379'),
|
||||||
|
TELEGRAM_URL_SEND_AUTH_LOGIN: z.string(),
|
||||||
|
TELEGRAM_URL_SEND_AUTH_MESSAGE: z.string(),
|
||||||
|
TELEGRAM_URL_SEND_AUTH_PASSWORD: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default envSchema;
|
||||||
23
apps/api/src/decorators/auth-mode.decorator.ts
Normal file
23
apps/api/src/decorators/auth-mode.decorator.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { createParamDecorator, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type AuthMode = 'ldap' | 'ldap-tfa' | 'account' | undefined;
|
||||||
|
export type RefreshToken = '1' | undefined;
|
||||||
|
export type Params = {
|
||||||
|
authMode: AuthMode;
|
||||||
|
refreshToken: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthParams = createParamDecorator<Params>((_data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const authMode = request.headers['auth-mode'] as AuthMode;
|
||||||
|
const refreshToken = (request.headers['refresh-token'] as RefreshToken) === '1';
|
||||||
|
|
||||||
|
if (!authMode) throw new UnauthorizedException('Auth mode is missing');
|
||||||
|
|
||||||
|
return {
|
||||||
|
authMode,
|
||||||
|
refreshToken,
|
||||||
|
} as Params;
|
||||||
|
});
|
||||||
14
apps/api/src/decorators/token.decorator.ts
Normal file
14
apps/api/src/decorators/token.decorator.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { env } from '../config/env';
|
||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { createParamDecorator, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const AuthToken = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const token =
|
||||||
|
request.cookies[env.COOKIE_TOKEN_NAME] || request.headers?.authorization?.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) throw new UnauthorizedException('Token is missing');
|
||||||
|
|
||||||
|
return token;
|
||||||
|
});
|
||||||
14
apps/api/src/dto/credentials.ts
Normal file
14
apps/api/src/dto/credentials.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class Credentials {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly login: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly password: string;
|
||||||
|
}
|
||||||
14
apps/api/src/dto/tfa.ts
Normal file
14
apps/api/src/dto/tfa.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class TelegramDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly authId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly employeeID: string;
|
||||||
|
}
|
||||||
116
apps/api/src/ldap-tfa/ldap-tfa.controller.ts
Normal file
116
apps/api/src/ldap-tfa/ldap-tfa.controller.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
|
||||||
|
import { LdapTfaService } from './ldap-tfa.service';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UsePipes,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { cookieOptions } from 'src/config/cookie';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
import { AuthToken } from 'src/decorators/token.decorator';
|
||||||
|
import { Credentials } from 'src/dto/credentials';
|
||||||
|
import { TelegramDto } from 'src/dto/tfa';
|
||||||
|
import { LdapController } from 'src/ldap/ldap.controller';
|
||||||
|
import { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway';
|
||||||
|
|
||||||
|
@Controller('ldap-tfa')
|
||||||
|
@ApiTags('ldap-tfa')
|
||||||
|
export class LdapTfaController extends LdapController {
|
||||||
|
constructor(
|
||||||
|
protected readonly ldapTfaService: LdapTfaService,
|
||||||
|
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||||
|
private readonly ldapTfaGateway: LdapTfaGateway
|
||||||
|
) {
|
||||||
|
super(ldapTfaService);
|
||||||
|
}
|
||||||
|
@Post('/login')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async login(
|
||||||
|
@Body() credentials: Credentials,
|
||||||
|
@Req() _req: FastifyRequest,
|
||||||
|
@Res() reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authId = crypto.randomUUID();
|
||||||
|
const token = await this.ldapTfaService.login(credentials, { authId });
|
||||||
|
const user = await this.ldapTfaService.getUser(token);
|
||||||
|
|
||||||
|
await this.cacheManager.set(authId, user, env.API_TOKEN_TFA_TTL);
|
||||||
|
|
||||||
|
return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send(user);
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/login-telegram')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async loginTelegram(@AuthToken() token: string, @Res() reply: FastifyReply) {
|
||||||
|
const { employeeID } = await this.ldapTfaService.getUser(token, { audience: 'auth' });
|
||||||
|
const { authId } = await this.ldapTfaService.parseToken(token, { audience: 'auth' });
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.get(env.TELEGRAM_URL_SEND_AUTH_MESSAGE, {
|
||||||
|
auth: {
|
||||||
|
password: env.TELEGRAM_URL_SEND_AUTH_PASSWORD,
|
||||||
|
username: env.TELEGRAM_URL_SEND_AUTH_LOGIN,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
authId,
|
||||||
|
employeeID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => reply.status(200).send(res.data))
|
||||||
|
.catch(() => reply.status(500).send());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/telegram-confirm')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async telegramConfirm(@Query() query: TelegramDto, @Res() reply: FastifyReply) {
|
||||||
|
this.ldapTfaGateway.notify('auth-allow', query);
|
||||||
|
|
||||||
|
return reply.status(200).send({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/telegram-reject')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async telegramReject(@Query() query: TelegramDto, @Res() reply: FastifyReply) {
|
||||||
|
this.ldapTfaGateway.notify('auth-deny', query);
|
||||||
|
|
||||||
|
return reply.status(200).send({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/login-confirm')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async loginConfirm(@AuthToken() token: string, @Res() reply: FastifyReply) {
|
||||||
|
const activatedToken = await this.ldapTfaService.activateToken(token, { audience: 'auth' });
|
||||||
|
|
||||||
|
return reply.setCookie(env.COOKIE_TOKEN_NAME, activatedToken, cookieOptions).status(200).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/api/src/ldap-tfa/ldap-tfa.gateway.ts
Normal file
49
apps/api/src/ldap-tfa/ldap-tfa.gateway.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import type { OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
|
||||||
|
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import type { Socket } from 'socket.io';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
import type { TelegramDto } from 'src/dto/tfa';
|
||||||
|
import type { DecodedToken } from 'src/types/jwt';
|
||||||
|
import type { User } from 'src/utils/ldap';
|
||||||
|
|
||||||
|
type UserWithSocketId = User & { socketId: string };
|
||||||
|
|
||||||
|
@WebSocketGateway({ cors: { credentials: true } })
|
||||||
|
export class LdapTfaGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
constructor(
|
||||||
|
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||||
|
private readonly jwtService: JwtService
|
||||||
|
) {
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@WebSocketServer() server: Server;
|
||||||
|
|
||||||
|
async handleConnection(client: Socket, ...args: any[]) {
|
||||||
|
const token = client.request.headers?.authorization?.split(' ')[1];
|
||||||
|
const { authId } = this.jwtService.decode(token) as DecodedToken;
|
||||||
|
const cached = this.cacheManager.get<User>(authId);
|
||||||
|
|
||||||
|
await this.cacheManager.set(authId, { ...cached, socketId: client.id }, env.API_TOKEN_TFA_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDisconnect(client: Socket) {
|
||||||
|
const token = client.request.headers?.authorization?.split(' ')[1];
|
||||||
|
const { authId } = this.jwtService.decode(token) as DecodedToken;
|
||||||
|
|
||||||
|
await this.cacheManager.del(authId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async notify<T>(event: string, { authId }: TelegramDto): Promise<void> {
|
||||||
|
const { socketId } = await this.cacheManager.get<UserWithSocketId>(authId);
|
||||||
|
this.server.to([socketId]).emit(event);
|
||||||
|
|
||||||
|
await this.cacheManager.del(authId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/ldap-tfa/ldap-tfa.module.ts
Normal file
14
apps/api/src/ldap-tfa/ldap-tfa.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-extraneous-class */
|
||||||
|
import { LdapTfaController } from './ldap-tfa.controller';
|
||||||
|
import { LdapTfaService } from './ldap-tfa.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LdapModule } from 'src/ldap/ldap.module';
|
||||||
|
import { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LdapTfaController],
|
||||||
|
exports: [LdapTfaService],
|
||||||
|
imports: [LdapModule],
|
||||||
|
providers: [LdapTfaGateway, LdapTfaService],
|
||||||
|
})
|
||||||
|
export class LdapTfaModule {}
|
||||||
57
apps/api/src/ldap-tfa/ldap-tfa.service.ts
Normal file
57
apps/api/src/ldap-tfa/ldap-tfa.service.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/* eslint-disable unicorn/no-object-as-default-parameter */
|
||||||
|
import type { TokenPayload } from '../types/jwt';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Inject, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { JwtVerifyOptions } from '@nestjs/jwt';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
import type { Credentials } from 'src/dto/credentials';
|
||||||
|
import { LdapService } from 'src/ldap/ldap.service';
|
||||||
|
import * as ldap from 'src/utils/ldap';
|
||||||
|
import type { PartialBy } from 'src/utils/types';
|
||||||
|
|
||||||
|
export class LdapTfaService extends LdapService {
|
||||||
|
constructor(
|
||||||
|
@Inject(CACHE_MANAGER) protected readonly cacheManager: Cache,
|
||||||
|
protected readonly jwtService: JwtService
|
||||||
|
) {
|
||||||
|
super(cacheManager, jwtService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async login(credentials: PartialBy<Credentials, 'password'>, additionalPayload?: object) {
|
||||||
|
try {
|
||||||
|
const user = await ldap.authenticate(credentials.login, credentials.password);
|
||||||
|
const { username } = user;
|
||||||
|
|
||||||
|
await this.cacheManager.set(username, user);
|
||||||
|
|
||||||
|
const payload: TokenPayload = {
|
||||||
|
domain: env.LDAP_DOMAIN,
|
||||||
|
username,
|
||||||
|
...additionalPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.jwtService.sign(payload, { audience: 'auth' });
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async activateToken(token: string, options: JwtVerifyOptions = { audience: 'auth' }) {
|
||||||
|
try {
|
||||||
|
const { username } = this.jwtService.verify<TokenPayload>(token, options);
|
||||||
|
const user = await ldap.authenticate(username);
|
||||||
|
await this.cacheManager.set(username, user);
|
||||||
|
|
||||||
|
const payload: TokenPayload = {
|
||||||
|
domain: env.LDAP_DOMAIN,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.jwtService.sign(payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
apps/api/src/ldap/ldap.controller.ts
Normal file
118
apps/api/src/ldap/ldap.controller.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
|
||||||
|
/* eslint-disable class-methods-use-this */
|
||||||
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
|
import { Credentials } from '../dto/credentials';
|
||||||
|
import { LdapService } from './ldap.service';
|
||||||
|
import type { CookieSerializeOptions } from '@fastify/cookie';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { cookieOptions } from 'src/config/cookie';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
import { AuthParams, Params } from 'src/decorators/auth-mode.decorator';
|
||||||
|
import { AuthToken } from 'src/decorators/token.decorator';
|
||||||
|
import type { BaseAuthController } from 'src/types/auth-controller';
|
||||||
|
import { User } from 'src/utils/ldap';
|
||||||
|
|
||||||
|
@Controller('ldap')
|
||||||
|
@ApiTags('ldap')
|
||||||
|
export class LdapController implements BaseAuthController {
|
||||||
|
cookieOptions: CookieSerializeOptions;
|
||||||
|
constructor(protected readonly ldapService: LdapService) {}
|
||||||
|
|
||||||
|
private clearCookies(req, reply) {
|
||||||
|
if (req.cookies) {
|
||||||
|
Object.keys(req.cookies).forEach((cookieName) => {
|
||||||
|
reply.clearCookie(cookieName, {
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/login')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async login(
|
||||||
|
@Body() credentials: Credentials,
|
||||||
|
@Req() _req: FastifyRequest,
|
||||||
|
@Res() reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const token = await this.ldapService.login(credentials);
|
||||||
|
|
||||||
|
return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send();
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/logout')
|
||||||
|
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply, @AuthToken() token: string) {
|
||||||
|
if (token) await this.ldapService.logout(token);
|
||||||
|
|
||||||
|
this.clearCookies(req, reply);
|
||||||
|
|
||||||
|
return reply.status(302).redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/refresh-token')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async refreshToken(
|
||||||
|
@AuthToken() token: string,
|
||||||
|
@AuthParams() { refreshToken }: Params,
|
||||||
|
@Res() reply: FastifyReply
|
||||||
|
) {
|
||||||
|
if (!refreshToken) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||||
|
|
||||||
|
const newToken = await this.ldapService.refreshToken(token);
|
||||||
|
|
||||||
|
reply.header('Authorization', `Bearer ${newToken}`);
|
||||||
|
|
||||||
|
return reply.setCookie(env.COOKIE_TOKEN_NAME, newToken, cookieOptions).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/get-user')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
type: User,
|
||||||
|
})
|
||||||
|
async getUser(
|
||||||
|
@Req() _req: FastifyRequest,
|
||||||
|
@Res() reply: FastifyReply,
|
||||||
|
@AuthToken() token: string
|
||||||
|
) {
|
||||||
|
const user = await this.ldapService.getUser(token);
|
||||||
|
|
||||||
|
if (!user) throw new UnauthorizedException('User not found');
|
||||||
|
|
||||||
|
return reply.send(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/check-auth')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
})
|
||||||
|
async checkAuth(@AuthToken() token: string, @Res() reply: FastifyReply) {
|
||||||
|
const { authId } = await this.ldapService.parseToken(token, { ignoreExpiration: true });
|
||||||
|
|
||||||
|
if (authId) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||||
|
|
||||||
|
const user = await this.ldapService.getUser(token, { ignoreExpiration: true });
|
||||||
|
|
||||||
|
return reply.status(200).send(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { LdapController } from './ldap.controller';
|
||||||
import { LdapService } from './ldap.service';
|
import { LdapService } from './ldap.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [LdapService],
|
controllers: [LdapController],
|
||||||
exports: [LdapService],
|
exports: [LdapService],
|
||||||
|
imports: [],
|
||||||
|
providers: [LdapService],
|
||||||
})
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||||
export class LdapModule {}
|
export class LdapModule {}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
import type { TestingModule } from '@nestjs/testing';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { LdapService } from './ldap.service';
|
|
||||||
|
|
||||||
describe('LdapService', () => {
|
|
||||||
let service: LdapService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [LdapService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<LdapService>(LdapService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,43 +1,94 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import type { DecodedToken, TokenPayload } from '../types/jwt';
|
||||||
import type { AuthenticationOptions } from 'ldap-authentication';
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
import { authenticate } from 'ldap-authentication';
|
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import type { User } from '../types/user';
|
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||||
import type { LdapUser } from './types/user';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
import type { Credentials } from 'src/dto/credentials';
|
||||||
|
import * as ldap from 'src/utils/ldap';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LdapService {
|
export class LdapService {
|
||||||
async authenticate(login: string, password?: string) {
|
constructor(
|
||||||
const options: AuthenticationOptions = {
|
@Inject(CACHE_MANAGER) protected readonly cacheManager: Cache,
|
||||||
ldapOpts: {
|
protected readonly jwtService: JwtService
|
||||||
url: process.env.LDAP_URL,
|
) {}
|
||||||
},
|
|
||||||
adminDn: process.env.LDAP_BIND_DN,
|
|
||||||
adminPassword: process.env.LDAP_BIND_CREDENTIALS,
|
|
||||||
userSearchBase: process.env.LDAP_BASE,
|
|
||||||
usernameAttribute: process.env.LDAP_ATTRIBUTE,
|
|
||||||
username: login,
|
|
||||||
userPassword: password,
|
|
||||||
verifyUserExists: password === undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
public async login(credentials: Credentials, options?: JwtSignOptions) {
|
||||||
displayName,
|
try {
|
||||||
department,
|
const user = await ldap.authenticate(credentials.login, credentials.password);
|
||||||
title,
|
const { username } = user;
|
||||||
mail,
|
|
||||||
sAMAccountName: username,
|
|
||||||
}: LdapUser = await authenticate(options);
|
|
||||||
|
|
||||||
const user: User = {
|
await this.cacheManager.set(username, user);
|
||||||
username,
|
|
||||||
domain: process.env.LDAP_DOMAIN,
|
|
||||||
displayName,
|
|
||||||
department,
|
|
||||||
position: title,
|
|
||||||
mail,
|
|
||||||
domainName: `${process.env.LDAP_DOMAIN}\\${username}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return user;
|
const payload: TokenPayload = {
|
||||||
|
domain: env.LDAP_DOMAIN,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.jwtService.sign(payload, options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async logout(token: string) {
|
||||||
|
const { username } = this.jwtService.decode(token) as DecodedToken;
|
||||||
|
|
||||||
|
if (this.cacheManager.get(username)) {
|
||||||
|
await this.cacheManager.del(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshToken(token: string) {
|
||||||
|
try {
|
||||||
|
const { username, aud = '' } = this.jwtService.verify<DecodedToken>(token, {
|
||||||
|
ignoreExpiration: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aud === 'auth') throw new UnauthorizedException();
|
||||||
|
|
||||||
|
const user = await ldap.authenticate(username);
|
||||||
|
|
||||||
|
await this.cacheManager.set(username, user);
|
||||||
|
|
||||||
|
const payload: TokenPayload = {
|
||||||
|
domain: env.LDAP_DOMAIN,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.jwtService.sign(payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUser(token: string, options?: JwtVerifyOptions) {
|
||||||
|
try {
|
||||||
|
const { username } = this.jwtService.verify(token, options) as DecodedToken;
|
||||||
|
|
||||||
|
const cachedUser = await this.cacheManager.get<ldap.User>(username);
|
||||||
|
|
||||||
|
if (!cachedUser) {
|
||||||
|
const user = await ldap.authenticate(username);
|
||||||
|
|
||||||
|
await this.cacheManager.set(username, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedUser;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseToken(token: string, options?: JwtVerifyOptions) {
|
||||||
|
try {
|
||||||
|
return this.jwtService.verify<TokenPayload>(token, options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,24 @@
|
|||||||
|
/* eslint-disable import/no-duplicates */
|
||||||
/* eslint-disable unicorn/prefer-top-level-await */
|
/* eslint-disable unicorn/prefer-top-level-await */
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import { AppModule } from './app.module';
|
||||||
|
import { env } from './config/env';
|
||||||
|
import { fastifyCookie } from '@fastify/cookie';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import { FastifyAdapter } from '@nestjs/platform-fastify';
|
import { FastifyAdapter } from '@nestjs/platform-fastify';
|
||||||
import { AppModule } from './app.module';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
function setupOpenApi(app: INestApplication) {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Evo.Auth')
|
||||||
|
.setVersion('1.0')
|
||||||
|
// .addTag('api')
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('swagger', app, document, { useGlobalPrefix: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@ -14,10 +29,14 @@ async function bootstrap() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await app.register(fastifyCookie, {
|
await app.register(fastifyCookie, {
|
||||||
secret: process.env.SECRET,
|
secret: env.API_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen(process.env.API_PORT || 3001, '0.0.0.0');
|
app.useGlobalPipes(new ValidationPipe({ stopAtFirstError: true }));
|
||||||
|
|
||||||
|
setupOpenApi(app);
|
||||||
|
|
||||||
|
await app.listen(env.API_PORT, '0.0.0.0');
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
51
apps/api/src/schemas/account.schema.ts
Normal file
51
apps/api/src/schemas/account.schema.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/* eslint-disable @babel/no-invalid-this */
|
||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import type { HydratedDocument } from 'mongoose';
|
||||||
|
|
||||||
|
export type UserDocument = HydratedDocument<Account>;
|
||||||
|
|
||||||
|
@Schema({ strict: false })
|
||||||
|
export class Account {
|
||||||
|
@ApiResponseProperty()
|
||||||
|
@ApiProperty()
|
||||||
|
@Prop({ index: { unique: true }, required: true })
|
||||||
|
public username: string;
|
||||||
|
|
||||||
|
@ApiResponseProperty()
|
||||||
|
@ApiProperty()
|
||||||
|
@Prop({ required: true })
|
||||||
|
public password: string;
|
||||||
|
|
||||||
|
readonly [key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountSchema = SchemaFactory.createForClass(Account);
|
||||||
|
|
||||||
|
AccountSchema.pre('save', async function (next) {
|
||||||
|
try {
|
||||||
|
if (!this.isModified('password')) return next();
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(this.password, 10);
|
||||||
|
this.password = hash;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AccountSchema.pre('findOneAndUpdate', async function (next) {
|
||||||
|
try {
|
||||||
|
const password = this.get('password');
|
||||||
|
if (password) {
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
this.set('password', hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
8
apps/api/src/types/auth-controller.ts
Normal file
8
apps/api/src/types/auth-controller.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import type { Credentials } from 'src/dto/credentials';
|
||||||
|
|
||||||
|
export type BaseAuthController = {
|
||||||
|
getUser: (req: FastifyRequest, reply: FastifyReply, token: string) => Promise<never>;
|
||||||
|
login: (credentials: Credentials, req: FastifyRequest, reply: FastifyReply) => Promise<never>;
|
||||||
|
logout: (req: FastifyRequest, reply: FastifyReply, token: string) => Promise<never>;
|
||||||
|
};
|
||||||
13
apps/api/src/types/jwt.ts
Normal file
13
apps/api/src/types/jwt.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { JwtSignOptions } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
export type TokenPayload = {
|
||||||
|
[key: string]: unknown;
|
||||||
|
authId?: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DecodedToken = {
|
||||||
|
aud?: JwtSignOptions['audience'];
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
} & TokenPayload;
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { Cache } from 'cache-manager';
|
|
||||||
import type { User } from '../types/user';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UsersCache {
|
|
||||||
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}
|
|
||||||
async getUser(username: string) {
|
|
||||||
const user = (await this.cacheManager.get(username)) as User;
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addUser(username: string, user: User) {
|
|
||||||
await this.cacheManager.set(username, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUser(username: string) {
|
|
||||||
if (this.cacheManager.get(username)) {
|
|
||||||
await this.cacheManager.del(username);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import type { TestingModule } from '@nestjs/testing';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { UsersController } from './users.controller';
|
|
||||||
import { UsersService } from './users.service';
|
|
||||||
|
|
||||||
describe('UsersController', () => {
|
|
||||||
let controller: UsersController;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [UsersController],
|
|
||||||
providers: [UsersService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<UsersController>(UsersController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
/* eslint-disable class-methods-use-this */
|
|
||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import { Controller, Get, Req, Res } from '@nestjs/common';
|
|
||||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import { COOKIE_TOKEN_NAME } from '../auth/lib/constants';
|
|
||||||
import { UsersService } from './users.service';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class UsersController {
|
|
||||||
constructor(private readonly usersService: UsersService) {}
|
|
||||||
|
|
||||||
@Get('/get-user')
|
|
||||||
async getUser(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
|
||||||
const token = req.cookies[COOKIE_TOKEN_NAME];
|
|
||||||
|
|
||||||
const user = await this.usersService.getUser(token);
|
|
||||||
|
|
||||||
return reply.send(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { CacheModule, Module } from '@nestjs/common';
|
|
||||||
import * as redisStore from 'cache-manager-ioredis';
|
|
||||||
import type { RedisOptions } from 'ioredis';
|
|
||||||
import { LdapModule } from '../ldap/ldap.module';
|
|
||||||
import { UsersCache } from './users.cache';
|
|
||||||
import { UsersController } from './users.controller';
|
|
||||||
import { UsersService } from './users.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
CacheModule.register<RedisOptions>({
|
|
||||||
store: redisStore,
|
|
||||||
host: process.env.REDIS_HOST,
|
|
||||||
port: Number.parseInt(process.env.REDIS_PORT, 10) || 6379,
|
|
||||||
ttl: Number.parseInt(process.env.CACHE_TTL, 10),
|
|
||||||
}),
|
|
||||||
LdapModule,
|
|
||||||
],
|
|
||||||
controllers: [UsersController],
|
|
||||||
providers: [UsersService, UsersCache],
|
|
||||||
exports: [UsersCache],
|
|
||||||
})
|
|
||||||
export class UsersModule {}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import type { TestingModule } from '@nestjs/testing';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { UsersService } from './users.service';
|
|
||||||
|
|
||||||
describe('UsersService', () => {
|
|
||||||
let service: UsersService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [UsersService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<UsersService>(UsersService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { LdapService } from '../ldap/ldap.service';
|
|
||||||
import type { DecodedToken } from '../auth/types/jwt';
|
|
||||||
import { UsersCache } from './users.cache';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UsersService {
|
|
||||||
constructor(
|
|
||||||
private readonly usersCache: UsersCache,
|
|
||||||
private readonly jwtService: JwtService,
|
|
||||||
private readonly ldapService: LdapService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async getUser(token: string) {
|
|
||||||
const { username } = this.jwtService.decode(token) as DecodedToken;
|
|
||||||
|
|
||||||
const cachedUser = await this.usersCache.getUser(username);
|
|
||||||
|
|
||||||
if (!cachedUser) {
|
|
||||||
const user = await this.ldapService.authenticate(username);
|
|
||||||
|
|
||||||
await this.usersCache.addUser(username, user);
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedUser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3
apps/api/src/utils/error.ts
Normal file
3
apps/api/src/utils/error.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function isTokenExpired(error: Error) {
|
||||||
|
return error.name?.toLocaleLowerCase().includes('expired');
|
||||||
|
}
|
||||||
@ -1,63 +1,128 @@
|
|||||||
|
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||||
|
import type { AuthenticationOptions } from 'ldap-authentication';
|
||||||
|
import * as ldap from 'ldap-authentication';
|
||||||
|
import { env } from 'src/config/env';
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public department: string;
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public displayName: string;
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public domain: string;
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public domainName: string;
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public mail: string;
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public position: string;
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public username: string;
|
||||||
|
@ApiResponseProperty()
|
||||||
|
public employeeID: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type LdapUser = {
|
export type LdapUser = {
|
||||||
dn: string;
|
accountExpires: string;
|
||||||
controls: any[];
|
|
||||||
objectClass: string[];
|
|
||||||
cn: string;
|
|
||||||
sn: string;
|
|
||||||
c: string;
|
|
||||||
title: string;
|
|
||||||
physicalDeliveryOfficeName: string;
|
|
||||||
givenName: string;
|
|
||||||
distinguishedName: string;
|
|
||||||
instanceType: string;
|
|
||||||
whenCreated: string;
|
|
||||||
whenChanged: string;
|
|
||||||
displayName: string;
|
|
||||||
uSNCreated: string;
|
|
||||||
memberOf: string[];
|
|
||||||
uSNChanged: string;
|
|
||||||
co: string;
|
|
||||||
department: string;
|
|
||||||
proxyAddresses: string[];
|
|
||||||
name: string;
|
|
||||||
objectGUID: string;
|
|
||||||
userAccountControl: string;
|
|
||||||
badPwdCount: string;
|
|
||||||
codePage: string;
|
|
||||||
countryCode: string;
|
|
||||||
employeeID: string;
|
|
||||||
badPasswordTime: string;
|
badPasswordTime: string;
|
||||||
|
badPwdCount: string;
|
||||||
|
c: string;
|
||||||
|
cn: string;
|
||||||
|
co: string;
|
||||||
|
codePage: string;
|
||||||
|
controls: unknown[];
|
||||||
|
countryCode: string;
|
||||||
|
dSCorePropagationData: string[];
|
||||||
|
department: string;
|
||||||
|
displayName: string;
|
||||||
|
distinguishedName: string;
|
||||||
|
dn: string;
|
||||||
|
employeeID: string;
|
||||||
|
extensionAttribute1: string;
|
||||||
|
extensionAttribute13: string;
|
||||||
|
extensionAttribute2: string;
|
||||||
|
givenName: string;
|
||||||
|
instanceType: string;
|
||||||
lastLogoff: string;
|
lastLogoff: string;
|
||||||
lastLogon: string;
|
lastLogon: string;
|
||||||
pwdLastSet: string;
|
lastLogonTimestamp: string;
|
||||||
primaryGroupID: string;
|
legacyExchangeDN: string;
|
||||||
objectSid: string;
|
lockoutTime: string;
|
||||||
accountExpires: string;
|
|
||||||
logonCount: string;
|
logonCount: string;
|
||||||
|
'mS-DS-ConsistencyGuid': string;
|
||||||
|
mail: string;
|
||||||
|
mailNickname: string;
|
||||||
|
memberOf: string[];
|
||||||
|
middleName: string;
|
||||||
|
'msDS-KeyCredentialLink': string;
|
||||||
|
msExchMailboxGuid: string;
|
||||||
|
msExchPoliciesIncluded: string[];
|
||||||
|
msExchRecipientDisplayType: string;
|
||||||
|
msExchRecipientTypeDetails: string;
|
||||||
|
msExchRemoteRecipientType: string;
|
||||||
|
msExchUMDtmfMap: string[];
|
||||||
|
msExchVersion: string;
|
||||||
|
name: string;
|
||||||
|
objectCategory: string;
|
||||||
|
objectClass: string[];
|
||||||
|
objectGUID: string;
|
||||||
|
objectSid: string;
|
||||||
|
physicalDeliveryOfficeName: string;
|
||||||
|
preferredLanguage: string;
|
||||||
|
primaryGroupID: string;
|
||||||
|
proxyAddresses: string[];
|
||||||
|
pwdLastSet: string;
|
||||||
sAMAccountName: string;
|
sAMAccountName: string;
|
||||||
sAMAccountType: string;
|
sAMAccountType: string;
|
||||||
showInAddressBook: string[];
|
showInAddressBook: string[];
|
||||||
legacyExchangeDN: string;
|
sn: string;
|
||||||
userPrincipalName: string;
|
|
||||||
lockoutTime: string;
|
|
||||||
objectCategory: string;
|
|
||||||
dSCorePropagationData: string[];
|
|
||||||
'mS-DS-ConsistencyGuid': string;
|
|
||||||
lastLogonTimestamp: string;
|
|
||||||
'msDS-KeyCredentialLink': string;
|
|
||||||
mail: string;
|
|
||||||
middleName: string;
|
|
||||||
preferredLanguage: string;
|
|
||||||
extensionAttribute2: string;
|
|
||||||
msExchVersion: string;
|
|
||||||
msExchRecipientDisplayType: string;
|
|
||||||
msExchRecipientTypeDetails: string;
|
|
||||||
extensionAttribute1: string;
|
|
||||||
msExchMailboxGuid: string;
|
|
||||||
targetAddress: string;
|
targetAddress: string;
|
||||||
msExchPoliciesIncluded: string[];
|
title: string;
|
||||||
extensionAttribute13: string;
|
uSNChanged: string;
|
||||||
mailNickname: string;
|
uSNCreated: string;
|
||||||
msExchRemoteRecipientType: string;
|
userAccountControl: string;
|
||||||
msExchUMDtmfMap: string[];
|
userPrincipalName: string;
|
||||||
|
whenChanged: string;
|
||||||
|
whenCreated: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BASE_OPTIONS: AuthenticationOptions = {
|
||||||
|
adminDn: env.LDAP_BIND_DN,
|
||||||
|
adminPassword: env.LDAP_BIND_CREDENTIALS,
|
||||||
|
ldapOpts: {
|
||||||
|
url: env.LDAP_URL,
|
||||||
|
},
|
||||||
|
userSearchBase: env.LDAP_BASE,
|
||||||
|
usernameAttribute: env.LDAP_ATTRIBUTE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function authenticate(login: string, password?: string) {
|
||||||
|
const options: AuthenticationOptions = {
|
||||||
|
...BASE_OPTIONS,
|
||||||
|
userPassword: password,
|
||||||
|
username: login,
|
||||||
|
verifyUserExists: password === undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
displayName,
|
||||||
|
department,
|
||||||
|
title,
|
||||||
|
mail,
|
||||||
|
sAMAccountName: username,
|
||||||
|
employeeID,
|
||||||
|
}: LdapUser = await ldap.authenticate(options);
|
||||||
|
|
||||||
|
const user: User = {
|
||||||
|
department,
|
||||||
|
displayName,
|
||||||
|
domain: env.LDAP_DOMAIN,
|
||||||
|
domainName: `${env.LDAP_DOMAIN}\\${username}`,
|
||||||
|
employeeID,
|
||||||
|
mail,
|
||||||
|
position: title,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
7
apps/api/src/utils/password.ts
Normal file
7
apps/api/src/utils/password.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$';
|
||||||
|
|
||||||
|
export function generatePassword(length = 10) {
|
||||||
|
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
|
||||||
|
.map((x) => characters[x % characters.length])
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
1
apps/api/src/utils/types.ts
Normal file
1
apps/api/src/utils/types.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import type { TestingModule } from '@nestjs/testing';
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import type { INestApplication } from '@nestjs/common';
|
|
||||||
import * as request from 'supertest';
|
import * as request from 'supertest';
|
||||||
import { AppModule } from '../src/app.module';
|
|
||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
describe('AppController (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@ -16,8 +16,5 @@ describe('AppController (e2e)', () => {
|
|||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => request(app.getHttpServer())
|
it('/ (GET)', () => request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'));
|
||||||
.get('/')
|
|
||||||
.expect(200)
|
|
||||||
.expect('Hello World!'));
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "es2017",
|
"target": "ES2021",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
@ -17,5 +17,6 @@
|
|||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"forceConsistentCasingInFileNames": false,
|
"forceConsistentCasingInFileNames": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
5767
apps/api/yarn.lock
5767
apps/api/yarn.lock
File diff suppressed because it is too large
Load Diff
14
apps/web/.eslintrc.js
Normal file
14
apps/web/.eslintrc.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const { createConfig } = require('@vchikalkin/eslint-config-awesome');
|
||||||
|
|
||||||
|
module.exports = createConfig('next-typescript', {
|
||||||
|
parserOptions: {
|
||||||
|
project: './tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'import/no-duplicates': 'off',
|
||||||
|
'import/consistent-type-specifier-style': 'off',
|
||||||
|
'react/forbid-component-props': 'off',
|
||||||
|
},
|
||||||
|
ignorePatterns: ['*.config.js', '.eslintrc.js'],
|
||||||
|
});
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
@ -1,49 +1,44 @@
|
|||||||
# This Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
|
# This Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
|
||||||
# Make sure you update both files!
|
# Make sure you update both files!
|
||||||
|
|
||||||
FROM node:16-alpine AS builder
|
FROM node:alpine AS builder
|
||||||
|
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||||
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN yarn global add turbo
|
RUN pnpm add -g turbo@1.12.4 dotenv-cli
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN turbo prune --scope=web --docker
|
RUN turbo prune --scope=web --docker
|
||||||
|
|
||||||
# Add lockfile and package.json's of isolated subworkspace
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
FROM node:16-alpine AS installer
|
FROM node:alpine AS installer
|
||||||
|
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||||
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
# First install the dependencies (as they change less often)
|
# First install the dependencies (as they change less often)
|
||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
COPY --from=builder /app/out/json/ .
|
COPY --from=builder /app/out/json/ .
|
||||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN yarn install
|
COPY --from=builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
COPY --from=builder /app/out/full/ .
|
COPY --from=builder /app/out/full/ .
|
||||||
COPY turbo.json turbo.json
|
COPY turbo.json turbo.json
|
||||||
|
ARG APP_BASE_PATH
|
||||||
# Pass variables from .env
|
|
||||||
ARG BASE_PATH
|
|
||||||
ARG APP_TITLE
|
|
||||||
ARG APP_DESCRIPTION
|
ARG APP_DESCRIPTION
|
||||||
ENV BASE_PATH=${BASE_PATH}
|
ARG TELEGRAM_BOT_URL
|
||||||
ENV APP_TITLE=${APP_TITLE}
|
RUN pnpm dotenv -e .env turbo run build --filter=web...
|
||||||
ENV APP_DESCRIPTION=${APP_DESCRIPTION}
|
|
||||||
|
|
||||||
RUN yarn turbo run build --filter=web...
|
FROM node:alpine AS runner
|
||||||
|
|
||||||
FROM node:16-alpine AS runner
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
# Don't run production as root
|
# Don't run production as root
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
import Input from 'elements/Input';
|
|
||||||
import Button from 'elements/Button';
|
|
||||||
import styles from './Form.module.scss';
|
|
||||||
import { H3 } from 'elements/H';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Error from 'elements/Error';
|
|
||||||
import getConfig from 'next/config';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
const { publicRuntimeConfig: config } = getConfig();
|
|
||||||
|
|
||||||
export default function Form() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [hasError, setError] = useState(false);
|
|
||||||
const error = hasError ? <Error>Неверный логин или пароль</Error> : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className={styles.form}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const login = e.target[0].value;
|
|
||||||
const password = e.target[1].value;
|
|
||||||
const data = { login, password };
|
|
||||||
|
|
||||||
axios
|
|
||||||
.post('/signin', data)
|
|
||||||
.then(() => {
|
|
||||||
router.reload();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setError(true);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<H3>{config.appTitle}</H3>
|
|
||||||
<Input name="login" type="text" placeholder="Логин" required autoComplete="on" />
|
|
||||||
<Input name="password" type="password" placeholder="Пароль" required autoComplete="on" />
|
|
||||||
{error}
|
|
||||||
<Button>Войти</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
44
apps/web/components/Form/base-form.tsx
Normal file
44
apps/web/components/Form/base-form.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import styles from './Form.module.scss';
|
||||||
|
import type { FormData, FormProps } from './lib/types';
|
||||||
|
import { publicRuntimeConfig } from '@/config/runtime';
|
||||||
|
import { FormStateContext } from '@/context/form-state';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
const { TELEGRAM_BOT_URL } = publicRuntimeConfig;
|
||||||
|
|
||||||
|
export function BaseForm({ children, onSubmit }: FormProps & PropsWithChildren) {
|
||||||
|
const { handleSubmit, register } = useForm<FormData>();
|
||||||
|
const {
|
||||||
|
state: { error, step },
|
||||||
|
} = useContext(FormStateContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<input
|
||||||
|
disabled={step !== 'login'}
|
||||||
|
type="text"
|
||||||
|
placeholder="Логин"
|
||||||
|
required
|
||||||
|
autoComplete="on"
|
||||||
|
{...register('login', { required: true })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
disabled={step !== 'login'}
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
required
|
||||||
|
autoComplete="on"
|
||||||
|
{...register('password', { required: true })}
|
||||||
|
/>
|
||||||
|
{step === 'login-success' || step === 'telegram-notification' ? (
|
||||||
|
<a target="_blank" className="info" href={TELEGRAM_BOT_URL} rel="noreferrer">
|
||||||
|
Открыть чат с ботом
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{error ? <span className="error">{error}</span> : null}
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/components/Form/default-form.tsx
Normal file
25
apps/web/components/Form/default-form.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { BaseForm } from './base-form';
|
||||||
|
import { useLogin } from './hooks/default';
|
||||||
|
import { useRefreshToken } from './hooks/token';
|
||||||
|
import { ButtonLoading, ButtonLogin } from './lib/buttons';
|
||||||
|
import { FormStateContext } from '@/context/form-state';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export function DefaultForm() {
|
||||||
|
useRefreshToken();
|
||||||
|
const { handleLogin } = useLogin();
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: { step },
|
||||||
|
} = useContext(FormStateContext);
|
||||||
|
|
||||||
|
if (step === 'refresh-token') {
|
||||||
|
return <ButtonLoading>Подождите...</ButtonLoading>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseForm onSubmit={(data) => handleLogin(data)}>
|
||||||
|
<ButtonLogin>Войти</ButtonLogin>
|
||||||
|
</BaseForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/components/Form/hooks/default.ts
Normal file
24
apps/web/components/Form/hooks/default.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { FormData } from '../lib/types';
|
||||||
|
import { redirect } from '@/components/Form/lib/utils';
|
||||||
|
import { ERROR_INVALID_CREDENTIALS } from '@/constants/errors';
|
||||||
|
import { FormStateContext } from '@/context/form-state';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const { dispatch } = useContext(FormStateContext);
|
||||||
|
|
||||||
|
function handleLogin(data: FormData) {
|
||||||
|
return axios
|
||||||
|
.post('/login', data)
|
||||||
|
.then(() => redirect())
|
||||||
|
.catch(() =>
|
||||||
|
dispatch({
|
||||||
|
payload: { error: ERROR_INVALID_CREDENTIALS },
|
||||||
|
type: 'set-error',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleLogin };
|
||||||
|
}
|
||||||
2
apps/web/components/Form/hooks/index.ts
Normal file
2
apps/web/components/Form/hooks/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './socket';
|
||||||
|
export * from './token';
|
||||||
11
apps/web/components/Form/hooks/socket.tsx
Normal file
11
apps/web/components/Form/hooks/socket.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
export function useSocket() {
|
||||||
|
const socket = useMemo(
|
||||||
|
() => io({ autoConnect: false, path: '/socket.io', reconnectionAttempts: 3 }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { socket };
|
||||||
|
}
|
||||||
101
apps/web/components/Form/hooks/telegram.ts
Normal file
101
apps/web/components/Form/hooks/telegram.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import type { FormData } from '../lib/types';
|
||||||
|
import { useSocket } from './socket';
|
||||||
|
import { redirect } from '@/components/Form/lib/utils';
|
||||||
|
import {
|
||||||
|
ERROR_INVALID_CREDENTIALS,
|
||||||
|
ERROR_SERVER,
|
||||||
|
ERROR_TELEGRAM_SEND_MESSAGE,
|
||||||
|
} from '@/constants/errors';
|
||||||
|
import { FormStateContext } from '@/context/form-state';
|
||||||
|
import type { LdapUser } from '@/types/user';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const { dispatch } = useContext(FormStateContext);
|
||||||
|
|
||||||
|
function handleLogin(data: FormData) {
|
||||||
|
axios
|
||||||
|
.post<LdapUser>('/login', data)
|
||||||
|
.then((res) => {
|
||||||
|
dispatch({
|
||||||
|
payload: {
|
||||||
|
step: 'login-success',
|
||||||
|
user: res.data,
|
||||||
|
},
|
||||||
|
type: 'set-step',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
dispatch({
|
||||||
|
payload: { error: ERROR_INVALID_CREDENTIALS },
|
||||||
|
type: 'set-error',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleLogin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTelegramLogin() {
|
||||||
|
const { dispatch } = useContext(FormStateContext);
|
||||||
|
|
||||||
|
function handleTelegramLogin() {
|
||||||
|
axios
|
||||||
|
.post<LdapUser>('/login-telegram')
|
||||||
|
.then(() => {
|
||||||
|
dispatch({
|
||||||
|
payload: {
|
||||||
|
step: 'telegram-notification',
|
||||||
|
},
|
||||||
|
type: 'set-step',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
dispatch({
|
||||||
|
payload: { error: ERROR_TELEGRAM_SEND_MESSAGE },
|
||||||
|
type: 'set-error',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleTelegramLogin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTelegramConfirm() {
|
||||||
|
const {
|
||||||
|
dispatch,
|
||||||
|
state: { step },
|
||||||
|
} = useContext(FormStateContext);
|
||||||
|
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === 'telegram-notification') {
|
||||||
|
socket.open();
|
||||||
|
socket.on('connect', () => {});
|
||||||
|
|
||||||
|
socket.on('auth-allow', () => {
|
||||||
|
socket.off('connect');
|
||||||
|
axios
|
||||||
|
.get('/login-confirm')
|
||||||
|
.then(() => redirect())
|
||||||
|
.catch(() =>
|
||||||
|
dispatch({
|
||||||
|
payload: { error: ERROR_SERVER },
|
||||||
|
type: 'set-error',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('auth-deny', () => {
|
||||||
|
socket.off('connect');
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('connect');
|
||||||
|
};
|
||||||
|
}, [dispatch, socket, step]);
|
||||||
|
}
|
||||||
28
apps/web/components/Form/hooks/token.ts
Normal file
28
apps/web/components/Form/hooks/token.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { redirect } from '@/components/Form/lib/utils';
|
||||||
|
import { ERROR_SERVER } from '@/constants/errors';
|
||||||
|
import { FormStateContext } from '@/context/form-state';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useRefreshToken() {
|
||||||
|
const {
|
||||||
|
dispatch,
|
||||||
|
state: { step, user },
|
||||||
|
} = useContext(FormStateContext);
|
||||||
|
|
||||||
|
function handleRefreshToken() {
|
||||||
|
axios
|
||||||
|
.get('/refresh-token')
|
||||||
|
.then(() => redirect())
|
||||||
|
.catch(() =>
|
||||||
|
dispatch({
|
||||||
|
payload: { error: ERROR_SERVER, user: undefined },
|
||||||
|
type: 'set-error',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === 'refresh-token') handleRefreshToken();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
2
apps/web/components/Form/index.tsx
Normal file
2
apps/web/components/Form/index.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './default-form';
|
||||||
|
export * from './telegram-form';
|
||||||
55
apps/web/components/Form/lib/buttons.module.scss
Normal file
55
apps/web/components/Form/lib/buttons.module.scss
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
.button-submit {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-telegram {
|
||||||
|
@extend .button-submit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
// text-transform: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
animation: colorTransition 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-telegram {
|
||||||
|
img {
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes colorTransition {
|
||||||
|
0% {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: #54a9eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-telegram-icon {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-right: 13px !important;
|
||||||
|
margin-left: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-icon {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
fill: var(--color-primary);
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-right: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
56
apps/web/components/Form/lib/buttons.tsx
Normal file
56
apps/web/components/Form/lib/buttons.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import styles from './buttons.module.scss';
|
||||||
|
import Spinner from '@/public/assets/animated/90-ring.svg';
|
||||||
|
import TelegramIcon from '@/public/assets/images/telegram.svg?url';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import type { ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
type Props = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
|
export function ButtonLogin(props: Props) {
|
||||||
|
return (
|
||||||
|
<button className={styles['button-submit']} type="submit" {...props}>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonLoading(props: Props) {
|
||||||
|
return (
|
||||||
|
<button disabled type="button" className={styles['button-submit']} {...props}>
|
||||||
|
<div className={styles['loading-wrapper']}>
|
||||||
|
<Spinner alt="spinner" className={styles['spinner-icon']} />
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonTelegram(props: Props) {
|
||||||
|
return (
|
||||||
|
<button type="submit" className={styles['button-telegram']} {...props}>
|
||||||
|
<Image
|
||||||
|
className={styles['button-telegram-icon']}
|
||||||
|
src={TelegramIcon}
|
||||||
|
width={24}
|
||||||
|
height={22}
|
||||||
|
alt="Telegram icon"
|
||||||
|
/>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonTelegramLogin(props: Props) {
|
||||||
|
return (
|
||||||
|
<button disabled type="submit" className={styles['button-telegram']} {...props}>
|
||||||
|
<Image
|
||||||
|
className={styles['button-telegram-icon']}
|
||||||
|
src={TelegramIcon}
|
||||||
|
width={24}
|
||||||
|
height={22}
|
||||||
|
alt="Telegram icon"
|
||||||
|
/>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/web/components/Form/lib/types.ts
Normal file
7
apps/web/components/Form/lib/types.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export type FormData = {
|
||||||
|
readonly login: string;
|
||||||
|
readonly password: string;
|
||||||
|
};
|
||||||
|
export type FormProps = {
|
||||||
|
readonly onSubmit: (data: FormData) => void;
|
||||||
|
};
|
||||||
10
apps/web/components/Form/lib/utils.ts
Normal file
10
apps/web/components/Form/lib/utils.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { publicRuntimeConfig } from '@/config/runtime';
|
||||||
|
|
||||||
|
const { APP_BASE_PATH } = publicRuntimeConfig;
|
||||||
|
|
||||||
|
export function redirect() {
|
||||||
|
const redirectUrl =
|
||||||
|
(window.location.pathname.replace(APP_BASE_PATH, '') || '/') + (window.location.search || '');
|
||||||
|
|
||||||
|
window.location.replace(redirectUrl);
|
||||||
|
}
|
||||||
43
apps/web/components/Form/telegram-form.tsx
Normal file
43
apps/web/components/Form/telegram-form.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { BaseForm } from './base-form';
|
||||||
|
import { useLogin, useTelegramConfirm, useTelegramLogin } from './hooks/telegram';
|
||||||
|
import { useRefreshToken } from './hooks/token';
|
||||||
|
import { ButtonLoading, ButtonLogin, ButtonTelegram, ButtonTelegramLogin } from './lib/buttons';
|
||||||
|
import { FormStateContext } from '@/context/form-state';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export function TelegramForm() {
|
||||||
|
useRefreshToken();
|
||||||
|
const { handleLogin } = useLogin();
|
||||||
|
const { handleTelegramLogin } = useTelegramLogin();
|
||||||
|
useTelegramConfirm();
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: { step },
|
||||||
|
} = useContext(FormStateContext);
|
||||||
|
|
||||||
|
if (step === 'refresh-token') {
|
||||||
|
return <ButtonLoading>Подождите...</ButtonLoading>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'login-success') {
|
||||||
|
return (
|
||||||
|
<BaseForm onSubmit={() => handleTelegramLogin()}>
|
||||||
|
<ButtonTelegram>Войти через Telegram</ButtonTelegram>
|
||||||
|
</BaseForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'telegram-notification') {
|
||||||
|
return (
|
||||||
|
<BaseForm onSubmit={() => {}}>
|
||||||
|
<ButtonTelegramLogin>Ожидаем подтверждения...</ButtonTelegramLogin>
|
||||||
|
</BaseForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseForm onSubmit={(data) => handleLogin(data)}>
|
||||||
|
<ButtonLogin>Далее</ButtonLogin>
|
||||||
|
</BaseForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import Form from './Form';
|
|
||||||
import Logo from 'elements/Logo';
|
|
||||||
import styles from './Login.module.scss';
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<div className={styles.login}>
|
|
||||||
<Logo />
|
|
||||||
<Form />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,9 +1,9 @@
|
|||||||
$layout-breakpoint-desktop: 768px;
|
$layout-breakpoint-tablet: 768px;
|
||||||
|
$layout-breakpoint-desktop: 1680px;
|
||||||
|
|
||||||
@mixin center-elements {
|
@mixin center-elements {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: center;
|
place-items: center;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
@ -19,23 +19,23 @@ $layout-breakpoint-desktop: 768px;
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 250px;
|
height: 250px;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
padding: 25px 10px;
|
padding: 25px 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
@media screen and (min-width: $layout-breakpoint-desktop) {
|
||||||
display: block;
|
.login {
|
||||||
margin-left: auto;
|
margin-bottom: 100px;
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: $layout-breakpoint-desktop) {
|
@media screen and (min-width: $layout-breakpoint-tablet) {
|
||||||
.login {
|
.login {
|
||||||
box-shadow: 4px 5px 17px -11px rgba(0, 0, 0, 0.75);
|
box-shadow: 4px 5px 17px -11px rgba(0, 0, 0, 0.75);
|
||||||
height: 320px;
|
height: 370px;
|
||||||
width: 380px;
|
width: 440px;
|
||||||
padding: 25px 30px;
|
padding: 25px 30px;
|
||||||
margin-bottom: 100px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
17
apps/web/components/Login/index.jsx
Normal file
17
apps/web/components/Login/index.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import styles from './Login.module.scss';
|
||||||
|
import { Logo } from '@/elements';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const DynamicDefaultForm = dynamic(() => import('../Form').then((m) => m.DefaultForm));
|
||||||
|
const DynamicTelegramForm = dynamic(() => import('../Form').then((m) => m.TelegramForm));
|
||||||
|
|
||||||
|
export function Login({ tfa }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.login}>
|
||||||
|
<Logo />
|
||||||
|
{tfa ? <DynamicTelegramForm /> : <DynamicDefaultForm />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
apps/web/components/index.js
Normal file
2
apps/web/components/index.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './Form';
|
||||||
|
export * from './Login';
|
||||||
14
apps/web/config/runtime.ts
Normal file
14
apps/web/config/runtime.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type envSchema from './schema/env';
|
||||||
|
import getConfig from 'next/config';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
type Config = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
type RunTimeConfig = {
|
||||||
|
publicRuntimeConfig: Config;
|
||||||
|
serverRuntimeConfig: Config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig() as RunTimeConfig;
|
||||||
|
|
||||||
|
export { publicRuntimeConfig, serverRuntimeConfig };
|
||||||
10
apps/web/config/schema/env.js
Normal file
10
apps/web/config/schema/env.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const { z } = require('zod');
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
APP_BASE_PATH: z.string().optional().default(''),
|
||||||
|
APP_DESCRIPTION: z.string(),
|
||||||
|
TELEGRAM_BOT_URL: z.string(),
|
||||||
|
URL_API_CHECK_AUTH: z.string().default('http://auth_api:3001/check-auth'),
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = envSchema;
|
||||||
3
apps/web/constants/errors.ts
Normal file
3
apps/web/constants/errors.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const ERROR_INVALID_CREDENTIALS = 'Неверный логин или пароль';
|
||||||
|
export const ERROR_SERVER = 'Не удалось войти. Повторите попытку позже';
|
||||||
|
export const ERROR_TELEGRAM_SEND_MESSAGE = 'Не удалось отправить сообщение в Telegram';
|
||||||
78
apps/web/context/form-state.tsx
Normal file
78
apps/web/context/form-state.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable sonarjs/no-small-switch */
|
||||||
|
import type { LdapUser } from '@/types/user';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { createContext, useMemo, useReducer } from 'react';
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
error: string | undefined;
|
||||||
|
step: 'login' | 'login-success' | 'telegram-notification' | 'refresh-token';
|
||||||
|
tfa: boolean;
|
||||||
|
user: LdapUser | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
payload: Partial<State>;
|
||||||
|
type: 'set-step' | 'set-error' | 'reset-error';
|
||||||
|
};
|
||||||
|
|
||||||
|
const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'set-step': {
|
||||||
|
if (action.payload.step)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
error: undefined,
|
||||||
|
step: action.payload.step,
|
||||||
|
user: action.payload.user || state.user,
|
||||||
|
};
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set-error': {
|
||||||
|
if (action.payload.error) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
error: action.payload.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'reset-error': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
dispatch: React.Dispatch<Action>;
|
||||||
|
state: State;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormStateContext = createContext<Context>({} as Context);
|
||||||
|
|
||||||
|
type FormStateProviderProps = {
|
||||||
|
readonly tfa: boolean;
|
||||||
|
readonly user?: LdapUser;
|
||||||
|
} & PropsWithChildren;
|
||||||
|
|
||||||
|
export function FormStateProvider({ children, tfa, user = undefined }: FormStateProviderProps) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, {
|
||||||
|
error: undefined,
|
||||||
|
step: user ? 'refresh-token' : 'login',
|
||||||
|
tfa,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ dispatch, state }), [state]);
|
||||||
|
|
||||||
|
return <FormStateContext.Provider value={value}>{children}</FormStateContext.Provider>;
|
||||||
|
}
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import styles from './Button.module.css';
|
|
||||||
|
|
||||||
type ButtonProps = JSX.IntrinsicElements['button'];
|
|
||||||
|
|
||||||
export default function Button({ children, ...props }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<button className={styles.btn} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import styles from './Error.module.css';
|
|
||||||
|
|
||||||
export default function Error({ children }) {
|
|
||||||
return <span className={styles.error}>{children}</span>;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import styles from './H.module.css';
|
|
||||||
|
|
||||||
export function H3({ children }) {
|
|
||||||
return <h3 className={styles.h3}>{children}</h3>;
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import styles from './Input.module.css';
|
|
||||||
|
|
||||||
type InputProps = JSX.IntrinsicElements['input'];
|
|
||||||
|
|
||||||
export default function Input(props: InputProps) {
|
|
||||||
return <input className={styles.input} {...props} />;
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import logo from 'public/assets/images/logo-primary.svg';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import logo from 'public/assets/images/logo-primary.svg?url';
|
||||||
|
|
||||||
export default function Logo() {
|
export function Logo() {
|
||||||
return <Image src={logo} alt="logo" width={154} />;
|
return <Image className="logo" src={logo} alt="logo" width={154} />;
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/web/elements/index.js
Normal file
1
apps/web/elements/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './Logo';
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
if (request.nextUrl.pathname === '/') {
|
|
||||||
if (request.cookies.get('token')) {
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
const uri = url?.searchParams.get('uri') || '/';
|
|
||||||
|
|
||||||
return NextResponse.redirect(new URL(uri, request.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +1,45 @@
|
|||||||
const path = require('path');
|
const envSchema = require('./config/schema/env.js');
|
||||||
|
const { join } = require('path');
|
||||||
|
|
||||||
const runtimeConfig = {
|
const runtimeConfig = envSchema.parse(process.env);
|
||||||
appTitle: process.env.APP_TITLE,
|
|
||||||
description: process.env.APP_DESCRIPTION,
|
|
||||||
basePath: process.env.BASE_PATH,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
module.exports = {
|
||||||
basePath: process.env.BASE_PATH,
|
basePath: process.env.APP_BASE_PATH,
|
||||||
output: 'standalone',
|
|
||||||
reactStrictMode: true,
|
|
||||||
swcMinify: true,
|
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
serverRuntimeConfig: runtimeConfig,
|
|
||||||
publicRuntimeConfig: runtimeConfig,
|
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: join(__dirname, '../../'),
|
||||||
|
},
|
||||||
|
output: 'standalone',
|
||||||
|
publicRuntimeConfig: runtimeConfig,
|
||||||
|
reactStrictMode: true,
|
||||||
|
serverRuntimeConfig: runtimeConfig,
|
||||||
|
swcMinify: true,
|
||||||
|
webpack(config) {
|
||||||
|
// Grab the existing rule that handles SVG imports
|
||||||
|
const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg'));
|
||||||
|
|
||||||
|
config.module.rules.push(
|
||||||
|
// Reapply the existing rule, but only for svg imports ending in ?url
|
||||||
|
{
|
||||||
|
...fileLoaderRule,
|
||||||
|
test: /\.svg$/i,
|
||||||
|
resourceQuery: /url/, // *.svg?url
|
||||||
|
},
|
||||||
|
// Convert all other *.svg imports to React components
|
||||||
|
{
|
||||||
|
test: /\.svg$/i,
|
||||||
|
issuer: fileLoaderRule.issuer,
|
||||||
|
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
|
||||||
|
use: ['@svgr/webpack'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||||
|
fileLoaderRule.exclude = /\.svg$/i;
|
||||||
|
|
||||||
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
||||||
|
|||||||
@ -6,21 +6,29 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint -- --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/montserrat": "^4.5.13",
|
"@fontsource/montserrat": "^5.0.13",
|
||||||
"@types/node": "18.11.9",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@types/react": "18.0.25",
|
"@types/node": "^20.10.0",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react": "^18.2.39",
|
||||||
"axios": "^1.2.1",
|
"@types/react-dom": "^18.2.17",
|
||||||
"eslint": "8.28.0",
|
"axios": "^1.5.1",
|
||||||
"eslint-config-next": "13.0.5",
|
"modern-normalize": "^2.0.0",
|
||||||
"next": "13.0.5",
|
"next": "^14.2.3",
|
||||||
"normalize.css": "^8.0.1",
|
"radash": "^11.0.0",
|
||||||
"react": "18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"sass": "^1.56.1",
|
"react-hook-form": "^7.51.3",
|
||||||
"typescript": "4.9.3"
|
"sass": "^1.69.3",
|
||||||
|
"socket.io-client": "^4.7.5",
|
||||||
|
"typescript": "5.3.2",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vchikalkin/eslint-config-awesome": "^1.1.6",
|
||||||
|
"eslint": "^8.51.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import type { AppProps } from 'next/app';
|
/* eslint-disable react/no-unknown-property */
|
||||||
import 'normalize.css';
|
import '../styles/globals.css';
|
||||||
import Head from 'next/head';
|
|
||||||
import '@fontsource/montserrat/400.css';
|
import '@fontsource/montserrat/400.css';
|
||||||
import '@fontsource/montserrat/600.css';
|
import '@fontsource/montserrat/600.css';
|
||||||
import '@fontsource/montserrat/700.css';
|
import '@fontsource/montserrat/700.css';
|
||||||
|
import type { AppProps } from 'next/app';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import getConfig from 'next/config';
|
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
|
||||||
|
import { serverRuntimeConfig } from '@/config/runtime';
|
||||||
import Document, { Head, Html, Main, NextScript } from 'next/document';
|
import Document, { Head, Html, Main, NextScript } from 'next/document';
|
||||||
|
|
||||||
const { serverRuntimeConfig: config } = getConfig();
|
const { APP_BASE_PATH } = serverRuntimeConfig;
|
||||||
const { basePath = '', description } = config;
|
|
||||||
|
|
||||||
export default class MyDocument extends Document {
|
export default class MyDocument extends Document {
|
||||||
render() {
|
render() {
|
||||||
@ -10,13 +10,25 @@ export default class MyDocument extends Document {
|
|||||||
<Html lang="ru" translate="no">
|
<Html lang="ru" translate="no">
|
||||||
<Head>
|
<Head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="description" content={description} />
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
<link rel="apple-touch-icon" sizes="120x120" href={`${basePath}/apple-touch-icon.png`} />
|
sizes="120x120"
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
|
href={`${APP_BASE_PATH}/apple-touch-icon.png`}
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
|
/>
|
||||||
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
|
<link
|
||||||
<link rel="mask-icon" href={`${basePath}/safari-pinned-tab.svg`} color="#5bbad5" />
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href={`${APP_BASE_PATH}/favicon-32x32.png`}
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href={`${APP_BASE_PATH}/favicon-16x16.png`}
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href={`${APP_BASE_PATH}/site.webmanifest`} />
|
||||||
|
<link rel="mask-icon" href={`${APP_BASE_PATH}/safari-pinned-tab.svg`} color="#5bbad5" />
|
||||||
<meta name="msapplication-TileColor" content="#1c01a9" />
|
<meta name="msapplication-TileColor" content="#1c01a9" />
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
</Head>
|
</Head>
|
||||||
|
|||||||
1
apps/web/pages/account.jsx
Normal file
1
apps/web/pages/account.jsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default, getServerSideProps } from './ldap';
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import Head from 'next/head';
|
|
||||||
import Login from 'components/Login';
|
|
||||||
import getConfig from 'next/config';
|
|
||||||
|
|
||||||
const { publicRuntimeConfig: config } = getConfig();
|
|
||||||
|
|
||||||
function PageHead() {
|
|
||||||
return (
|
|
||||||
<Head>
|
|
||||||
<title>{`Вход - ${config.description}`}</title>
|
|
||||||
</Head>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHead title={config.description} />
|
|
||||||
<Login />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user