diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..8275a406 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm + +# Установка зависимостей +RUN apt-get update && apt-get install -y curl + +# Установка git +RUN apt-get install -y git + +# Установка pnpm +ENV HOME=/home/node +ENV PNPM_HOME=$HOME/.local/share/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN npm install -g pnpm@10.7.0 +RUN pnpm config set -g store-dir "${HOME}/.local/share/pnpm/store" +RUN chown -R node:node "$PNPM_HOME" + +USER node \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..1b7ef3c5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,54 @@ +{ + "name": "Flippo frontend DevContainer", + + "build": { + "dockerfile": "Dockerfile" + }, + + "customizations": { + "vscode": { + "extensions": [ + "formulahendry.auto-rename-tag", + "streetsidesoftware.code-spell-checker", + "vunguyentuan.vscode-css-variables", + "dsznajder.es7-react-js-snippets", + "dbaeumer.vscode-eslint", + "felipecaputo.git-project-manager", + "esbenp.prettier-vscode", + "streetsidesoftware.code-spell-checker-russian", + "sibiraj-s.vscode-scss-formatter", + "stylelint.vscode-stylelint", + "surrealdb.surrealql", + "simonsiefke.svg-preview", + "ionutvmi.path-autocomplete", + "christian-kohler.npm-intellisense", + "eamodio.gitlens", + "sidthesloth.html5-boilerplate", + "ecmel.vscode-html-css", + "lokalise.i18n-ally", + "ms-vscode.vscode-typescript-next", + "katsute.code-background", + "ms-azuretools.vscode-docker", + "mgmcdermott.vscode-language-babel" + ] + } + }, + + "forwardPorts": ["3030:3030", "3031:3031", "80:80"], + "portsAttributes": { + "3030:3030": { + "label": "Frontend port", + "onAutoForward": "openBrowserOnce" + }, + "3031:3031": { + "label": "SurrealDB port" + }, + "80:80": { + "label": "Backend port" + } + }, + + "runArgs": ["--name", "flippo_devcontainer"], + "workspaceFolder": "/workspace/flippo" + //"updateContentCommand": "pnpm install --frozen-lockfile" +} diff --git a/.devcontainer/gorilla.webp b/.devcontainer/gorilla.webp new file mode 100644 index 00000000..142befb9 Binary files /dev/null and b/.devcontainer/gorilla.webp differ diff --git a/.devcontainer/idi_nahuy.jpg b/.devcontainer/idi_nahuy.jpg new file mode 100644 index 00000000..2f3b5ccc Binary files /dev/null and b/.devcontainer/idi_nahuy.jpg differ diff --git a/.gitignore b/.gitignore index bad730b6..58168657 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,39 @@ -# Logs +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js +.pnpm-store + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage +*storybook.log +testplane-report +.testplane + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist +dist-ssr + + +# Debug logs *.log npm-debug.log* @@ -7,22 +42,15 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -node_modules -dist -dist-ssr -*.local +# Misc +.DS_Store +*.pem # Editor directories and files -.vscode/* -!.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln -*.sw? - -*storybook.log -testplane-report -.testplane \ No newline at end of file +*.sw? \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..805efa9f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.14.0 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e0ffa241 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,63 @@ +{ + "i18n-ally.localesPaths": [ + "**/locales" + ], + + "eslint.enable": true, + "biome.enabled": false, + + //"eslint.useFlatConfig": true, + //"eslint.useESLintClass": false, // важно! + + // Disable the default formatter, use eslint instead + "prettier.enable": false, + "editor.formatOnSave": false, + + // Auto fix + "editor.codeActionsOnSave": { + //"source.fixAll.biome": "explicit", + //"source.organizeImports.biome": "explicit", + + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" + }, + + //"eslint.runtime": "node", + + // Silent the stylistic rules in you IDE, but still auto fix them + "eslint.rules.customizations": [ + { "rule": "style/*", "severity": "info", "fixable": true }, + { "rule": "*-indent", "severity": "info", "fixable": true }, + { "rule": "*-spacing", "severity": "info", "fixable": true }, + { "rule": "*-spaces", "severity": "info", "fixable": true }, + { "rule": "*-order", "severity": "info", "fixable": true }, + { "rule": "*-dangle", "severity": "info", "fixable": true }, + { "rule": "*-newline", "severity": "info", "fixable": true }, + { "rule": "*quotes", "severity": "info", "fixable": true }, + { "rule": "*semi", "severity": "info", "fixable": true } + ], + + // Enable eslint for all supported languages + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "json5", + "jsonc", + "yaml", + "toml", + "xml" + ], + + "pair-diff.patterns": [ + { + "source": "./fixtures/output/**/*.*", + "target": "./fixtures/input/" + } + ] +} \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..1805dcb6 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,2 @@ +node_module +database \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..ffbbf46d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,26 @@ +# dependencies +/node_modules +/database/ +/.pnp +.pnp.js +.yarn/install-state.gz +.env +/config + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# typescript +*.tsbuildinfo \ No newline at end of file diff --git a/backend/.prettierrc.json b/backend/.prettierrc.json new file mode 100644 index 00000000..95a61b85 --- /dev/null +++ b/backend/.prettierrc.json @@ -0,0 +1,19 @@ +{ + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "embeddedLanguageFormatting": "auto", + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "printWidth": 100, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "requirePragma": false, + "semi": true, + "singleAttributePerLine": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..f78c82ad --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM node:20 AS base + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +# backend-base +FROM base AS backend-base +WORKDIR /flippo/backend + +COPY . . +RUN pnpm install --frozen-lockfile + +# backend-dev +FROM backend-base AS backend-dev +CMD ["pnpm", "run", "dev:server"] + +# backend-build +FROM backend-base AS backend-build +RUN pnpm run build + +# backend-prod +FROM base AS backend-prod +COPY --from=backend-build /flippo/backend/build /app +WORKDIR /app +CMD ["node", "/app/app.js"] \ No newline at end of file diff --git a/LICENSE b/backend/LICENSE similarity index 100% rename from LICENSE rename to backend/LICENSE diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..9f3821be --- /dev/null +++ b/backend/README.md @@ -0,0 +1,68 @@ +# Flippo + +## 1 О проекте + +Проект направлен на создание удобного и функционального инструмента для обучения. +Реализация проекта позволит пользователям эффективно организовывать учебный процесс, улучшая свои знания и навыки. + +**Цель проекта:** Создание веб-приложения для составления, хранения и использования обучающих карточек, +предназначенных для улучшения запоминания информации и подготовки к экзаменам, тестам и другим образовательным целям. + +## 2 Целевая аудитория + +- Ученики и студенты; +- Преподаватели и репетиторы; +- Люди, занимающиеся самообразованием; +- Специалисты, готовящиеся к профессиональным экзаменам и сертификациям. + +## 3 Основные функции и возможности + +1. Регистрация и авторизация пользователей: + +- Регистрация через email; +- Вход через социальные сети (Google, VK, Yandex ID). + +2. Профиль пользователя: + +- Редактирование личных данных. + +3. Создание и управление карточками: + +- Создание карточек с вопросами и ответами; +- Возможность добавления изображений и ссылок; +- Организация карточек по папкам. + +4. Обучение с помощью карточек: + +- Режим изучения: отображение вопросов и скрытие ответов до нажатия; + +5. Импорт и экспорт карточек: + +- Импорт карточек из CSV; +- Экспорт созданных карточек; +- Копирование наборов или отдельных карточек по ссылке. + +6. Сообщество и обмен: + +- Возможность делиться коллекциями карточек с другими пользователями; +- Поиск и подписка на коллекции других пользователей; +- Возможность совместного редактирования набора карточек. + +7. Дополнительные функции: + +- Синхронизация данных между устройствами; +- Поддержка многоязычности. + +## 4 Технические требования + +1. Веб-технологии: + +- Frontend: HTML5, SCSS, React, Effector, axios, storybook, atomic-router, i18next, motion/react(framer-motion); +- Backend: NodeJS, Express, jose, nodemailer; +- База данных: SurrealDB; +- Общие: TypeScript, Zod. + +2. Безопасность: + +- SSL-сертификат для безопасного соединения; +- JWT & Refresh token. diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml new file mode 100644 index 00000000..8aa5c02d --- /dev/null +++ b/backend/docker-compose.yaml @@ -0,0 +1,39 @@ +services: + backend: + image: flippo-backend + container_name: backend + build: + context: ./ + target: backend-dev + ports: ["80:${API_PORT}"] + depends_on: [surreal] + environment: + SURREALDB_ENDPOINT: http://surrealdb:3031/rpc + develop: + watch: + - path: ./ + action: sync + target: /flippo/backend + - path: ./package.json + action: rebuild + networks: [backend-surreal] + + surreal: + image: surrealdb/surrealdb:latest + container_name: surrealdb + command: start --bind 0.0.0.0:3031 --user ${SURREALDB_USER} --pass ${SURREALDB_PASS} surrealkv:database/flippo.db + ports: ["3031:3031"] + volumes: + - type: volume + source: surrealdb-data + target: /database + user: root + restart: always + networks: [backend-surreal] + +networks: + backend-surreal: + driver: bridge + +volumes: + surrealdb-data: diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs new file mode 100644 index 00000000..4ff5e812 --- /dev/null +++ b/backend/eslint.config.mjs @@ -0,0 +1,118 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/naming-convention */ +// @ts-nocheck +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; +import { default as eslintPlugin } from "@typescript-eslint/eslint-plugin"; +import prettier from "eslint-plugin-prettier"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default [ + ...fixupConfigRules( + compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ) + ), + { + plugins: { + "@typescript-eslint": fixupPluginRules(eslintPlugin), + prettier: fixupPluginRules(prettier) + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.amd, + ...globals.node + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: "module", + + parserOptions: { + project: "./tsconfig.json" + } + }, + + rules: { + "prettier/prettier": [ + "warn", + { + endOfLine: "auto" + }, + { + usePrettierrc: true + } + ], + + "@typescript-eslint/array-type": [ + "warn", + { + default: "array", + readonly: "array" + } + ], + + "@typescript-eslint/consistent-type-exports": "error", + + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "inline-type-imports" + } + ], + + "default-param-last": "off", + "@typescript-eslint/default-param-last": "warn", + "@typescript-eslint/no-empty-object-type": "off", + + "@typescript-eslint/naming-convention": [ + "warn", + { + selector: "variable", + format: ["camelCase", "UPPER_CASE", "PascalCase"] + }, + { + selector: "typeLike", + format: ["PascalCase"] + } + ], + + "@typescript-eslint/no-array-delete": "warn", + "@typescript-eslint/no-duplicate-enum-values": "error", + "@typescript-eslint/no-duplicate-type-constituents": "error", + + "@typescript-eslint/typedef": [ + "warn", + { + memberVariableDeclaration: true, + propertyDeclaration: true, + parameter: true + } + ], + + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "warn" + } + } +]; diff --git a/backend/loader.js b/backend/loader.js new file mode 100644 index 00000000..3c2f40c5 --- /dev/null +++ b/backend/loader.js @@ -0,0 +1,15 @@ +import { resolve as resolveTs } from "ts-node/esm"; +import * as tsConfigPaths from "tsconfig-paths"; +import { pathToFileURL } from "url"; + +const { absoluteBaseUrl, paths } = tsConfigPaths.loadConfig(); +const matchPath = tsConfigPaths.createMatchPath(absoluteBaseUrl, paths); + +export function resolve(specifier, ctx, defaultResolve) { + const match = matchPath(specifier); + return match + ? resolveTs(pathToFileURL(`${match}`).href, ctx, defaultResolve) + : resolveTs(specifier, ctx, defaultResolve); +} + +export { load, transformSource } from "ts-node/esm"; diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 00000000..f88fcac8 --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": ["node_modules"], + "exec": "node --import=./register.js" +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..8670b9a1 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,60 @@ +{ + "name": "@flippo/backend", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "./src/app.ts", + "scripts": { + "dev:server": "nodemon src/app.ts", + "dev:surreal": "surreal start --log trace --allow-all --bind 0.0.0.0:3031 surrealkv:database/flippo.db", + "dev": "concurrently --names surreal,server \"pnpm run dev:surreal\" \"pnpm run dev:server\"", + "surreal::validate": "surreal validate database/models/*.surql", + "lint": "npx eslint --fix", + "build": "tsc --outDir build", + "start": "node build/app.js" + }, + "keywords": [], + "author": "Egor Gorochkin", + "license": "ISC", + "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.12.0", + "@eslint/migrate-config": "^1.3.1", + "@types/body-parser": "^1.19.5", + "@types/cookie-parser": "^1.4.7", + "@types/cors": "^2.8.17", + "@types/eslint__eslintrc": "^2.1.2", + "@types/eslint__js": "^8.42.3", + "@types/express": "^4.17.21", + "@types/express-useragent": "^1.0.5", + "@types/lodash-es": "^4.17.12", + "@types/node": "^22.5.5", + "@types/nodemailer": "^6.4.16", + "@types/pino-http": "^5.8.4", + "@types/qs": "^6.9.16", + "@typescript-eslint/eslint-plugin": "^8.6.0", + "@typescript-eslint/parser": "^8.6.0", + "pino-pretty": "^11.2.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.6.2" + }, + "dependencies": { + "axios": "^1.7.7", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "express-useragent": "^1.0.15", + "jose": "^5.9.2", + "lodash-es": "^4.17.21", + "nodemailer": "^6.9.15", + "nodemon": "^3.1.5", + "pino": "^9.4.0", + "pkce-challenge": "^4.1.0", + "qs": "^6.13.0", + "surrealdb": "1.0.0-beta.21", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } +} diff --git a/backend/register.js b/backend/register.js new file mode 100644 index 00000000..2a0129de --- /dev/null +++ b/backend/register.js @@ -0,0 +1,4 @@ +import { register } from "node:module"; +import { pathToFileURL } from "node:url"; + +register("./loader.js", pathToFileURL("./")); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 00000000..4f967589 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,45 @@ +import express from "express"; +import cookieParser from "cookie-parser"; +import cors from "cors"; +import userAgent from "express-useragent"; +import { getDb } from "@utils/connect.ts"; +import logger from "@utils/logger.ts"; +import { ENV } from "@schemas/index.ts"; + +import routes from "./router.ts"; + +const app = express(); +const port = ENV.API_PORT; + +app.use(express.json()); +app.use(cookieParser()); +app.use( + cors({ + origin: ENV.APP_BASE_URL, + credentials: true, + optionsSuccessStatus: 200 + }) +); +app.use(userAgent.express()); + +const start = () => { + try { + app.listen(port, async () => { + logger.info(`Server started at http://localhost:${port}`); + routes(app); + + let db = await getDb(); + + if (!db?.connection) { + setTimeout(async function tick() { + db = await getDb(); + if (!db?.connection) setTimeout(tick, 3000); + }, 3000); + } + }); + } catch (error: any) { + logger.error("An error occurred while starting the server:", error); + } +}; + +start(); diff --git a/backend/src/controller/constant/cookieOption.ts b/backend/src/controller/constant/cookieOption.ts new file mode 100644 index 00000000..0bbac6c1 --- /dev/null +++ b/backend/src/controller/constant/cookieOption.ts @@ -0,0 +1,69 @@ +import stringToUnixSeconds from "@utils/jwt/stringToUnixSeconds.ts"; +import { type CookieOptions } from "express"; + +const DOMAIN = "localhost"; + +const accessTokenCookieOptions: CookieOptions = { + maxAge: stringToUnixSeconds("15min") * 1000, // 15 mins + httpOnly: true, + domain: DOMAIN, + path: "/auth", + sameSite: "lax", + secure: false +}; + +const refreshTokenCookieOptions: CookieOptions = { + ...accessTokenCookieOptions, + maxAge: stringToUnixSeconds("24w") * 1000, //23 weeks + path: "/auth/refresh_token" +}; + +const refreshTokenClearCookieOptions: CookieOptions = { + httpOnly: true, + domain: DOMAIN, + path: "/auth/refresh_token" +}; + +const accessTokenClearCookieOptions = { + httpOnly: true, + domain: DOMAIN, + path: "/auth" +}; + +const codeVerifierCookieOptions: CookieOptions = { + maxAge: stringToUnixSeconds("5mins") * 1000, // 5 mins + httpOnly: true, + sameSite: "lax" +}; + +const dbTokenCookieOptions: CookieOptions = { + maxAge: stringToUnixSeconds("1min") * 1000, // 15 mins + httpOnly: false, + domain: DOMAIN, + sameSite: "lax" +}; + +const registrationEmailCookieOptions: CookieOptions = { + maxAge: stringToUnixSeconds("1h") * 1000, // 1 hour + httpOnly: true, + domain: DOMAIN, + path: "/", + sameSite: "lax" +}; + +const registrationEmailClearCookieOptions = { + httpOnly: true, + domain: DOMAIN, + path: "/" +}; + +export { + accessTokenCookieOptions, + accessTokenClearCookieOptions, + refreshTokenCookieOptions, + refreshTokenClearCookieOptions, + codeVerifierCookieOptions, + dbTokenCookieOptions, + registrationEmailCookieOptions, + registrationEmailClearCookieOptions +}; diff --git a/backend/src/controller/email.controller.ts b/backend/src/controller/email.controller.ts new file mode 100644 index 00000000..1ea037fe --- /dev/null +++ b/backend/src/controller/email.controller.ts @@ -0,0 +1,56 @@ +import { type Request, type Response, type NextFunction } from "express"; +import generateCode from "@utils/generateVerificationCode.ts"; +import { CodeVerifyService } from "@service/codeVerify.service.ts"; +import { EmailService } from "@service/email.service.ts"; +import { getExpiration } from "@utils/jwt/timestamps.ts"; +import logger from "@utils/logger.ts"; + +import { registrationEmailCookieOptions } from "./constant/cookieOption.ts"; + +const CODE_LENGTH = 5; +const LIFE_TIME = "5mins"; + +class EmailController { + static async generateVerificationCode(req: Request, res: Response, next: NextFunction) { + try { + const { email } = req.body; + + const oldCode = await CodeVerifyService.findCode(email); + if (oldCode) { + await CodeVerifyService.deleteCode(email); + } + + const code = generateCode(CODE_LENGTH); + const exp = getExpiration(LIFE_TIME); + await CodeVerifyService.saveCode(email, code, exp); + + const info = await EmailService.sendVerifyCode(email, code); + logger.info({ info }); + + res.cookie("registrationEmail", email, registrationEmailCookieOptions); + + return res.sendStatus(200); + } catch (error: any) { + return next(error); + } + } + + static async verifyCode(req: Request, res: Response, next: NextFunction) { + try { + const { email, code } = req.body; + const { registrationEmail } = req.cookies; + + await EmailService.validateEmail(registrationEmail, email); + + await CodeVerifyService.verifyCode(email, code); + + await CodeVerifyService.deleteCode(email); + + return res.sendStatus(200); + } catch (error) { + return next(error); + } + } +} + +export { EmailController }; diff --git a/backend/src/controller/pkce.controller.ts b/backend/src/controller/pkce.controller.ts new file mode 100644 index 00000000..61fe1fbb --- /dev/null +++ b/backend/src/controller/pkce.controller.ts @@ -0,0 +1,21 @@ +import { type Request, type Response, type NextFunction } from "express"; +import pkceChallenge from "pkce-challenge"; + +import { codeVerifierCookieOptions } from "./constant/cookieOption.ts"; + +class PkceController { + static async generatePkce(req: Request, res: Response, next: NextFunction) { + try { + const { code_verifier: codeVerifier, code_challenge: codeChallenge } = + await pkceChallenge(64); + + res.cookie("codeVerifier", encodeURIComponent(codeVerifier), codeVerifierCookieOptions); + + res.status(200).json({ codeChallenge }); + } catch (error: any) { + return next(error); + } + } +} + +export { PkceController }; diff --git a/backend/src/controller/token.controller.ts b/backend/src/controller/token.controller.ts new file mode 100644 index 00000000..95da4000 --- /dev/null +++ b/backend/src/controller/token.controller.ts @@ -0,0 +1,74 @@ +import { TokenService } from "@service/token.service.ts"; +import { type Request, type Response, type NextFunction } from "express"; +import { UserService } from "@service/user.service.ts"; +import { type TAccessPayload } from "@utils/jwt/types/TAuthPayload.ts"; +import { ApiError } from "src/exceptions/api.error.ts"; + +import { + accessTokenCookieOptions, + dbTokenCookieOptions, + refreshTokenCookieOptions +} from "./constant/cookieOption.ts"; + +class TokenController { + static async auth(req: Request, res: Response, next: NextFunction) { + try { + const accessToken: string = req.cookies["accessToken"]; + const verificationResult = await TokenService.verifyToken(accessToken); + + if (!verificationResult || verificationResult.code === "ERR_JWT_EXPIRED") + throw ApiError.Unauthorized(); + + const user = await UserService.getUserById(verificationResult.payload.id); + const dbToken = await TokenService.generateDbToken(user.id); + + res.cookie("dbToken", dbToken, dbTokenCookieOptions); + + return res.status(200).json({ + userId: user.id, + name: user.name, + surname: user.surname, + role: user.role, + email: user.email, + username: user.username, + image: user.image + }); + } catch (error: any) { + return next(error); // -->error.middleware.ts + } + } + + static async refresh(req: Request, res: Response, next: NextFunction) { + try { + const { refreshToken } = req.cookies; + const tokens = await TokenService.refresh(refreshToken); + + res.cookie("refreshToken", tokens.refreshToken, refreshTokenCookieOptions); + res.cookie("accessToken", tokens.accessToken, accessTokenCookieOptions); + + return res.sendStatus(200); + } catch (error: any) { + return next(error); // -->error.middleware.ts + } + } + + static async getDbToken(req: Request, res: Response, next: NextFunction) { + try { + const { accessToken } = req.cookies; + + const verificationResult = await TokenService.verifyToken(accessToken); + if (!verificationResult) throw ApiError.Unauthorized(); + + const { id } = verificationResult.payload; + const dbToken = await TokenService.generateDbToken(id); + + res.cookie("dbToken", dbToken, dbTokenCookieOptions); + + return res.sendStatus(200); + } catch (error) { + return next(error); // --> error.middleware.ts + } + } +} + +export { TokenController }; diff --git a/backend/src/controller/user.controller.ts b/backend/src/controller/user.controller.ts new file mode 100644 index 00000000..d8ecc43e --- /dev/null +++ b/backend/src/controller/user.controller.ts @@ -0,0 +1,224 @@ +import { type Request, type Response, type NextFunction } from "express"; +import { ENV, type TCreateUser, type TUser } from "@schemas/index.ts"; +import { OAuthService } from "@service/oauth.service.ts"; +import { type IGoogleTokensResult } from "@service/types/IGoogleTokensResult.ts"; +import { type IGoogleUserResult } from "@service/types/IGoogleUserResult.ts"; +import { type IVkontakteTokensResult } from "@service/types/IVkontakteTokensResult.ts"; +import { type IVkontakteUserResult } from "@service/types/IVkontakteUserResult.ts"; +import { type IYandexTokensResult } from "@service/types/IYandexTokensResult.ts"; +import { type IYandexUserResult } from "@service/types/IYandexUserResult.ts"; +import { UserService } from "@service/user.service.ts"; +import getConnectionData from "@utils/getConnectionData.ts"; +import { ApiError } from "src/exceptions/api.error.ts"; +import { type TAllTokens } from "@utils/jwt/types/TTokens.ts"; +import { EmailService } from "@service/email.service.ts"; + +import { + accessTokenClearCookieOptions, + accessTokenCookieOptions, + dbTokenCookieOptions, + refreshTokenClearCookieOptions, + refreshTokenCookieOptions, + registrationEmailClearCookieOptions +} from "./constant/cookieOption.ts"; + +class UserController { + static composeAnAuthResponse(res: Response, tokens: TAllTokens, user: TUser) { + res.cookie("refreshToken", tokens.refreshToken, refreshTokenCookieOptions); + res.cookie("accessToken", tokens.accessToken, accessTokenCookieOptions); + res.cookie("dbToken", tokens.dbToken, dbTokenCookieOptions); + + const composeData = { + userId: user.id, + role: user.role, + email: user.email, + image: user.image, + name: user.name, + surname: user.surname, + username: user.username + }; + + return composeData; + } + + static async authProcess(req: Request, res: Response, authUser: TCreateUser) { + const connectionData = getConnectionData(req); + + let userData = await UserService.signIn(authUser.providersId[0], connectionData); + if (!userData.user) userData = await UserService.signUp(authUser, connectionData); + + const { user, tokens } = userData; + + UserController.composeAnAuthResponse(res, tokens, user); + + return res.redirect(`${ENV.APP_REDIRECT_URL}`); + } + + static async oauthGoogle(req: Request, res: Response, next: NextFunction) { + try { + const code = req.query.code; + const codeVerifier = decodeURIComponent(req.cookies["codeVerifier"]); + + const { access_token } = await OAuthService.getOauthTokens( + "google", + code as string, + codeVerifier + ); + + const googleUser = await OAuthService.getUserInfo( + "google", + access_token as string + ); + + if (!googleUser.verified_email) throw ApiError.Forbidden("Google account is not verified."); + + const authUser: TCreateUser = { + name: googleUser.given_name, + surname: googleUser.family_name, + email: googleUser.email, + providersId: [`google:${googleUser.id}`], + role: "user", + image: googleUser.picture + }; + + await UserController.authProcess(req, res, authUser); + } catch (error: any) { + return next(error); // -->error.middleware + } + } + + static async oauthVkontakte(req: Request, res: Response, next: NextFunction) { + try { + const { code, device_id } = req.query; + const codeVerifier = decodeURIComponent(req.cookies["codeVerifier"]); + const { access_token } = await OAuthService.getOauthTokens( + "vkontakte", + code as string, + codeVerifier, + device_id as string + ); + + const { user: vkontakteUser } = await OAuthService.getUserInfo( + "vkontakte", + access_token + ); + + const authUser: TCreateUser = { + name: vkontakteUser.first_name, + surname: vkontakteUser.last_name, + email: vkontakteUser.email, + providersId: [`vkontakte:${vkontakteUser.user_id}`], + role: "user", + image: vkontakteUser.avatar + }; + + await UserController.authProcess(req, res, authUser); + return; + } catch (error: any) { + return next(error); // -->error.middleware + } + } + + static async oauthYandex(req: Request, res: Response, next: NextFunction) { + try { + const code = req.query.code; + const codeVerifier = decodeURIComponent(req.cookies["codeVerifier"]); + const { access_token } = await OAuthService.getOauthTokens( + "yandex", + code as string, + codeVerifier + ); + + const yandexUser = await OAuthService.getUserInfo("yandex", access_token); + + const authUser: TCreateUser = { + name: yandexUser.first_name, + surname: yandexUser.last_name, + email: yandexUser.default_email, + providersId: [`yandex:${yandexUser.id}`], + role: "user", + image: !yandexUser.is_avatar_empty + ? `https://avatars.yandex.net/get-yapic/${yandexUser.default_avatar_id}/islands-200` + : undefined + }; + + await UserController.authProcess(req, res, authUser); + } catch (error: any) { + return next(error); // -->error.middleware + } + } + + static async signUpWithEmail(req: Request, res: Response, next: NextFunction) { + try { + const { username, email } = req.body; + const { registrationEmail } = req.cookies; + + await EmailService.validateEmail(registrationEmail, email); + + const userData: TCreateUser = { + name: "", + surname: "", + username, + email, + providersId: [`email:${email}`], + role: "user", + image: undefined + }; + + const connectionData = getConnectionData(req); + const { user, tokens } = await UserService.signUp(userData, connectionData); + + const composeData = UserController.composeAnAuthResponse(res, tokens, user); + + res.clearCookie("registrationEmail", registrationEmailClearCookieOptions); + + return res.status(200).json(composeData); + } catch (error: any) { + return next(error); // --> error.middleware.ts + } + } + + static async signInWitEmail(req: Request, res: Response, next: NextFunction) { + try { + const { email } = req.body; + const { registrationEmail } = req.cookies; + + await EmailService.validateEmail(registrationEmail, email); + + const providerId = `email:${email}`; + + const connectionData = getConnectionData(req); + const { user, tokens } = await UserService.signIn(providerId, connectionData); + if (!user) throw ApiError.Unauthorized(); + + const composeData = UserController.composeAnAuthResponse(res, tokens, user); + + res.clearCookie("registrationEmail", registrationEmailClearCookieOptions); + + return res.status(200).json(composeData); + } catch (error: any) { + return next(error); // --> error.middleware.ts + } + } + + static async signOut(req: Request, res: Response, next: NextFunction) { + const clearCookies = () => { + res.clearCookie("refreshToken", refreshTokenClearCookieOptions); + res.clearCookie("accessToken", accessTokenClearCookieOptions); + }; + + try { + const { refreshToken } = req.cookies; + await UserService.signOut(refreshToken); + + clearCookies(); + + return res.sendStatus(200); + } catch (error: any) { + clearCookies(); + return next(error); // -->error.middleware.ts + } + } +} + +export { UserController }; diff --git a/backend/src/dao/refreshToken.dao.ts b/backend/src/dao/refreshToken.dao.ts new file mode 100644 index 00000000..d3d73d34 --- /dev/null +++ b/backend/src/dao/refreshToken.dao.ts @@ -0,0 +1,153 @@ +import { + ConnectionDataSchema, + RefreshJwtIdSchema, + RefreshTokenSchema, + UserIdSchema, + CreateRefreshTokenSchema, + type TCreateRefreshToken, + type TConnectionData, + type TRefreshToken +} from "@schemas/index.ts"; +import { getDb } from "@utils/connect.ts"; +import logger from "@utils/logger.ts"; +import { RecordId } from "surrealdb"; +import { z } from "zod"; + +async function findRefreshTokenById(id: string) { + id = RefreshJwtIdSchema.parse(id); + + const db = await getDb(); + const [table, value] = String(id).split(":"); + const result = await db.select(new RecordId(table, value)); + + return await RefreshTokenSchema.parseAsync(result).catch(() => undefined); +} + +async function findRefreshTokenByData( + userId: string, + { system, browser, ip }: Pick +) { + userId = UserIdSchema.parse(userId); + system = ConnectionDataSchema.shape.system.parse(system); + browser = ConnectionDataSchema.shape.browser.parse(browser); + ip = ConnectionDataSchema.shape.ip.parse(ip); + + const db = await getDb(); + const [tableUser, valueUser] = String(userId).split(":"); + const [[result]] = await db.query<[[TRefreshToken]]>( + /* surql */ ` + SELECT * FROM refreshToken WHERE $user = user AND connectionData.system = $system + AND connectionData.browser = $browser AND connectionData.ip = $ip + ; + `, + { user: new RecordId(tableUser, valueUser), system, browser, ip } + ); + + if (result) result.id = result.id.toString() as z.infer; + + return await RefreshTokenSchema.parseAsync(result).catch(() => undefined); +} + +async function createRefreshToken(refreshId: string, refreshData: TCreateRefreshToken) { + refreshId = RefreshJwtIdSchema.parse(refreshId); + refreshData = CreateRefreshTokenSchema.parse(refreshData); + + const db = await getDb(); + const [table, value] = String(refreshId).split(":"); + const [tableUser, valueUser] = String(refreshData.user).split(":"); + const [[result]] = await db.query<[[TRefreshToken]]>( + /* surql */ ` + CREATE type::thing($table, $value) SET user = $user, isRevoked = $isRevoked, + connectionData.system = $system, + connectionData.browser = $browser, + connectionData.ip = $ip + ; + `, + { + table: table, + value: value, + user: new RecordId(tableUser, valueUser), + isRevoked: refreshData.isRevoked, + system: refreshData.connectionData.system, + browser: refreshData.connectionData.browser, + ip: refreshData.connectionData.ip + } + ); + + result.id = result.id.toString() as z.infer; + + return await RefreshTokenSchema.parseAsync(result).catch(() => undefined); +} + +async function revokeRefreshToken(id: string) { + id = RefreshJwtIdSchema.parse(id); + + const db = await getDb(); + const [table, value] = String(id).split(":"); + const [result] = await db.merge>( + new RecordId(table, value), + { + isRevoked: true + } + ); + + return await RefreshTokenSchema.parseAsync(result).catch(() => undefined); +} + +async function deleteRefreshToken(id: string) { + id = RefreshJwtIdSchema.parse(id); + + const [table, value] = String(id).split(":"); + try { + const db = await getDb(); + const [[result]] = await db.query<[[TRefreshToken]]>( + /* surql */ ` + DELETE $id RETURN BEFORE; + `, + { + id: new RecordId( + table, + value.startsWith("⟨") && value.endsWith("⟩") ? value.slice(1, -1) : value + ) + } + ); + + return await RefreshTokenSchema.parseAsync(result).catch(() => undefined); + } catch (error: any) { + logger.error(`Failed to delete refresh token: ${error.message}`); + return undefined; + } +} + +async function deleteAllRefreshTokens(userId: string) { + userId = UserIdSchema.parse(userId); + + const [tableUser, valueUser] = String(userId).split(":"); + try { + const db = await getDb(); + const [result] = await db.query<[TRefreshToken[]]>( + /* surrealql */ ` + DELETE refreshToken WHERE user = $userId RETURN BEFORE + ; + `, + { userId: new RecordId(tableUser, valueUser) } + ); + + return await z + .array(RefreshTokenSchema) + .parseAsync(result) + .catch(() => undefined); + } catch (error: any) { + logger.error(`Failed to delete refresh tokens: ${error.message}`); + return undefined; + } +} + +export { + findRefreshTokenById, + findRefreshTokenByData, + createRefreshToken, + revokeRefreshToken, + deleteRefreshToken, + deleteAllRefreshTokens +}; diff --git a/backend/src/dao/user.dao.ts b/backend/src/dao/user.dao.ts new file mode 100644 index 00000000..4f820fb5 --- /dev/null +++ b/backend/src/dao/user.dao.ts @@ -0,0 +1,100 @@ +import { getDb } from "@utils/connect.ts"; +import { + CreateUserSchema, + type TCreateUser, + type TUser, + UserSchema, + ProviderIdSchema +} from "@schemas/index.ts"; +import { RecordId } from "surrealdb"; +import z from "zod"; +import logger from "@utils/logger.ts"; + +async function findUserById(id: string) { + id = UserSchema.shape.id.parse(id); + + const db = await getDb(); + const [table, value] = String(id).split(":"); + const [[result]] = await db.query<[[TUser]]>(/* surql */ `SELECT * FROM $id`, { + id: new RecordId(table, value) + }); + + if (result) result.id = result.id.toString() as z.infer; + + return UserSchema.parse(result); +} + +async function findUserByEmail(email: string) { + email = z.string().email().parse(email); + + const db = await getDb(); + const [[result]] = await db.query<[[TUser]]>( + /* surrealql */ ` + SELECT ONLY * FROM user WHERE $email = email + `, + { email } + ); + + if (result) result.id = result.id.toString() as z.infer; + + return await UserSchema.parseAsync(result).catch(() => undefined); +} + +async function findUserByProviderId(providerId: string) { + providerId = ProviderIdSchema.parse(providerId); + + const db = await getDb(); + const [[result]] = await db.query<[[TUser]]>( + /* surrealql */ ` + SELECT * FROM user WHERE $providerId IN providersId + `, + { providerId } + ); + + if (result) result.id = result.id.toString() as z.infer; + + return await UserSchema.parseAsync(result).catch(() => undefined); +} + +async function createUser(userData: TCreateUser) { + userData = CreateUserSchema.parse(userData); + + const db = await getDb(); + const result = await db.create("user", userData); + + return await UserSchema.parseAsync(result).catch(() => undefined); +} + +async function updateUser( + id: string, + userData: Partial> +) { + id = UserSchema.shape.id.parse(id); + userData = UserSchema.omit({ id: true, created: true, updated: true }).partial().parse(userData); + + const db = await getDb(); + const [table, value] = String(id).split(":"); + const result = await db.merge>>( + new RecordId(table, value), + userData + ); + + return await UserSchema.parseAsync(result).catch(() => undefined); +} + +async function deleteUser(id: string) { + id = UserSchema.shape.id.parse(id); + + const [table, value] = String(id).split(":"); + try { + const db = await getDb(); + const result = await db.delete(new RecordId(table, value)); + + return await UserSchema.parseAsync(result).catch(() => undefined); + } catch (error: any) { + logger.error(`Failed to delete user: ${error.message}`); + return undefined; + } +} + +export { findUserById, findUserByEmail, findUserByProviderId, createUser, updateUser, deleteUser }; diff --git a/backend/src/dao/verificationCode.dao.ts b/backend/src/dao/verificationCode.dao.ts new file mode 100644 index 00000000..98e12205 --- /dev/null +++ b/backend/src/dao/verificationCode.dao.ts @@ -0,0 +1,54 @@ +import { getDb } from "@utils/connect.ts"; +import { + type TVerificationCode, + VerificationCodeSchema +} from "@schemas/db/verificationCode.schema.ts"; +import logger from "@utils/logger.ts"; + +async function findVerifyCode(email: string) { + email = VerificationCodeSchema.shape.email.parse(email); + + const db = await getDb(); + const [[result]] = await db.query<[[TVerificationCode]]>( + /*surql*/ ` + SELECT * FROM verifyCode WHERE email = $email + ; + `, + { + email + } + ); + + return await VerificationCodeSchema.parseAsync(result).catch(() => undefined); +} + +async function createVerifyCode({ code, email, exp }: TVerificationCode) { + const verificationCode = VerificationCodeSchema.parse({ code, email, exp }); + + const db = await getDb(); + const result = await db.create("verifyCode", verificationCode); + + return await VerificationCodeSchema.parseAsync(result).catch(() => undefined); +} + +async function deleteVerifyCode(email: string) { + email = VerificationCodeSchema.shape.email.parse(email); + + try { + const db = await getDb(); + const [result] = await db.query<[TVerificationCode[]]>( + /*surql*/ ` + DELETE FROM verifyCode WHERE email = $email RETURN BEFORE + ; + `, + { email } + ); + + return await VerificationCodeSchema.parseAsync(result).catch(() => undefined); + } catch (error: any) { + logger.error(`Failed to delete verification code: ${error.message}`); + return undefined; + } +} + +export { findVerifyCode, createVerifyCode, deleteVerifyCode }; diff --git a/backend/src/exceptions/api.error.ts b/backend/src/exceptions/api.error.ts new file mode 100644 index 00000000..7b4808ce --- /dev/null +++ b/backend/src/exceptions/api.error.ts @@ -0,0 +1,40 @@ +class ApiError extends Error { + status: number; + errors?: any[]; + + constructor(status: number, message: string, errors?: any[]) { + super(message); + this.status = status; + this.errors = errors; + } + + static Unauthorized() { + return new ApiError(401, "Unauthorized."); + } + + static BadRequest(message: string, errors: any[] = []) { + return new ApiError(400, message, errors); + } + + static ServiceUnavailable(message: string, errors: any[] = []) { + return new ApiError(503, message, errors); + } + + static Forbidden(message: string, errors: any[] = []) { + return new ApiError(403, message, errors); + } + + static Internal(message: string, errors: any[] = []) { + return new ApiError(500, message, errors); + } + + static NotFound() { + return new ApiError(404, "Data not found."); + } + + static Expired(message: string, errors: any[] = []) { + return new ApiError(410, message, errors); + } +} + +export { ApiError }; diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts new file mode 100644 index 00000000..377d9058 --- /dev/null +++ b/backend/src/middleware/auth.middleware.ts @@ -0,0 +1,28 @@ +import { TokenService } from "@service/token.service.ts"; +import { type TAccessPayload } from "@utils/jwt/types/TAuthPayload.ts"; +import { type Request, type Response, type NextFunction } from "express"; +import { ApiError } from "src/exceptions/api.error.ts"; + +async function authMiddleware(req: Request, res: Response, next: NextFunction) { + try { + const { accessToken } = req.cookies; + if (!accessToken) { + throw ApiError.Unauthorized(); + } + + const verificationResult = await TokenService.verifyToken(accessToken); + if (!verificationResult || verificationResult.code === "ERR_JWT_EXPIRED") { + throw ApiError.Unauthorized(); + } + + return next(); + } catch (error: any) { + if (error instanceof ApiError) { + return res.status(error.status).json(); + } + + return res.status(500).json(); + } +} + +export default authMiddleware; diff --git a/backend/src/middleware/errorOauth.middleware.ts b/backend/src/middleware/errorOauth.middleware.ts new file mode 100644 index 00000000..0b13ec83 --- /dev/null +++ b/backend/src/middleware/errorOauth.middleware.ts @@ -0,0 +1,16 @@ +import { type Request, type Response, type NextFunction } from "express"; +import { ApiError } from "src/exceptions/api.error.ts"; +import logger from "@utils/logger.ts"; +import { ENV } from "@schemas/index.ts"; + +async function errorOauthMiddleware(error: Error, req: Request, res: Response, next: NextFunction) { + logger.error(error.message); + + if (error instanceof ApiError) { + return res.redirect(`${ENV.APP_REDIRECT_URL}?error=${error.status}`); + } + + return res.redirect(`${ENV.APP_REDIRECT_URL}?error=500`); +} + +export default errorOauthMiddleware; diff --git a/backend/src/middleware/errorRequest.middelware.ts b/backend/src/middleware/errorRequest.middelware.ts new file mode 100644 index 00000000..6a89ad77 --- /dev/null +++ b/backend/src/middleware/errorRequest.middelware.ts @@ -0,0 +1,20 @@ +import logger from "@utils/logger.ts"; +import { type Request, type Response, type NextFunction } from "express"; +import { ApiError } from "src/exceptions/api.error.ts"; + +async function errorRequestMiddelware( + error: Error, + req: Request, + res: Response, + next: NextFunction +) { + logger.error(error.message); + + if (error instanceof ApiError) { + return res.status(error.status).json({ error: error.message }); + } + + return res.status(500).json({ error: "Internal Server Error" }); +} + +export default errorRequestMiddelware; diff --git a/backend/src/router.ts b/backend/src/router.ts new file mode 100644 index 00000000..57a61066 --- /dev/null +++ b/backend/src/router.ts @@ -0,0 +1,28 @@ +import { type Express } from "express"; +import errorOauth from "@middleware/errorOauth.middleware.ts"; +import errorRequest from "@middleware/errorRequest.middelware.ts"; +import auth from "@middleware/auth.middleware.ts"; +import { UserController } from "@controller/user.controller.ts"; +import { EmailController } from "@controller/email.controller.ts"; +import { TokenController } from "@controller/token.controller.ts"; +import { PkceController } from "@controller/pkce.controller.ts"; + +function routes(app: Express) { + app.get("/auth", auth, TokenController.auth, errorRequest); + app.post("/auth/refresh_token/signout", UserController.signOut, errorRequest); + app.get("/auth/refresh_token/refresh", TokenController.refresh, errorRequest); + app.get("/auth/token_db", auth, TokenController.getDbToken, errorRequest); + + app.get("/oauth/google", UserController.oauthGoogle, errorOauth); + app.get("/oauth/vkontakte", UserController.oauthVkontakte, errorOauth); + app.get("/oauth/yandexID", UserController.oauthYandex, errorOauth); + + app.post("/sign_in_with_email", UserController.signInWitEmail, errorRequest); + app.post("/sign_up_with_email", UserController.signUpWithEmail, errorRequest); + + app.get("/pkce", PkceController.generatePkce, errorRequest); + app.post("/generate_verification_code", EmailController.generateVerificationCode, errorRequest); + app.post("/check_verification_code", EmailController.verifyCode, errorRequest); +} + +export default routes; diff --git a/backend/src/schemas/db/record.schema.ts b/backend/src/schemas/db/record.schema.ts new file mode 100644 index 00000000..43e57b77 --- /dev/null +++ b/backend/src/schemas/db/record.schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { RecordId } from "surrealdb"; + +const record = (table?: Table) => { + return z.custom<`${Table}:${string}`>( + (val: string | RecordId<`${Table}`>) => { + if (val instanceof RecordId) { + val = val.toString(); + } + return typeof val === "string" && table ? val.startsWith(table + ":") : false; + }, + { + message: ["Must be a record", table && `Table must be: "${table}"`] + .filter((a) => a) + .join("; ") + } + ); +}; + +export { record }; diff --git a/backend/src/schemas/db/refreshToken.schema.ts b/backend/src/schemas/db/refreshToken.schema.ts new file mode 100644 index 00000000..ed1d522a --- /dev/null +++ b/backend/src/schemas/db/refreshToken.schema.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +import { record } from "./record.schema.ts"; + +const RefreshJwtIdSchema = record("refreshToken"); +/** + * @type тип идентификатора refresh токена. + */ +type TRefreshJwtId = z.infer; + +const ConnectionDataSchema = z.object({ + system: z.string(), + browser: z.string(), + ip: z.string(), + date: z.date() +}); + +type TConnectionData = z.infer; + +const RefreshTokenSchema = z.object({ + id: RefreshJwtIdSchema, + user: record("user"), + isRevoked: z.boolean(), + connectionData: ConnectionDataSchema +}); + +type TRefreshToken = z.infer; + +const CreateRefreshTokenSchema = RefreshTokenSchema.extend({ + connectionData: ConnectionDataSchema.omit({ date: true }) +}).omit({ id: true }); + +type TCreateRefreshToken = z.infer; + +export { + RefreshJwtIdSchema, + type TRefreshJwtId, + ConnectionDataSchema, + type TConnectionData, + RefreshTokenSchema, + type TRefreshToken, + CreateRefreshTokenSchema, + type TCreateRefreshToken +}; diff --git a/backend/src/schemas/db/user.schema.ts b/backend/src/schemas/db/user.schema.ts new file mode 100644 index 00000000..2e7b1ac3 --- /dev/null +++ b/backend/src/schemas/db/user.schema.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { providerId } from "@schemas/utils/providerId.schema.ts"; + +import { record } from "./record.schema.ts"; + +const UserRoleSchema = z.union([z.literal("admin"), z.literal("user"), z.literal("premium")]); +type TUserRole = z.infer; + +const UserIdSchema = record("user"); +type TUserId = z.infer; + +const ProviderIdSchema = z.union([ + providerId("google"), + providerId("vkontakte"), + providerId("email"), + providerId("yandex") +]); +type TProviderId = z.infer; + +const UserSchema = z.object({ + id: UserIdSchema, + name: z.string().optional(), + surname: z.string().optional(), + patronymic: z.string().optional(), + username: z.string().optional(), + email: z.string().email().optional(), + providersId: z.array(ProviderIdSchema), + image: z.string().url().optional(), + role: UserRoleSchema, + created: z.coerce.date(), + updated: z.coerce.date() +}); + +type TUser = z.infer; + +const CreateUserSchema = UserSchema.omit({ + id: true, + created: true, + updated: true +}); + +type TCreateUser = z.infer; + +export { + UserRoleSchema, + type TUserRole, + UserIdSchema, + type TUserId, + ProviderIdSchema, + type TProviderId, + UserSchema, + type TUser, + CreateUserSchema, + type TCreateUser +}; diff --git a/backend/src/schemas/db/verificationCode.schema.ts b/backend/src/schemas/db/verificationCode.schema.ts new file mode 100644 index 00000000..ab24f1c4 --- /dev/null +++ b/backend/src/schemas/db/verificationCode.schema.ts @@ -0,0 +1,11 @@ +import z from "zod"; + +const VerificationCodeSchema = z.object({ + code: z.string().min(5).max(5), + email: z.string().email(), + exp: z.number() +}); + +type TVerificationCode = z.infer; + +export { VerificationCodeSchema, type TVerificationCode }; diff --git a/backend/src/schemas/env/env.ts b/backend/src/schemas/env/env.ts new file mode 100644 index 00000000..3738db18 --- /dev/null +++ b/backend/src/schemas/env/env.ts @@ -0,0 +1,74 @@ +import { TimeFormatSchema } from "@schemas/utils/time.schema.ts"; +import { z } from "zod"; +import "dotenv/config"; +import { buildEnvProxy } from "@utils/buildEnvProxy.ts"; +import { parseConfig } from "@utils/parseConfig.ts"; + +const EnvSchema = z.object({ + API_BASE_URL: z.string().url("Invalid url"), + API_PORT: z.coerce.number(), + APP_BASE_URL: z.string().url("Invalid url"), + APP_REDIRECT_URL: z.string().url("Invalid url!"), + + EMAIL_SERVICE: z.string(), + EMAIL_USER: z.string(), + EMAIL_PASS: z.string(), + EMAIL_APP_PASS: z.string(), + EMAIL_ISSUER: z.string(), + SMTP_HOST: z.string(), + SMTP_PORT: z.coerce.number(), + SMTP_SECURE: z.coerce.boolean(), + + GOOGLE_CLIENT_ID: z.string().min(1, "Must be 1 or more characters long"), + GOOGLE_CLIENT_SECRET: z.string().min(1, "Must be 1 or more characters long"), + GOOGLE_REDIRECT_URL: z.string().url("Invalid redirect URL"), + + VK_CLIENT_ID: z.string().min(1, "Must be 1 or more characters long"), + VK_CLIENT_SECRET: z.string().min(1, "Must be 1 or more characters long"), + VK_CLIENT_SERVICE_ACCESS: z.string().min(1, "Must be 1 or more characters long"), + VK_REDIRECT_URL: z.string().url("Invalid redirect URL"), + + YANDEX_CLIENT_ID: z.string().min(1, "Must be 1 or more characters long"), + YANDEX_CLIENT_SECRET: z.string().min(1, "Must be 1 or more characters long"), + YANDEX_REDIRECT_URL: z.string().url("Invalid redirect URL"), + + SURREALDB_ENDPOINT: z.string().min(1, "Must be 1 or more characters long"), + SURREALDB_USER: z.string().min(1, "Must be 1 or more characters long"), + SURREALDB_PASS: z.string().min(1, "Must be 1 or more characters long"), + SURREALDB_NS: z.string().min(1, "Must be 1 or more characters long"), + SURREALDB_DB: z.string().min(1, "Must be 1 or more characters long"), + SURREALDB_AC: z.string().min(1, "Must be 1 or more characters long"), + SURREALDB_EXP_DB_TOKEN: TimeFormatSchema, + SURREALDB_PRIVATE_KEY: z.string(), + SURREALDB_PUBLIC_KEY: z.string(), + + AUTH_ISS: z.string().min(1, "Must be 1 or more characters long"), + AUTH_EXP_ACCESS_TOKEN: TimeFormatSchema, + AUTH_EXP_REFRESH_TOKEN: TimeFormatSchema, + AUTH_HASH_ALG: z.union([ + z.literal("RS256"), + z.literal("EDDSA"), + z.literal("ES256"), + z.literal("ES384"), + z.literal("ES512"), + z.literal("PS256"), + z.literal("PS384"), + z.literal("PS512"), + z.literal("RS256"), + z.literal("RS384"), + z.literal("RS512") + ]), + AUTH_PRIVATE_KEY: z.string().min(1, "Must be 1 or more characters long"), + AUTH_PUBLIC_KEY: z.string().min(1, "Must be 1 or more characters long") +}); + +const envObj = buildEnvProxy([{ source: process.env }]); +const ENV = parseConfig(envObj, EnvSchema); + +declare global { + namespace NodeJS { + interface ProcessEnv extends z.infer {} + } +} + +export { ENV }; diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts new file mode 100644 index 00000000..ca81dc78 --- /dev/null +++ b/backend/src/schemas/index.ts @@ -0,0 +1,28 @@ +export { ENV } from "./env/env.ts"; + +export { + RefreshJwtIdSchema, + type TRefreshJwtId, + CreateRefreshTokenSchema, + type TCreateRefreshToken, + ConnectionDataSchema, + type TConnectionData, + RefreshTokenSchema, + type TRefreshToken +} from "./db/refreshToken.schema.ts"; +export { + UserRoleSchema, + type TUserRole, + UserIdSchema, + type TUserId, + CreateUserSchema, + type TCreateUser, + ProviderIdSchema, + type TProviderId, + UserSchema, + type TUser +} from "./db/user.schema.ts"; +export { record } from "./db/record.schema.ts"; + +export { TimeFormatSchema, type TTimeFormat } from "./utils/time.schema.ts"; +export { providerId } from "./utils/providerId.schema.ts"; diff --git a/backend/src/schemas/utils/providerId.schema.ts b/backend/src/schemas/utils/providerId.schema.ts new file mode 100644 index 00000000..504bc973 --- /dev/null +++ b/backend/src/schemas/utils/providerId.schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +const providerId = (provider: Provider) => + z.custom<`${Provider}:${string}`>( + (val) => { + return typeof val === "string" && provider ? val.startsWith(provider + ":") : false; + }, + { + message: `Provider must be: "${provider}"` + } + ); + +export { providerId }; diff --git a/backend/src/schemas/utils/time.schema.ts b/backend/src/schemas/utils/time.schema.ts new file mode 100644 index 00000000..197a0547 --- /dev/null +++ b/backend/src/schemas/utils/time.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +const REG = + /^(\+|-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)$/i; + +const TimeFormatSchema = + z.custom<`${"+" | "-" | ""}${string}${"seconds" | "secs" | "s" | "minutes" | "mins" | "m" | "hours" | "hrs" | "h" | "days" | "d" | "weeks" | "w" | "years" | "yrs" | "y"}`>( + (val: string) => { + return typeof val === "string" ? REG.test(val) : false; + }, + { + message: "Invalid time period format." + } + ); + +type TTimeFormat = z.infer; + +export { TimeFormatSchema, type TTimeFormat }; diff --git a/backend/src/service/codeVerify.service.ts b/backend/src/service/codeVerify.service.ts new file mode 100644 index 00000000..4b533573 --- /dev/null +++ b/backend/src/service/codeVerify.service.ts @@ -0,0 +1,37 @@ +import { createVerifyCode, deleteVerifyCode, findVerifyCode } from "@dao/verificationCode.dao.ts"; +import isTimeExpired from "@utils/jwt/isTimeExpired.ts"; +import { ApiError } from "src/exceptions/api.error.ts"; + +class CodeVerifyService { + static async findCode(email: string) { + const verificationCodeDB = await findVerifyCode(email); + + return verificationCodeDB; + } + + static async saveCode(email: string, code: string, exp: number) { + const verificationCodeDB = await createVerifyCode({ email, code, exp }); + + return verificationCodeDB; + } + + static async deleteCode(email: string) { + const isDeleted = await deleteVerifyCode(email); + + return isDeleted; + } + + static async verifyCode(email: string, code: string) { + const verificationCodeDB = await this.findCode(email); + if (!verificationCodeDB) + throw ApiError.Expired("Could not find code. Verification code expired"); + + if (verificationCodeDB.code !== code) throw ApiError.BadRequest("Invalid code."); + + if (isTimeExpired(verificationCodeDB.exp)) throw ApiError.Expired("Verification code expired"); + + return true; + } +} + +export { CodeVerifyService }; diff --git a/backend/src/service/email.service.ts b/backend/src/service/email.service.ts new file mode 100644 index 00000000..4ad42a25 --- /dev/null +++ b/backend/src/service/email.service.ts @@ -0,0 +1,70 @@ +import { ENV } from "@schemas/index.ts"; +import logger from "@utils/logger.ts"; +import nodemailer from "nodemailer"; +import { ApiError } from "src/exceptions/api.error.ts"; + +class EmailService { + static async sendVerifyCode(to: string, code: number | string) { + try { + const transporter = nodemailer.createTransport({ + service: ENV.EMAIL_SERVICE, + auth: { + user: ENV.EMAIL_USER, + pass: ENV.EMAIL_APP_PASS + } + }); + + const info = await transporter.sendMail({ + from: `"Flippo" <${ENV.EMAIL_USER}>`, + to, + subject: "Your Verification Code for Flippo", + html: ` +
+

+ Hi ${to}, +

+ +

+ Thank you for signing up with Flippo! To complete your registration, please use the verification code below: +

+ +

+ ${code} +

+ +

+ Enter this code in the app to verify your email and activate your account. If you didn't sign up for an account, please ignore this email. +

+ +

+ If you have any questions or need assistance, feel free to contact our support team. +

+ +

+ Welcome to Flippo! +

+ +

+ Best regards,
The Flippo Team
flippo.support@yandex.com +

+
+ ` + }); + + return info; + } catch (error: any) { + logger.error(error.message); + throw ApiError.BadRequest("Failed send verification code"); + } + } + + static async validateEmail(emailFromCookie: string, email: string) { + if (emailFromCookie !== email) { + throw ApiError.BadRequest("Invalid email"); + } + + return true; + } +} + +export { EmailService }; diff --git a/backend/src/service/oauth.service.ts b/backend/src/service/oauth.service.ts new file mode 100644 index 00000000..4c8c2701 --- /dev/null +++ b/backend/src/service/oauth.service.ts @@ -0,0 +1,134 @@ +import { ENV } from "@schemas/index.ts"; +import axios, { type AxiosRequestConfig } from "axios"; +import { ApiError } from "src/exceptions/api.error.ts"; + +import { type IGoogleTokensResult } from "./types/IGoogleTokensResult.ts"; +import { type IGoogleUserResult } from "./types/IGoogleUserResult.ts"; +import { type IYandexTokensResult } from "./types/IYandexTokensResult.ts"; +import { type IVkontakteTokensResult } from "./types/IVkontakteTokensResult.ts"; +import { type IYandexUserResult } from "./types/IYandexUserResult.ts"; +import { type IVkontakteUserResult } from "./types/IVkontakteUserResult.ts"; + +type TOAuthProviders = "google" | "yandex" | "vkontakte"; + +type TSharedQueryParams = { + client_id: string; + client_secret: string; + code: string; + grant_type: "authorization_code"; + redirect_uri: string; + code_verifier?: string; + device_id?: string; +}; + +const oauthTokenUrl: { [key in TOAuthProviders]: string } = { + google: `https://oauth2.googleapis.com/token`, + yandex: `https://oauth.yandex.ru/token`, + vkontakte: `https://id.vk.com/oauth2/auth` +}; + +const oauthUserInfoUrl: { [key in TOAuthProviders]: string } = { + google: `https://www.googleapis.com/oauth2/v1/userinfo`, + yandex: `https://login.yandex.ru/info`, + vkontakte: `https://id.vk.com/oauth2/user_info` +}; + +const providerData = { + google: { + client_id: ENV.GOOGLE_CLIENT_ID, + client_secret: ENV.GOOGLE_CLIENT_SECRET, + redirect_uri: ENV.GOOGLE_REDIRECT_URL + }, + yandex: { + client_id: ENV.YANDEX_CLIENT_ID, + client_secret: ENV.YANDEX_CLIENT_SECRET, + redirect_uri: ENV.YANDEX_REDIRECT_URL + }, + vkontakte: { + client_id: ENV.VK_CLIENT_ID, + client_secret: ENV.VK_CLIENT_SECRET, + redirect_uri: ENV.VK_REDIRECT_URL + } +}; + +const configForRequestUserInfo: { + [key in TOAuthProviders]: (access_token: string, ...args: any) => AxiosRequestConfig; +} = { + google: (access_token) => { + return { + url: oauthUserInfoUrl["google"], + method: "GET", + headers: { + Authorization: `Bearer ${access_token}` + } + }; + }, + yandex: (access_token) => { + return { + url: oauthUserInfoUrl["yandex"], + method: "GET", + headers: { + Authorization: `OAuth ${access_token}` + } + }; + }, + vkontakte: (access_token) => { + return { + url: oauthUserInfoUrl["vkontakte"], + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + data: { + client_id: ENV.VK_CLIENT_ID, + access_token + } + }; + } +}; + +class OAuthService { + static async getOauthTokens< + T extends IGoogleTokensResult | IYandexTokensResult | IVkontakteTokensResult + >( + provider: TOAuthProviders, + code: string, + codeVerifier?: string, + device_id?: string + ): Promise { + try { + const options: TSharedQueryParams = { + ...providerData[provider], + code, + grant_type: "authorization_code", + code_verifier: codeVerifier, + device_id: device_id + }; + + const result = await axios.post(oauthTokenUrl[provider], options, { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }); + + return result.data; + } catch (error: any) { + throw ApiError.BadRequest(`Failed to get ${provider} OAuth tokens. Reason: ${error.message}`); + } + } + + static async getUserInfo( + provider: TOAuthProviders, + access_token: string + ): Promise { + try { + const result = await axios(configForRequestUserInfo[provider](access_token)); + + return result.data; + } catch (error: any) { + throw ApiError.BadRequest(`Failed to get ${provider} user info. Reason: ${error.message}`); + } + } +} + +export { OAuthService }; diff --git a/backend/src/service/token.service.ts b/backend/src/service/token.service.ts new file mode 100644 index 00000000..a1689b9e --- /dev/null +++ b/backend/src/service/token.service.ts @@ -0,0 +1,121 @@ +import { + findRefreshTokenById, + findRefreshTokenByData, + deleteRefreshToken, + createRefreshToken +} from "@dao/refreshToken.dao.ts"; +import { type TConnectionData, type TUserId } from "@schemas/index.ts"; +import * as JWT from "@utils/jwt/jwt.ts"; +import { type TAccessPayload, type TRefreshPayload } from "@utils/jwt/types/TAuthPayload.ts"; +import { ApiError } from "src/exceptions/api.error.ts"; +import { type TCreateRefreshToken, type TRefreshJwtId } from "@schemas/db/refreshToken.schema.ts"; +import { omit } from "lodash-es"; +import { JWTExpired } from "jose/errors"; + +type TVerificationResultCodeJWT = "SUCCESS" | "ERR_JWT_EXPIRED"; + +type TVerificationResultJWT = { + code: TVerificationResultCodeJWT; + payload: T; +}; + +class TokenService { + static async generateAccessRefreshTokens( + userId: TUserId, + connectionData: Omit + ) { + const { tokens, payloads } = await JWT.generateTokens(userId); + + const refreshTokenDB = await TokenService.saveRefreshToken(payloads.refreshPayload.jti, { + user: userId, + isRevoked: false, + connectionData + }); + + if (!refreshTokenDB) throw ApiError.Internal("Failed to generate a refresh token in DB."); + + return tokens; + } + + static async refresh(token: string) { + const verificationResult = await TokenService.verifyToken(token); + if (!verificationResult) throw ApiError.BadRequest("Invalid token"); + + if (verificationResult.code === "ERR_JWT_EXPIRED") { + await deleteRefreshToken(verificationResult.payload.jti); + throw ApiError.Unauthorized(); + } + + const tokenDB = await findRefreshTokenById(verificationResult.payload.jti); + if (!tokenDB || tokenDB.isRevoked) { + throw ApiError.Unauthorized(); + } + + await deleteRefreshToken(verificationResult.payload.jti); + + const connectionData = omit(tokenDB.connectionData, "date"); + const tokens = await this.generateAccessRefreshTokens( + verificationResult.payload.userId as TUserId, + connectionData + ); + + return tokens; + } + + static async verifyToken( + token: string + ): Promise | null> { + try { + const payload = await JWT.verifyToken(token); + + return { code: "SUCCESS", payload: payload }; + } catch (error: any) { + if (error instanceof JWTExpired) + return { + code: "ERR_JWT_EXPIRED", + payload: error.payload as T + }; + + return null; + } + } + + static async saveRefreshToken(refreshId: TRefreshJwtId, refreshData: TCreateRefreshToken) { + const userId = refreshData.user; + const system = refreshData.connectionData.system; + const browser = refreshData.connectionData.browser; + const ip = refreshData.connectionData.ip; + + const tokenDB = await findRefreshTokenByData(userId, { system, browser, ip }); + if (tokenDB) { + const isDeleted = await this.deleteRefreshToken(tokenDB.id); + if (!isDeleted) throw ApiError.Internal("Failed to delete refresh token from DB."); + } + + const newTokenDB = await createRefreshToken(refreshId, refreshData); + if (!newTokenDB) throw ApiError.Internal("Failed to save a refresh token in DB."); + + return newTokenDB; + } + + static async deleteRefreshToken(jti: string) { + const isDeleted = await deleteRefreshToken(jti); + + return isDeleted; + } + + static async findRefreshToken(jti: string) { + const tokenDB = await findRefreshTokenById(jti); + + return tokenDB; + } + + static async generateDbToken(userId: string) { + const token = await JWT.generateDbToken(userId); + if (!token) throw ApiError.Internal("Failed to generate a DB token."); + + return token; + } +} + +export { TokenService }; diff --git a/backend/src/service/types/IGoogleTokensResult.ts b/backend/src/service/types/IGoogleTokensResult.ts new file mode 100644 index 00000000..3f95deae --- /dev/null +++ b/backend/src/service/types/IGoogleTokensResult.ts @@ -0,0 +1,7 @@ +import { type ITokensResult } from "./ITokensResult.ts"; + +interface IGoogleTokensResult extends ITokensResult { + id_token: string; +} + +export type { IGoogleTokensResult }; diff --git a/backend/src/service/types/IGoogleUserResult.ts b/backend/src/service/types/IGoogleUserResult.ts new file mode 100644 index 00000000..fdcc4c0b --- /dev/null +++ b/backend/src/service/types/IGoogleUserResult.ts @@ -0,0 +1,12 @@ +interface IGoogleUserResult { + id: string; + email: string; + verified_email: boolean; + name: string; + given_name: string; + family_name: string; + picture: string; + locale: string; +} + +export type { IGoogleUserResult }; diff --git a/backend/src/service/types/ITokensResult.ts b/backend/src/service/types/ITokensResult.ts new file mode 100644 index 00000000..b91efa10 --- /dev/null +++ b/backend/src/service/types/ITokensResult.ts @@ -0,0 +1,10 @@ +interface ITokensResult { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; + state?: string; +} + +export { type ITokensResult }; diff --git a/backend/src/service/types/IVkontakteTokensResult.ts b/backend/src/service/types/IVkontakteTokensResult.ts new file mode 100644 index 00000000..e2f4730a --- /dev/null +++ b/backend/src/service/types/IVkontakteTokensResult.ts @@ -0,0 +1,8 @@ +import { type ITokensResult } from "./ITokensResult.ts"; + +interface IVkontakteTokensResult extends ITokensResult { + id_token: string; + user_id: string; +} + +export type { IVkontakteTokensResult }; diff --git a/backend/src/service/types/IVkontakteUserResult.ts b/backend/src/service/types/IVkontakteUserResult.ts new file mode 100644 index 00000000..6050d5dd --- /dev/null +++ b/backend/src/service/types/IVkontakteUserResult.ts @@ -0,0 +1,14 @@ +interface IVkontakteUserResult { + user: { + user_id: string; + first_name: string; + last_name: string; + avatar: string; + email: string; + sex: number; + verified: boolean; + birthday: string; + }; +} + +export type { IVkontakteUserResult }; diff --git a/backend/src/service/types/IYandexTokensResult.ts b/backend/src/service/types/IYandexTokensResult.ts new file mode 100644 index 00000000..9694808f --- /dev/null +++ b/backend/src/service/types/IYandexTokensResult.ts @@ -0,0 +1,5 @@ +import { type ITokensResult } from "./ITokensResult.ts"; + +interface IYandexTokensResult extends ITokensResult {} + +export type { IYandexTokensResult }; diff --git a/backend/src/service/types/IYandexUserResult.ts b/backend/src/service/types/IYandexUserResult.ts new file mode 100644 index 00000000..8acdfab1 --- /dev/null +++ b/backend/src/service/types/IYandexUserResult.ts @@ -0,0 +1,22 @@ +interface IYandexUserResult { + login: string; + id: string; + client_id: string; + psuid: string; + + old_social_login: string; + + default_email: string; + emails: string[]; + + is_avatar_empty: boolean; + default_avatar_id: string; + + first_name: string; + last_name: string; + display_name: string; + real_name: string; + sex: string; +} + +export type { IYandexUserResult }; diff --git a/backend/src/service/user.service.ts b/backend/src/service/user.service.ts new file mode 100644 index 00000000..c30792c3 --- /dev/null +++ b/backend/src/service/user.service.ts @@ -0,0 +1,73 @@ +import { + createUser, + deleteUser, + findUserById, + findUserByProviderId, + updateUser +} from "@dao/user.dao.ts"; +import { type TConnectionData, type TCreateUser, type TUser } from "@schemas/index.ts"; +import { ApiError } from "src/exceptions/api.error.ts"; +import { type TRefreshPayload } from "@utils/jwt/types/TAuthPayload.ts"; + +import { TokenService } from "./token.service.ts"; + +class UserService { + static async signUp(userData: TCreateUser, connectionData: Omit) { + const user = await createUser(userData); + if (!user) throw ApiError.ServiceUnavailable("Failed to create a user."); + + const authTokens = await TokenService.generateAccessRefreshTokens(user.id, connectionData); + const dbToken = await TokenService.generateDbToken(user.id); + + return { user, tokens: { ...authTokens, dbToken } }; + } + + static async signIn(providerId: string, connectionData: Omit) { + const user = await findUserByProviderId(providerId); + if (!user) return { user: undefined, tokens: undefined }; + + const authTokens = await TokenService.generateAccessRefreshTokens(user.id, connectionData); + const dbToken = await TokenService.generateDbToken(user.id); + + return { user, tokens: { ...authTokens, dbToken } }; + } + + static async signOut(refreshToken: string) { + const verificationResult = await TokenService.verifyToken(refreshToken); + if (!verificationResult) throw ApiError.BadRequest("Refresh token not found."); + + const isDeleted = await TokenService.deleteRefreshToken(verificationResult.payload.jti); + if (!isDeleted) throw ApiError.BadRequest("Failed to delete token."); + + return true; + } + + static async getUserById(userId: string) { + const user = await findUserById(userId); + + return user; + } + + static async getUserByProviderId(providerId: string) { + const user = await findUserByProviderId(providerId); + + return user; + } + + static async updateUser( + userId: string, + userData: Partial> + ) { + const user = await updateUser(userId, userData); + + return user; + } + + static async deleteUser(userId: string) { + const user = await deleteUser(userId); + + return user; + } +} + +export { UserService }; diff --git a/backend/src/utils/buildEnvProxy.ts b/backend/src/utils/buildEnvProxy.ts new file mode 100644 index 00000000..d469ff6c --- /dev/null +++ b/backend/src/utils/buildEnvProxy.ts @@ -0,0 +1,18 @@ +export const buildEnvProxy = >( + envSources: { + source: Partial; + transformKey?: (key: string) => string; + }[] +) => + new Proxy({} as T, { + get(_, key) { + return envSources + .map(({ source, transformKey }) => { + const keyStr = String(key); + const envKey = transformKey ? transformKey(keyStr) : keyStr; + + return source[envKey]; + }) + .find((v) => v !== undefined); + } + }); diff --git a/backend/src/utils/connect.ts b/backend/src/utils/connect.ts new file mode 100644 index 00000000..1d15366f --- /dev/null +++ b/backend/src/utils/connect.ts @@ -0,0 +1,67 @@ +import { Surreal } from "surrealdb"; +import logger from "@utils/logger.ts"; +import { ENV } from "@schemas/index.ts"; + +type TDbConfig = { + database: string; + endpoint: string; + namespace: string; +}; + +type TCredentials = { + username: string; + password: string; +}; + +const DEFAULT_CONFIG: TDbConfig = { + database: ENV.SURREALDB_DB || "test", + endpoint: ENV.SURREALDB_ENDPOINT || "http://127.0.0.1:8000/rpc", + namespace: ENV.SURREALDB_NS || "test" +}; + +const DEFAULT_CREDENTIALS: TCredentials = { + username: ENV.SURREALDB_USER || "root", + password: ENV.SURREALDB_PASS || "root" +}; + +let connectionPromise: Promise | null = null; + +async function connectToDatabase(config: TDbConfig = DEFAULT_CONFIG): Promise { + const db = new Surreal(); + + try { + await db.connect(config.endpoint); + await db.use({ database: config.database, namespace: config.namespace }); + + await signInToDatabase(db); + + await db.ready; + + logger.info("SurrealDB connected!"); + return db; + } catch (error: any) { + logger.error(`Error connecting to SurrealDB: ${error}`); + + await db.close(); + //throw error; + } +} + +async function signInToDatabase(db: Surreal, credentials: TCredentials = DEFAULT_CREDENTIALS) { + await db.signin({ password: credentials.password, username: credentials.username }); + await db.ready; +} + +export async function getDb() { + if (!connectionPromise) { + connectionPromise = connectToDatabase(DEFAULT_CONFIG); + } + + const db = await connectionPromise; + const connection = await db?.connection; + if (connection) { + await signInToDatabase(db, DEFAULT_CREDENTIALS); + } + + return connectionPromise; +} diff --git a/backend/src/utils/generateVerificationCode.ts b/backend/src/utils/generateVerificationCode.ts new file mode 100644 index 00000000..26ca4403 --- /dev/null +++ b/backend/src/utils/generateVerificationCode.ts @@ -0,0 +1,10 @@ +export default (length: number = 1) => { + if (length === 0) throw new Error("Length must be greater than 0."); + + const lowerBound = Number(`1${new Array(length - 1).fill(0).join("")}`); + const upperBound = Number(`${new Array(length).fill(9).join("")}`); + + const code = Math.floor(Math.random() * (upperBound - lowerBound + 1) + lowerBound).toString(); + + return code; +}; diff --git a/backend/src/utils/getConnectionData.ts b/backend/src/utils/getConnectionData.ts new file mode 100644 index 00000000..a21c6617 --- /dev/null +++ b/backend/src/utils/getConnectionData.ts @@ -0,0 +1,13 @@ +import { type TConnectionData } from "@schemas/index.ts"; +import { type Request } from "express"; + +export default (req: Request) => { + const userAgent = req.useragent; + const connectionData = { + ip: req.socket.remoteAddress, + browser: userAgent ? userAgent.browser : "", + system: userAgent ? userAgent.os : "" + } as Omit; + + return connectionData; +}; diff --git a/backend/src/utils/jwt/epoch.ts b/backend/src/utils/jwt/epoch.ts new file mode 100644 index 00000000..c7fd5cfa --- /dev/null +++ b/backend/src/utils/jwt/epoch.ts @@ -0,0 +1 @@ +export default (date: Date): number => Math.floor(date.getTime() / 1000); diff --git a/backend/src/utils/jwt/instanceOfA.ts b/backend/src/utils/jwt/instanceOfA.ts new file mode 100644 index 00000000..1f35aa05 --- /dev/null +++ b/backend/src/utils/jwt/instanceOfA.ts @@ -0,0 +1,4 @@ +export default (object: any): object is T => { + const keys = Object.keys(object) as (keyof T)[]; + return keys.every((key) => key in object); +}; diff --git a/backend/src/utils/jwt/isTimeExpired.ts b/backend/src/utils/jwt/isTimeExpired.ts new file mode 100644 index 00000000..a84eb88d --- /dev/null +++ b/backend/src/utils/jwt/isTimeExpired.ts @@ -0,0 +1,3 @@ +import epoch from "./epoch.ts"; + +export default (value: number): boolean => epoch(new Date()) > value; diff --git a/backend/src/utils/jwt/jwt.ts b/backend/src/utils/jwt/jwt.ts new file mode 100644 index 00000000..9a70f733 --- /dev/null +++ b/backend/src/utils/jwt/jwt.ts @@ -0,0 +1,108 @@ +import * as jose from "jose"; +import { type TRefreshJwtId, ENV } from "@schemas/index.ts"; + +import { getExpiration, getIssuedAt } from "./timestamps.ts"; +import generateUUId from "./uuid.ts"; +import { + AccessPayloadSchema, + DbTokenPayloadSchema, + RefreshPayloadSchema, + type TAccessPayload, + type TDbTokenPayload, + type TRefreshPayload +} from "./types/TAuthPayload.ts"; +import { type TTokens } from "./types/TTokens.ts"; +import { PrivateKeyObject } from "./keys/privateKey.ts"; +import { PublicKeyObject } from "./keys/publicKey.ts"; +import { DbPrivateKey } from "./keys/index.ts"; + +function generateAccessTokenPayload(userId: string) { + const iat: number = getIssuedAt(); + const expAccess: number = getExpiration(ENV.AUTH_EXP_ACCESS_TOKEN, iat); + + const payload = { + iss: ENV.AUTH_ISS, + id: userId, + iat: iat, + exp: expAccess + } as TAccessPayload; + + return AccessPayloadSchema.parse(payload); +} + +function generateRefreshTokenPayload(userId: string) { + const iat: number = getIssuedAt(); + const expRefresh: number = getExpiration(ENV.AUTH_EXP_REFRESH_TOKEN, iat); + const jtiRefresh: TRefreshJwtId = `refreshToken:${generateUUId()}` as TRefreshJwtId; + + const payload = { + iss: ENV.AUTH_ISS, + jti: jtiRefresh, + userId: userId, + iat: iat, + exp: expRefresh + } as TRefreshPayload; + + return RefreshPayloadSchema.parse(payload); +} + +async function generateDbToken(userId: string) { + const iat: number = getIssuedAt(); + const expDb: number = getExpiration(ENV.SURREALDB_EXP_DB_TOKEN, iat); + + const payload = { + iss: ENV.AUTH_ISS, + id: userId, + ns: ENV.SURREALDB_NS, + db: ENV.SURREALDB_DB, + ac: ENV.SURREALDB_AC, + iat: iat, + exp: expDb + } as TDbTokenPayload; + + DbTokenPayloadSchema.parse(payload); + + const dbToken = await new jose.SignJWT(payload) + .setProtectedHeader({ + alg: ENV.AUTH_HASH_ALG + }) + .sign(DbPrivateKey); + + return dbToken; +} + +async function generateTokens(userId: string): Promise<{ + tokens: TTokens; + payloads: { accessPayload: TAccessPayload; refreshPayload: TRefreshPayload }; +}> { + const accessPayload: TAccessPayload = generateAccessTokenPayload(userId); + const refreshPayload: TRefreshPayload = generateRefreshTokenPayload(userId); + + const accessToken: string = await generateToken(accessPayload); + const refreshToken: string = await generateToken(refreshPayload); + + return { tokens: { accessToken, refreshToken }, payloads: { accessPayload, refreshPayload } }; +} + +async function generateToken( + payload: T +): Promise { + const token: string = await new jose.SignJWT(payload) + .setProtectedHeader({ alg: ENV.AUTH_HASH_ALG }) + .sign(PrivateKeyObject); + + return token; +} + +async function verifyToken(token: string): Promise { + const options: jose.JWTVerifyOptions = { + algorithms: [ENV.AUTH_HASH_ALG], + issuer: ENV.AUTH_ISS + }; + + const payload = (await jose.jwtVerify(token, PublicKeyObject, options)).payload as T; + + return payload; +} + +export { generateTokens, verifyToken, generateDbToken }; diff --git a/backend/src/utils/jwt/keys/dbPrivateKey.ts b/backend/src/utils/jwt/keys/dbPrivateKey.ts new file mode 100644 index 00000000..a152b453 --- /dev/null +++ b/backend/src/utils/jwt/keys/dbPrivateKey.ts @@ -0,0 +1,11 @@ +import crypto from "crypto"; + +import { ENV } from "@schemas/index.ts"; + +const DbPrivateKey = crypto.createPrivateKey({ + key: Buffer.from(ENV.SURREALDB_PRIVATE_KEY.replace(/\\n/g, "\n")), + format: "pem", + type: "pkcs8" +}); + +export { DbPrivateKey }; diff --git a/backend/src/utils/jwt/keys/index.ts b/backend/src/utils/jwt/keys/index.ts new file mode 100644 index 00000000..98cdde0b --- /dev/null +++ b/backend/src/utils/jwt/keys/index.ts @@ -0,0 +1,3 @@ +export { PrivateKeyObject } from "./privateKey.ts"; +export { PublicKeyObject } from "./publicKey.ts"; +export { DbPrivateKey } from "./dbPrivateKey.ts"; diff --git a/backend/src/utils/jwt/keys/privateKey.ts b/backend/src/utils/jwt/keys/privateKey.ts new file mode 100644 index 00000000..e323e670 --- /dev/null +++ b/backend/src/utils/jwt/keys/privateKey.ts @@ -0,0 +1,11 @@ +import crypto from "crypto"; + +import { ENV } from "@schemas/index.ts"; + +const PrivateKeyObject = crypto.createPrivateKey({ + key: Buffer.from(ENV.AUTH_PRIVATE_KEY.replace(/\\n/g, "\n")), + format: "pem", + type: "pkcs8" +}); + +export { PrivateKeyObject }; diff --git a/backend/src/utils/jwt/keys/publicKey.ts b/backend/src/utils/jwt/keys/publicKey.ts new file mode 100644 index 00000000..93ed0a7f --- /dev/null +++ b/backend/src/utils/jwt/keys/publicKey.ts @@ -0,0 +1,10 @@ +import crypto from "crypto"; + +import { ENV } from "@schemas/index.ts"; + +const PublicKeyObject = crypto.createPublicKey({ + key: ENV.AUTH_PUBLIC_KEY.replace(/\\n/g, "\n"), + format: "pem" +}); + +export { PublicKeyObject }; diff --git a/backend/src/utils/jwt/stringToUnixSeconds.ts b/backend/src/utils/jwt/stringToUnixSeconds.ts new file mode 100644 index 00000000..6391b51e --- /dev/null +++ b/backend/src/utils/jwt/stringToUnixSeconds.ts @@ -0,0 +1,56 @@ +const MINUTE = 60; +const HOUR = MINUTE * 60; +const DAY = HOUR * 24; +const WEEK = DAY * 7; +const YEAR = DAY * 365.25; +const REG = + /^(\+|-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)$/i; + +export default (str: string): number => { + const matched = REG.exec(str); + if (!matched) { + throw new TypeError("Invalid time period format"); + } + + const value: number = parseFloat(matched[2]); + const units: string = matched[3].toLowerCase(); + let numericDate: number; + + switch (units) { + case "seconds": + case "secs": + case "s": + numericDate = Math.round(value); + break; + case "minutes": + case "mins": + case "m": + numericDate = Math.round(value) * MINUTE; + break; + case "hours": + case "hrs": + case "h": + numericDate = Math.round(value) * HOUR; + break; + case "days": + case "d": + numericDate = Math.round(value) * DAY; + break; + case "weeks": + case "w": + numericDate = Math.round(value) * WEEK; + break; + case "years": + case "yrs": + case "y": + numericDate = Math.round(value) * YEAR; + break; + default: + numericDate = Math.round(value * YEAR); + break; + } + + if (matched[1]) return -numericDate; + + return numericDate; +}; diff --git a/backend/src/utils/jwt/timestamps.ts b/backend/src/utils/jwt/timestamps.ts new file mode 100644 index 00000000..eb25caa9 --- /dev/null +++ b/backend/src/utils/jwt/timestamps.ts @@ -0,0 +1,28 @@ +import { type TTimeFormat } from "@schemas/index.ts"; + +import epoch from "./epoch.ts"; +import stringToUnixSeconds from "./stringToUnixSeconds.ts"; + +function getIssuedAt(): number { + return epoch(new Date()); +} + +/** + * + * @param offset - смещение относительно "iat" в UNIX, в случае если "iat" не передано берётся текущее время. + * @param iat - время, в которое был выдан токен, в UNIX секундах. + */ +function getNotBefore(offset: TTimeFormat = "0s", iat?: number): number { + return (iat ?? getIssuedAt()) + stringToUnixSeconds(offset); +} + +/** + * + * @param offset - смещение относительно "nbf" в UNIX, в случае если "nbf" не передано берётся текущее время. + * @param nbf - время, до истечения которого токен не будет принят для установления новых аутентифицированных сеансов, в UNIX секундах. + */ +function getExpiration(offset: TTimeFormat = "1h", nbf?: number): number { + return (nbf ?? getNotBefore()) + stringToUnixSeconds(offset); +} + +export { getIssuedAt, getNotBefore, getExpiration }; diff --git a/backend/src/utils/jwt/types/TAuthPayload.ts b/backend/src/utils/jwt/types/TAuthPayload.ts new file mode 100644 index 00000000..39777a03 --- /dev/null +++ b/backend/src/utils/jwt/types/TAuthPayload.ts @@ -0,0 +1,42 @@ +import { RefreshJwtIdSchema, UserIdSchema } from "@schemas/index.ts"; +import z from "zod"; + +const DbTokenPayloadSchema = z.object({ + iss: z.string().min(1, "Must be 1 or more characters long"), + id: UserIdSchema, + ns: z.string().min(1, "Must be 1 or more characters long"), + db: z.string().min(1, "Must be 1 or more characters long"), + ac: z.string().min(1, "Must be 1 or more characters long"), + iat: z.number(), + exp: z.number() +}); + +type TDbTokenPayload = z.infer; + +const AccessPayloadSchema = z.object({ + iss: z.string().min(1, "Must be 1 or more characters long"), + id: UserIdSchema, + iat: z.number(), + exp: z.number() +}); + +type TAccessPayload = z.infer; + +const RefreshPayloadSchema = z.object({ + iss: z.string().min(1, "Must be 1 or more characters long"), + jti: RefreshJwtIdSchema, + userId: UserIdSchema, + iat: z.number(), + exp: z.number() +}); + +type TRefreshPayload = z.infer; + +export { + AccessPayloadSchema, + type TAccessPayload, + RefreshPayloadSchema, + type TRefreshPayload, + DbTokenPayloadSchema, + type TDbTokenPayload +}; diff --git a/backend/src/utils/jwt/types/TTokens.ts b/backend/src/utils/jwt/types/TTokens.ts new file mode 100644 index 00000000..ddd46811 --- /dev/null +++ b/backend/src/utils/jwt/types/TTokens.ts @@ -0,0 +1,4 @@ +type TTokens = { accessToken: string; refreshToken: string }; +type TAllTokens = TTokens & { dbToken: string }; + +export type { TTokens, TAllTokens }; diff --git a/backend/src/utils/jwt/uuid.ts b/backend/src/utils/jwt/uuid.ts new file mode 100644 index 00000000..725ffa0a --- /dev/null +++ b/backend/src/utils/jwt/uuid.ts @@ -0,0 +1 @@ +export default (): string => crypto.randomUUID(); diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 00000000..f6e54ce6 --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,12 @@ +import { pino } from "pino"; + +const transport = pino.transport({ + target: "pino-pretty", + options: { + colorize: true + } +}); + +const logger = pino(transport); + +export default logger; diff --git a/backend/src/utils/omitUndefined.ts b/backend/src/utils/omitUndefined.ts new file mode 100644 index 00000000..eac25a5f --- /dev/null +++ b/backend/src/utils/omitUndefined.ts @@ -0,0 +1,7 @@ +export default (obj: T) => { + const newObj = Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined) + ) as { [key: string]: any }; + + return newObj; +}; diff --git a/backend/src/utils/parseConfig.ts b/backend/src/utils/parseConfig.ts new file mode 100644 index 00000000..9b98c0a4 --- /dev/null +++ b/backend/src/utils/parseConfig.ts @@ -0,0 +1,9 @@ +import { type ZodSchema } from "zod"; + +export const parseConfig = (configObj: Record, configSchema: ZodSchema) => { + const parseResult = configSchema.safeParse(configObj); + + if (!parseResult.success) throw parseResult.error; + + return parseResult.data; +}; diff --git a/swaggerFlippo.yaml b/backend/swaggerFlippo.yaml similarity index 100% rename from swaggerFlippo.yaml rename to backend/swaggerFlippo.yaml diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 00000000..f5950e3a --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "@flippo/tsconfig", + "compilerOptions": { + "types": ["node"], + "lib": [ + "ESNext", + ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, + + /* Modules */ + "module": "NodeNext" /* Specify what module code is generated. */, + "moduleResolution": "NodeNext" /* Specify how TypeScript looks up a file from a given module specifier. */, + "paths": { + "@utils/*": ["src/utils/*"], + "@controller/*": ["src/controller/*"], + "@jwt/*": ["src/utils/jwt/*"], + "@middleware/*": ["src/middleware/*"], + "@dao/*": ["src/dao/*"], + "@schemas/*": ["src/schemas/*"], + "@service/*": ["src/service/*"] + } /* Specify a set of entries that re-map imports to additional lookup locations. */, + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + }, + "include": ["./src/**/*.ts", "register.js", "loader.js", "eslint.config.mjs"], + "exclude": ["node_modules"], + "ts-node": { + "esm": true, + "respawn": true, + "transpileOnly": true, + "compilerOptions": { + "module": "NodeNext" + } + } +} diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index e70680ab..00000000 --- a/eslint.config.js +++ /dev/null @@ -1,162 +0,0 @@ -import { fixupPluginRules } from '@eslint/compat'; -import { FlatCompat } from '@eslint/eslintrc'; -import pluginJs from '@eslint/js'; -import pluginEffector from 'eslint-plugin-effector'; -import pluginPerfectionist from 'eslint-plugin-perfectionist'; -import pluginPrettier from 'eslint-plugin-prettier'; -import pluginReact from 'eslint-plugin-react'; -import pluginReactHooks from 'eslint-plugin-react-hooks'; -import pluginStorybook from 'eslint-plugin-storybook'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; - -const flatCompat = new FlatCompat({ - baseDirectory: import.meta.dirname, - resolvePluginsRelativeTo: import.meta.dirname -}); - -/** @type {import('eslint').Linter.Config[]} */ -export default [ - ...tseslint.configs.recommendedTypeChecked, - pluginJs.configs.recommended, - pluginReact.configs.flat?.recommended, - ...fixupPluginRules(flatCompat.extends('plugin:react-hooks/recommended')), - ...pluginStorybook.configs['flat/recommended'], - pluginPerfectionist.configs['recommended-natural'], - { - files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], - languageOptions: { - globals: { ...globals.browser, NodeJS: true }, - parser: tseslint.parser, - parserOptions: { - project: ['./tsconfig.json', './tsconfig.app.json'], - projectService: { - allowDefaultProject: ['eslint.config.js', 'prettier.config.js', 'stylelint.config.js'] - }, - tsconfigRootDir: import.meta.dirname - } - }, - - plugins: { - effector: pluginEffector, - prettier: pluginPrettier, - react: pluginReact, - 'react-hooks': pluginReactHooks, - storybook: pluginStorybook - }, - - rules: { - ...pluginEffector.configs.recommended.rules, - '@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'array' }], - '@typescript-eslint/consistent-type-exports': 'error', - '@typescript-eslint/consistent-type-imports': [ - 'error', - { - fixStyle: 'inline-type-imports', - prefer: 'type-imports' - } - ], - '@typescript-eslint/default-param-last': 'warn', - '@typescript-eslint/naming-convention': [ - 'warn', - { - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - selector: 'variable' - }, - { - format: ['PascalCase'], - selector: 'typeLike' - } - ], - '@typescript-eslint/no-array-delete': 'warn', - '@typescript-eslint/no-duplicate-enum-values': 'error', - '@typescript-eslint/no-duplicate-type-constituents': 'error', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-namespace': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unused-expressions': [ - 'error', - { - allowShortCircuit: true, - allowTaggedTemplates: false, - allowTernary: true - } - ], - '@typescript-eslint/no-unused-vars': 'warn', - '@typescript-eslint/prefer-promise-reject-errors': 'off', - '@typescript-eslint/typedef': [ - 'warn', - { - memberVariableDeclaration: true, - parameter: true, - propertyDeclaration: true - } - ], - 'default-param-last': 'off', - 'effector/no-duplicate-on': 'off', - 'no-redeclare': 'off', - 'no-unused-expressions': 'off', - 'no-unused-vars': 'off', - 'perfectionist/sort-objects': [ - 'warn', - { ignorePattern: ['sample', 'split', 'attache', 'condition', 'chainRoute'] } - ], - 'prettier/prettier': ['warn', { endOfLine: 'auto' }, { usePrettierrc: true }], - 'react/function-component-definition': [ - 1, - { - namedComponents: 'function-declaration', - unnamedComponents: 'arrow-function' - } - ], - 'react/hook-use-state': 'off', - 'react/jsx-boolean-value': [1, 'never'], - 'react/jsx-closing-bracket-location': 1, - 'react/jsx-curly-brace-presence': [1, 'always'], - 'react/jsx-filename-extension': [1, { extensions: ['.ts', '.tsx'] }], - 'react/jsx-fragments': [1, 'syntax'], - 'react/jsx-indent-props': [1, 2], - 'react/jsx-no-duplicate-props': [1, { ignoreCase: true }], - 'react/jsx-no-leaked-render': [1, { validStrategies: ['ternary'] }], - 'react/jsx-wrap-multilines': [ - 1, - { - arrow: 'parens-new-line', - assignment: 'parens-new-line', - condition: 'parens-new-line', - declaration: 'parens-new-line', - logical: 'parens-new-line', - prop: 'parens-new-line', - return: 'parens-new-line' - } - ], - 'react/react-in-jsx-scope': 0 - }, - settings: { - react: { - version: 'detect' - } - } - }, - { - files: ['**/*.js'], - languageOptions: { - globals: globals.browser, - parser: tseslint.parser, - parserOptions: { - project: ['./tsconfig.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname - } - }, - ...tseslint.configs.disableTypeChecked - }, - { - files: ['**/tests/*.testplane.js', '**/tests/*.testplane.ts'], - rules: { - '@typescript-eslint/await-thenable': 'off', - 'no-undef': 'off' - } - } -]; diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..5e2c2114 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1 @@ +node_module \ No newline at end of file diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 00000000..21256661 --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1 @@ +* text=auto \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..55729a50 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.pnpm-store + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*storybook.log +testplane-report +.testplane \ No newline at end of file diff --git a/.storybook/customTheme.ts b/frontend/.storybook/customTheme.ts similarity index 100% rename from .storybook/customTheme.ts rename to frontend/.storybook/customTheme.ts diff --git a/.storybook/main.ts b/frontend/.storybook/main.ts similarity index 100% rename from .storybook/main.ts rename to frontend/.storybook/main.ts diff --git a/.storybook/manager.ts b/frontend/.storybook/manager.ts similarity index 100% rename from .storybook/manager.ts rename to frontend/.storybook/manager.ts diff --git a/.storybook/preview-head.html b/frontend/.storybook/preview-head.html similarity index 100% rename from .storybook/preview-head.html rename to frontend/.storybook/preview-head.html diff --git a/.storybook/preview.tsx b/frontend/.storybook/preview.tsx similarity index 78% rename from .storybook/preview.tsx rename to frontend/.storybook/preview.tsx index c32ec9e9..946a6640 100644 --- a/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -1,24 +1,25 @@ import type { Preview } from '@storybook/react'; -import '../src/settings/styles/global.scss'; - import { router } from '@settings/routing'; import { ToastContainer } from '@widgets/ToastNotification'; + import { RouterProvider } from 'atomic-router-react'; import { Suspense } from 'react'; - import { $i18n, initI18next } from '../src/settings/i18next/i18next.config'; +import '../src/settings/styles/global.scss'; + +import './storybook.scss'; initI18next(); -// eslint-disable-next-line effector/no-getState + const i18n = $i18n.getState(); const preview: Preview = { - decorators: (Story) => ( - - + decorators: Story => ( + + - + ), @@ -36,7 +37,7 @@ const preview: Preview = { date: /Date$/i } }, - i18n: i18n, + i18n, layout: 'centered' }, tags: ['autodocs'] diff --git a/frontend/.storybook/storybook.scss b/frontend/.storybook/storybook.scss new file mode 100644 index 00000000..5a8dd189 --- /dev/null +++ b/frontend/.storybook/storybook.scss @@ -0,0 +1,3 @@ +body { + overflow: scroll; +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..59e616ac --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20 AS base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN npm install -g pnpm@10.7.0 + +# frontend-base +FROM base AS frontend-base +WORKDIR /flippo/frontend + +COPY . . +RUN pnpm install + +# frontend-dev +FROM frontend-base AS frontend-dev +CMD ["pnpm", "run", "dev"] + +# frontend-build +FROM frontend-base AS frontend-build +RUN pnpm run build + +# prod +FROM base AS frontend-prod +COPY --from=frontend-build /flippo/frontend/build . diff --git a/frontend/LICENSE b/frontend/LICENSE new file mode 100644 index 00000000..bdb7c748 --- /dev/null +++ b/frontend/LICENSE @@ -0,0 +1,19 @@ +Limited Read-Only License + +Copyright (c) 2024 Gorochkin Egor Romanovich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software, to view and read the software source code (the "Software") for +the sole purpose of assessing the competence and skills of the author. + +THE SOFTWARE IS PROVIDED FOR VIEWING PURPOSES ONLY AND MAY NOT BE USED, +COPIED, MODIFIED, MERGED, PUBLISHED, DISTRIBUTED, SUBLICENSED, AND/OR SOLD +WITHOUT THE EXPRESS PERMISSION OF THE AUTHOR. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHOR OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..9f3821be --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,68 @@ +# Flippo + +## 1 О проекте + +Проект направлен на создание удобного и функционального инструмента для обучения. +Реализация проекта позволит пользователям эффективно организовывать учебный процесс, улучшая свои знания и навыки. + +**Цель проекта:** Создание веб-приложения для составления, хранения и использования обучающих карточек, +предназначенных для улучшения запоминания информации и подготовки к экзаменам, тестам и другим образовательным целям. + +## 2 Целевая аудитория + +- Ученики и студенты; +- Преподаватели и репетиторы; +- Люди, занимающиеся самообразованием; +- Специалисты, готовящиеся к профессиональным экзаменам и сертификациям. + +## 3 Основные функции и возможности + +1. Регистрация и авторизация пользователей: + +- Регистрация через email; +- Вход через социальные сети (Google, VK, Yandex ID). + +2. Профиль пользователя: + +- Редактирование личных данных. + +3. Создание и управление карточками: + +- Создание карточек с вопросами и ответами; +- Возможность добавления изображений и ссылок; +- Организация карточек по папкам. + +4. Обучение с помощью карточек: + +- Режим изучения: отображение вопросов и скрытие ответов до нажатия; + +5. Импорт и экспорт карточек: + +- Импорт карточек из CSV; +- Экспорт созданных карточек; +- Копирование наборов или отдельных карточек по ссылке. + +6. Сообщество и обмен: + +- Возможность делиться коллекциями карточек с другими пользователями; +- Поиск и подписка на коллекции других пользователей; +- Возможность совместного редактирования набора карточек. + +7. Дополнительные функции: + +- Синхронизация данных между устройствами; +- Поддержка многоязычности. + +## 4 Технические требования + +1. Веб-технологии: + +- Frontend: HTML5, SCSS, React, Effector, axios, storybook, atomic-router, i18next, motion/react(framer-motion); +- Backend: NodeJS, Express, jose, nodemailer; +- База данных: SurrealDB; +- Общие: TypeScript, Zod. + +2. Безопасность: + +- SSL-сертификат для безопасного соединения; +- JWT & Refresh token. diff --git a/frontend/docker-compose.yaml b/frontend/docker-compose.yaml new file mode 100644 index 00000000..a3fbe29e --- /dev/null +++ b/frontend/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + frontend: + image: flippo-frontend + container_name: flippo-frontend + build: + context: ./ + target: frontend-dev + ports: [3030:3030] + develop: + watch: + - path: ./ + action: sync + target: /flippo/frontend + - path: ./package.json + action: rebuild diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..540ccf87 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,25 @@ +import { eslintReactConfig } from '@flippo/eslint'; + +import pluginEffector from 'eslint-plugin-effector'; +import pluginStorybook from 'eslint-plugin-storybook'; + +/** @type {import('eslint').Linter.Config[]} */ +export default eslintReactConfig(import.meta.dirname).append( + ...pluginStorybook.configs['flat/recommended'], + { + plugins: { + effector: pluginEffector + }, + rules: { + ...pluginEffector.configs.react.rules, + 'react-refresh/only-export-components': 'off' + } + }, + { + files: ['**/tests/*.testplane.js', '**/tests/*.testplane.ts'], + rules: { + '@typescript-eslint/await-thenable': 'off', + 'no-undef': 'off' + } + } +); diff --git a/index.html b/frontend/index.html similarity index 100% rename from index.html rename to frontend/index.html diff --git a/locales/en/auth.json b/frontend/locales/en/auth.json similarity index 100% rename from locales/en/auth.json rename to frontend/locales/en/auth.json diff --git a/locales/en/header.json b/frontend/locales/en/header.json similarity index 100% rename from locales/en/header.json rename to frontend/locales/en/header.json diff --git a/locales/en/modal.json b/frontend/locales/en/modal.json similarity index 100% rename from locales/en/modal.json rename to frontend/locales/en/modal.json diff --git a/locales/en/notfound.json b/frontend/locales/en/notfound.json similarity index 100% rename from locales/en/notfound.json rename to frontend/locales/en/notfound.json diff --git a/locales/en/translation.json b/frontend/locales/en/translation.json similarity index 100% rename from locales/en/translation.json rename to frontend/locales/en/translation.json diff --git a/locales/ru/auth.json b/frontend/locales/ru/auth.json similarity index 100% rename from locales/ru/auth.json rename to frontend/locales/ru/auth.json diff --git a/locales/ru/header.json b/frontend/locales/ru/header.json similarity index 100% rename from locales/ru/header.json rename to frontend/locales/ru/header.json diff --git a/locales/ru/modal.json b/frontend/locales/ru/modal.json similarity index 100% rename from locales/ru/modal.json rename to frontend/locales/ru/modal.json diff --git a/locales/ru/notfound.json b/frontend/locales/ru/notfound.json similarity index 100% rename from locales/ru/notfound.json rename to frontend/locales/ru/notfound.json diff --git a/locales/ru/translation.json b/frontend/locales/ru/translation.json similarity index 100% rename from locales/ru/translation.json rename to frontend/locales/ru/translation.json diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..344ae654 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,92 @@ +{ + "name": "@flippo/frontend", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "style:prettier-write": "prettier --write '**/*.{js,ts,jsx,tsx,cts,cjs}'", + "style:prettier": "prettier --check '**/*.{js,ts,jsx,tsx,cts,cjs}'", + "style:lint": "eslint '**/*.{js,ts,jsx,tsx,cts,cjs}'", + "preview": "vite preview", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "tests:unit": "npx testplane -c testplane.config.ts", + "tests:screenshots": "npx testplane gui --storybook --port 6000 -c testplane.storybook.config.cts", + "tests:e2e": "" + }, + "dependencies": { + "@farfetched/core": "catalog:", + "@withease/i18next": "catalog:", + "@withease/web-api": "catalog:", + "atomic-router": "catalog:", + "atomic-router-react": "catalog:", + "axios": "catalog:", + "clsx": "catalog:", + "effector": "catalog:", + "effector-react": "catalog:", + "framer-motion": "catalog:", + "history": "catalog:", + "i18next": "catalog:", + "i18next-browser-languagedetector": "catalog:", + "i18next-http-backend": "catalog:", + "js-cookie": "catalog:", + "patronum": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-i18next": "catalog:", + "react-use-measure": "catalog:", + "surrealdb": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@chromatic-com/storybook": "catalog:", + "@eslint-react/eslint-plugin": "catalog:", + "@flippo/eslint": "workspace:*", + "@flippo/tsconfig": "workspace:*", + "@storybook/addon-essentials": "catalog:", + "@storybook/addon-interactions": "catalog:", + "@storybook/addon-onboarding": "catalog:", + "@storybook/blocks": "catalog:", + "@storybook/manager-api": "catalog:", + "@storybook/react": "catalog:", + "@storybook/react-vite": "catalog:", + "@storybook/test": "catalog:", + "@storybook/theming": "catalog:", + "@testing-library/webdriverio": "catalog:", + "@testplane/global-hook": "catalog:", + "@testplane/storybook": "catalog:", + "@testplane/test-filter": "catalog:", + "@testplane/url-decorator": "catalog:", + "@types/eslint": "catalog:", + "@types/js-cookie": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "eslint": "catalog:", + "eslint-plugin-effector": "catalog:", + "eslint-plugin-format": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-react-refresh": "catalog:", + "eslint-plugin-storybook": "catalog:", + "html-reporter": "catalog:", + "i18next-hmr": "catalog:", + "postcss": "catalog:", + "postcss-flexbugs-fixes": "catalog:", + "postcss-preset-env": "catalog:", + "sass-embedded": "catalog:", + "storybook": "catalog:", + "storybook-react-i18next": "catalog:", + "stylelint": "catalog:", + "testplane": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + }, + "eslintConfig": { + "extends": [ + "plugin:storybook/recommended" + ] + } +} diff --git a/public/profile_avatar.webp b/frontend/public/profile_avatar.webp similarity index 100% rename from public/profile_avatar.webp rename to frontend/public/profile_avatar.webp diff --git a/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx similarity index 100% rename from src/app/auth/callback/page.tsx rename to frontend/src/app/auth/callback/page.tsx diff --git a/src/app/auth/page.tsx b/frontend/src/app/auth/page.tsx similarity index 100% rename from src/app/auth/page.tsx rename to frontend/src/app/auth/page.tsx diff --git a/src/app/index.tsx b/frontend/src/app/index.tsx similarity index 81% rename from src/app/index.tsx rename to frontend/src/app/index.tsx index 8cea3d0e..eedb204e 100644 --- a/src/app/index.tsx +++ b/frontend/src/app/index.tsx @@ -1,4 +1,3 @@ -import '@settings/styles/global.scss'; import { NotFoundPage } from '@pages'; import { authAnonymousRoute, @@ -11,16 +10,18 @@ import { communityRoute, mainOptionalRoute, mainRoute, + setOptionalRoute, settingsAuthorizedRoute, settingsRoute } from '@settings/routing'; import { createRoutesView, createRouteView } from 'atomic-router-react'; import { createRoot } from 'react-dom/client'; +import AuthCallbackPage from './auth/callback/page'; -import { default as AuthCallbackPage } from './auth/callback/page'; -import { default as AuthPage } from './auth/page'; +import AuthPage from './auth/page'; import { InitApp } from './init/index'; import { BaseLayout } from './layouts'; +import '@settings/styles/global.scss'; const RoutesView = createRoutesView({ otherwise: () => , @@ -45,6 +46,17 @@ const RoutesView = createRoutesView({ route: settingsRoute, view: createRouteView({ route: settingsAuthorizedRoute, view: () =>

{'Settings'}

}) }, + { + layout: BaseLayout, + route: setOptionalRoute, + view: () =>

{'Set'}

+ }, + { + layout: BaseLayout, + route: settingsRoute, + view: createRouteView({ route: settingsAuthorizedRoute, view: () =>

{'Settings'}

}) + }, + { route: authRoute, view: createRouteView({ route: authAnonymousRoute, view: () => }) }, { route: callbackRoute, view: createRouteView({ route: callbackAnonymousRoute, view: () => }) } ] diff --git a/src/app/init/InitApp.tsx b/frontend/src/app/init/InitApp.tsx similarity index 77% rename from src/app/init/InitApp.tsx rename to frontend/src/app/init/InitApp.tsx index 7c7fae86..40534de1 100644 --- a/src/app/init/InitApp.tsx +++ b/frontend/src/app/init/InitApp.tsx @@ -1,11 +1,12 @@ +import type { i18n } from 'i18next'; +import type { PropsWithChildren } from 'react'; import { StartPage } from '@pages'; import { $i18n } from '@settings/i18next'; import { router } from '@settings/routing'; import { ToastContainer } from '@widgets/ToastNotification'; import { RouterProvider } from 'atomic-router-react'; import { useUnit } from 'effector-react'; -import { type i18n } from 'i18next'; -import { type PropsWithChildren, useEffect } from 'react'; +import { useEffect } from 'react'; import { I18nextProvider } from 'react-i18next'; import { $initialized, initApp, teardownApp } from './models/app.model'; @@ -27,10 +28,10 @@ function InitApp(props: PropsWithChildren) { } return ( - - + + {children} - + ); diff --git a/src/app/init/index.ts b/frontend/src/app/init/index.ts similarity index 100% rename from src/app/init/index.ts rename to frontend/src/app/init/index.ts diff --git a/frontend/src/app/init/models/app.model.ts b/frontend/src/app/init/models/app.model.ts new file mode 100644 index 00000000..40c9216f --- /dev/null +++ b/frontend/src/app/init/models/app.model.ts @@ -0,0 +1,35 @@ +import { $i18n, $isReady, initI18next } from '@settings/i18next'; +import { initRouter } from '@settings/routing'; +import { sessionAuth } from '@settings/session'; +import { dbConnectFx as originDbConnectFx, dbDisconnectFx as originDbDisconnectFx } from '@settings/surreal'; +import { attach, createEffect, createEvent, sample } from 'effector'; +import { and, not } from 'patronum'; + +const dbConnectFx = attach({ effect: originDbConnectFx }); +const dbDisconnectFx = attach({ effect: originDbDisconnectFx }); + +// #region init +export const initApp = createEvent(); +const initAppFx = createEffect(async () => { + await Promise.allSettled([dbConnectFx]); +}); + +const $i18nNotNull = $i18n.map(instance => !!instance); +const $initAppProcess = initAppFx.pending; +export const $initialized = and(not($initAppProcess), $isReady, $i18nNotNull); + +sample({ + clock: initApp, + target: [initI18next, initAppFx, initRouter, sessionAuth] +}); +// #endregion + +// #region teardown +export const teardownApp = createEvent(); +const teardownI18nextFx = createEffect(() => {}); + +sample({ + clock: teardownApp, + target: [teardownI18nextFx, dbDisconnectFx] +}); +// #endregion diff --git a/src/app/layouts/BaseLayout/BaseLayout.module.scss b/frontend/src/app/layouts/BaseLayout/BaseLayout.module.scss similarity index 100% rename from src/app/layouts/BaseLayout/BaseLayout.module.scss rename to frontend/src/app/layouts/BaseLayout/BaseLayout.module.scss diff --git a/src/app/layouts/BaseLayout/BaseLayout.tsx b/frontend/src/app/layouts/BaseLayout/BaseLayout.tsx similarity index 77% rename from src/app/layouts/BaseLayout/BaseLayout.tsx rename to frontend/src/app/layouts/BaseLayout/BaseLayout.tsx index e5392d20..64a2cc7a 100644 --- a/src/app/layouts/BaseLayout/BaseLayout.tsx +++ b/frontend/src/app/layouts/BaseLayout/BaseLayout.tsx @@ -1,11 +1,11 @@ +import type { ReactNode } from 'react'; import Header from '@modules/header'; -import { type ReactNode } from 'react'; import st from './BaseLayout.module.scss'; function BaseLayout({ children }: { children: ReactNode }) { return ( -
+
{children}
diff --git a/src/app/layouts/index.ts b/frontend/src/app/layouts/index.ts similarity index 100% rename from src/app/layouts/index.ts rename to frontend/src/app/layouts/index.ts diff --git a/src/modules/auth/api/index.ts b/frontend/src/modules/auth/api/index.ts similarity index 95% rename from src/modules/auth/api/index.ts rename to frontend/src/modules/auth/api/index.ts index 28d81259..17745bf5 100644 --- a/src/modules/auth/api/index.ts +++ b/frontend/src/modules/auth/api/index.ts @@ -1,4 +1,4 @@ -import { type TSession } from '@settings/session'; +import type { TSession } from '@settings/session'; import * as appApi from '@shared/api'; import { attach, createEffect } from 'effector'; @@ -6,7 +6,7 @@ const requestFx = attach({ effect: appApi.requestFx }); export const requestVerificationCodeFx = createEffect((email: string) => { return requestFx({ - body: { email: email }, + body: { email }, method: 'POST', options: { headers: { 'Content-Type': 'application/json' }, diff --git a/src/modules/auth/constant/index.tsx b/frontend/src/modules/auth/constant/index.tsx similarity index 66% rename from src/modules/auth/constant/index.tsx rename to frontend/src/modules/auth/constant/index.tsx index 917c9404..5a170295 100644 --- a/src/modules/auth/constant/index.tsx +++ b/frontend/src/modules/auth/constant/index.tsx @@ -1,16 +1,14 @@ import type { TAuthProvider } from '@shared/query'; import type { ReactElement } from 'react'; -import { GoogleIcon } from '@shared/icons'; -import { VKIcon } from '@shared/icons'; -import { YandexIcon } from '@shared/icons'; +import { GoogleIcon, VKIcon, YandexIcon } from '@shared/icons'; const SIZE = { height: 24, width: 24 }; export const PROVIDER_ICONS: { [key in TAuthProvider]: ReactElement } = { - google: , - vkontakte: , - yandexID: + google: , + vkontakte: , + yandexID: }; export const PROVIDER_NAMES: { [key in TAuthProvider]: string } = { diff --git a/src/modules/auth/index.ts b/frontend/src/modules/auth/index.ts similarity index 100% rename from src/modules/auth/index.ts rename to frontend/src/modules/auth/index.ts diff --git a/src/modules/auth/models/auth.model.ts b/frontend/src/modules/auth/models/auth.model.ts similarity index 92% rename from src/modules/auth/models/auth.model.ts rename to frontend/src/modules/auth/models/auth.model.ts index 851c7cc7..d4a73927 100644 --- a/src/modules/auth/models/auth.model.ts +++ b/frontend/src/modules/auth/models/auth.model.ts @@ -1,8 +1,8 @@ +import type { TAuthContent } from '../types/TAuthContent'; import { authRoute } from '@settings/routing'; import { createEvent, createStore, sample } from 'effector'; -import { reset } from 'patronum'; -import { type TAuthContent } from '../types/TAuthContent'; +import { reset } from 'patronum'; import { placeBeforeAuthorization, rememberPlaceBeforeAuthorization } from './placeBefore.model'; // #region model description "auth modal" @@ -24,7 +24,7 @@ export const $authEmail = createStore(''); // #region of logic "auth modal" reset({ clock: authRoute.closed, target: [$authEmail, $authContent] }); -$authContent.on(authTo, (value) => value); +$authContent.on(authTo, value => value); $authContent.on(authToAuthorizationMethod, () => 'authorizationMethod'); $authContent.on(authToVerificationCode, () => 'verificationCode'); $authContent.on(authToInputUsername, () => 'inputUsername'); diff --git a/src/modules/auth/models/authorizationMethod.model.ts b/frontend/src/modules/auth/models/authorizationMethod.model.ts similarity index 91% rename from src/modules/auth/models/authorizationMethod.model.ts rename to frontend/src/modules/auth/models/authorizationMethod.model.ts index 42ea6cae..5b628d00 100644 --- a/src/modules/auth/models/authorizationMethod.model.ts +++ b/frontend/src/modules/auth/models/authorizationMethod.model.ts @@ -1,8 +1,10 @@ +import type { TAuthProvider } from '@shared/query'; +import type { TTranslationOptions } from '@widgets/ToastNotification'; +import type { HistoryPushParams } from 'atomic-router'; import { router } from '@settings/routing'; import { createFormInput } from '@shared/factories'; -import { getOAuthUrl, type TAuthProvider } from '@shared/query'; -import { displayRequestError, type TTranslationOptions } from '@widgets/ToastNotification'; -import { type HistoryPushParams } from 'atomic-router'; +import { getOAuthUrl } from '@shared/query'; +import { displayRequestError } from '@widgets/ToastNotification'; import { attach, createEffect, createEvent, createStore, sample } from 'effector'; import { and, not, or, reset } from 'patronum'; import { z } from 'zod'; diff --git a/src/modules/auth/models/inputUsername.model.ts b/frontend/src/modules/auth/models/inputUsername.model.ts similarity index 88% rename from src/modules/auth/models/inputUsername.model.ts rename to frontend/src/modules/auth/models/inputUsername.model.ts index 99077e11..0eaabe67 100644 --- a/src/modules/auth/models/inputUsername.model.ts +++ b/frontend/src/modules/auth/models/inputUsername.model.ts @@ -1,7 +1,8 @@ +import type { TTranslationOptions } from '@widgets/ToastNotification'; import { authRoute } from '@settings/routing'; -import { sessionChanged } from '@settings/session'; +import { sessionChangedFx } from '@settings/session'; import { createFormInput } from '@shared/factories'; -import { displayRequestError, displayRequestSuccess, type TTranslationOptions } from '@widgets/ToastNotification'; +import { displayRequestError, displayRequestSuccess } from '@widgets/ToastNotification'; import { attach, createEvent, sample } from 'effector'; import { not, reset } from 'patronum'; import { z } from 'zod'; @@ -56,7 +57,7 @@ sample({ sample({ clock: signUpWithEmailFx.doneData, - target: [sessionChanged, authClose] + target: [sessionChangedFx, authClose] }); sample({ diff --git a/src/modules/auth/models/oauthCallback.model.ts b/frontend/src/modules/auth/models/oauthCallback.model.ts similarity index 97% rename from src/modules/auth/models/oauthCallback.model.ts rename to frontend/src/modules/auth/models/oauthCallback.model.ts index 5fcb6553..be9fcfac 100644 --- a/src/modules/auth/models/oauthCallback.model.ts +++ b/frontend/src/modules/auth/models/oauthCallback.model.ts @@ -1,7 +1,7 @@ +import type { NavigateParams } from 'atomic-router'; import { authRoute, callbackRoute, mainRoute } from '@settings/routing'; import { $session, sessionValidateFx as sideSessionValidateFx } from '@settings/session'; import * as sessionApi from '@shared/api'; -import { type NavigateParams } from 'atomic-router'; import { attach, createEvent, createStore, sample } from 'effector'; import { placeBeforeAuthorization } from './placeBefore.model'; diff --git a/src/modules/auth/models/placeBefore.model.ts b/frontend/src/modules/auth/models/placeBefore.model.ts similarity index 84% rename from src/modules/auth/models/placeBefore.model.ts rename to frontend/src/modules/auth/models/placeBefore.model.ts index bd8e7538..7806d73f 100644 --- a/src/modules/auth/models/placeBefore.model.ts +++ b/frontend/src/modules/auth/models/placeBefore.model.ts @@ -1,5 +1,5 @@ +import type { HistoryPushParams } from 'atomic-router'; import { router } from '@settings/routing'; -import { type HistoryPushParams } from 'atomic-router'; import { createEffect, createEvent, sample } from 'effector'; const CALLBACK_URL_NAME = 'callbackUrl'; @@ -30,6 +30,6 @@ sample({ sample({ clock: getPlaceFx.doneData, - fn: (url) => ({ path: url, method: 'replace' }) as Omit, + fn: url => ({ path: url, method: 'replace' }) as Omit, target: router.push }); diff --git a/src/modules/auth/models/verificationCode.model.ts b/frontend/src/modules/auth/models/verificationCode.model.ts similarity index 90% rename from src/modules/auth/models/verificationCode.model.ts rename to frontend/src/modules/auth/models/verificationCode.model.ts index 7bd50add..f04cd473 100644 --- a/src/modules/auth/models/verificationCode.model.ts +++ b/frontend/src/modules/auth/models/verificationCode.model.ts @@ -1,13 +1,15 @@ +import type { TVerifyInputHandler } from '@shared/ui/Input'; +import type { TTranslationOptions } from '@widgets/ToastNotification'; +import type { TEmailProvider } from '../utils/getEmailProvider'; import { $t } from '@settings/i18next'; import * as sessionApi from '@settings/session'; -import { type TVerifyInputHandler } from '@shared/ui/Input'; -import { displayRequestError, displayRequestSuccess, type TTranslationOptions } from '@widgets/ToastNotification'; +import { displayRequestError, displayRequestSuccess } from '@widgets/ToastNotification'; import { attach, createEffect, createEvent, createStore, sample, split } from 'effector'; -import { and, not, or, reset } from 'patronum'; +import { and, not, or, reset } from 'patronum'; import * as authApi from '../api'; import { TIMEOUT_IN_MILLISECONDS, TIMEOUT_IN_MINUTES } from '../constant'; -import { getEmailProvider, type TEmailProvider } from '../utils/getEmailProvider'; +import { getEmailProvider } from '../utils/getEmailProvider'; import { $authEmail, authClose, authToAuthorizationMethod, authToInputUsername } from './auth.model'; const sessionValidateFx = attach({ effect: sessionApi.sessionValidateFx }); @@ -20,7 +22,8 @@ export const $truncatedEmail = $authEmail.map((email) => { const maxEmailLength = 50; const reserveForDomainLength = 20; - if (email.length < maxEmailLength) return email; + if (email.length < maxEmailLength) + return email; const [local, domain] = email.split('@'); @@ -50,7 +53,8 @@ export const inputRefChanged = createEvent(); export const inputFocused = createEvent(); export const inputFocusedFx = createEffect((input) => { - if (input) input.focus(); + if (input) + input.focus(); }); export const $resendCodeDisabled = createStore(false); @@ -91,7 +95,8 @@ const timerStartFx = createEffect(() => { const timerClear = createEvent(); const timerClearFx = createEffect((timer) => { - if (timer) clearInterval(timer); + if (timer) + clearInterval(timer); }); // #endregion @@ -137,7 +142,7 @@ sample({ sample({ clock: timerStart, filter: not($timer), fn: () => true, target: [timerStartFx, $resendCodeDisabled] }); sample({ clock: timerEnd, - filter: $timer.map((timer) => !!timer), + filter: $timer.map(timer => !!timer), fn: () => false, target: [timerClear, $resendCodeDisabled] }); @@ -145,7 +150,7 @@ sample({ sample({ clock: timerClear, source: $timer, - filter: $timer.map((state) => !!state), + filter: $timer.map(state => !!state), target: timerClearFx }); @@ -163,13 +168,13 @@ sample({ clock: checkVerificationCodeFx.fail, target: inputFocused }); sample({ clock: inputFocused, source: $inputRef, - filter: $inputRef.map((inputRef) => !!inputRef), + filter: $inputRef.map(inputRef => !!inputRef), target: inputFocusedFx }); split({ source: checkVerificationCodeFx.failData, - match: (value) => (['400', '410'].includes(value) ? 'verificationCodeError' : 'error'), + match: value => (['400', '410'].includes(value) ? 'verificationCodeError' : 'error'), cases: { verificationCodeError: verificationCodeErrorChanged, error: [throwAnError, authToAuthorizationMethod] @@ -208,7 +213,7 @@ sample({ split({ source: signInWithEmailFx.failData, - match: (value) => (value === '401' ? 'unauthorized' : 'error'), + match: value => (value === '401' ? 'unauthorized' : 'error'), cases: { unauthorized: authToInputUsername, error: [throwAnError, authToAuthorizationMethod] diff --git a/src/modules/auth/story/Auth.stories.tsx b/frontend/src/modules/auth/story/Auth.stories.tsx similarity index 70% rename from src/modules/auth/story/Auth.stories.tsx rename to frontend/src/modules/auth/story/Auth.stories.tsx index 4186736e..b67d247f 100644 --- a/src/modules/auth/story/Auth.stories.tsx +++ b/frontend/src/modules/auth/story/Auth.stories.tsx @@ -1,17 +1,18 @@ +import type { TStoryCombineProps } from '@shared/ui/StoryCombine'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { TAuthContent } from '../types/TAuthContent'; import { Button } from '@shared/ui/Button'; -import { StoryCombine, type TStoryCombineProps } from '@shared/ui/StoryCombine'; -import { type Meta, type StoryObj } from '@storybook/react'; +import { StoryCombine } from '@shared/ui/StoryCombine'; import { createEvent, fork, sample, scopeBind } from 'effector'; -import { Provider } from 'effector-react'; +import { Provider, useUnit } from 'effector-react'; import { $authContent, $authEmail } from '../models/auth.model'; import { $checkVerificationCodeProcess, timerStart, verificationCodeErrorChanged } from '../models/verificationCode.model'; -import { type TAuthContent } from '../types/TAuthContent'; -import { default as Auth } from '../view/ui/Auth/Auth'; +import Auth from '../view/ui/Auth/Auth'; import st from './Decorator.module.scss'; const meta: Meta = { @@ -31,15 +32,11 @@ export default meta; type AuthStory = StoryObj; export const AuthMethod: AuthStory = { - render: () => + render: () => }; export const AuthCode: AuthStory = { - render: () => -}; - -export const AuthCodeStoryCombine: AuthStory = { - render: () => + render: () => }; const CODE_GROUPS: TStoryCombineProps<{ @@ -85,8 +82,12 @@ const CODE_GROUPS: TStoryCombineProps<{ ] }; +export const AuthCodeStoryCombine: AuthStory = { + render: () => +}; + export const AuthUsername: AuthStory = { - render: () => + render: () => }; export const AuthSwap: AuthStory = { @@ -110,21 +111,21 @@ export const AuthSwap: AuthStory = { }); return ( - -
-
-
+ +
+
+
-
- - -
@@ -142,7 +143,7 @@ function WithVariant(props: { variant: TAuthContent }) { }); return ( - + ); @@ -150,6 +151,7 @@ function WithVariant(props: { variant: TAuthContent }) { function WithCode(props: { checking?: boolean; email: string; error?: null | string }) { const { checking = false, email, error = null } = props; + const [onVerificationCodeErrorChanged, onTimerStart] = useUnit([verificationCodeErrorChanged, timerStart]); const scopeStory = fork({ values: [ @@ -159,14 +161,14 @@ function WithCode(props: { checking?: boolean; email: string; error?: null | str ] }); - const errorBind = scopeBind(verificationCodeErrorChanged, { scope: scopeStory }); - const timerBind = scopeBind(timerStart, { scope: scopeStory }); + const errorBind = scopeBind(onVerificationCodeErrorChanged, { scope: scopeStory }); + const timerBind = scopeBind(onTimerStart, { scope: scopeStory }); errorBind(error); timerBind(); return ( - + ); diff --git a/src/modules/auth/story/AuthCallback.stories.tsx b/frontend/src/modules/auth/story/AuthCallback.stories.tsx similarity index 69% rename from src/modules/auth/story/AuthCallback.stories.tsx rename to frontend/src/modules/auth/story/AuthCallback.stories.tsx index 02c957d3..d5fef25e 100644 --- a/src/modules/auth/story/AuthCallback.stories.tsx +++ b/frontend/src/modules/auth/story/AuthCallback.stories.tsx @@ -1,12 +1,13 @@ +import type { TStoryCombineProps } from '@shared/ui/StoryCombine'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '@shared/ui/Button'; -import { StoryCombine, type TStoryCombineProps } from '@shared/ui/StoryCombine'; -import { type Meta, type StoryObj } from '@storybook/react'; +import { StoryCombine } from '@shared/ui/StoryCombine'; import { createEvent, fork, sample, scopeBind } from 'effector'; import { Provider } from 'effector-react'; import { $authContent } from '../models/auth.model'; import { $oauthCallbackStatus, OauthStatus } from '../models/oauthCallback.model'; -import { default as AuthCallback } from '../view/ui/AuthCallback/AuthCallback'; +import AuthCallback from '../view/ui/AuthCallback/AuthCallback'; import st from './Decorator.module.scss'; const meta: Meta = { @@ -25,10 +26,6 @@ export default meta; type AuthCallbackStory = StoryObj; -export const AuthCallbackStoryCombine: AuthCallbackStory = { - render: () => -}; - const CALLBACK_GROUPS: TStoryCombineProps<{ state: OauthStatus }> = { component: CallbackWithState, groups: [ @@ -43,6 +40,10 @@ const CALLBACK_GROUPS: TStoryCombineProps<{ state: OauthStatus }> = { ] }; +export const AuthCallbackStoryCombine: AuthCallbackStory = { + render: () => +}; + export const AuthCallbackSwap: AuthCallbackStory = { parameters: { layout: 'none' @@ -61,21 +62,21 @@ export const AuthCallbackSwap: AuthCallbackStory = { }); return ( - -
-
-
+ +
+
+
-
- - -
@@ -93,7 +94,7 @@ function CallbackWithState(props: { state: OauthStatus }) { }); return ( - + ); diff --git a/src/modules/auth/story/Decorator.module.scss b/frontend/src/modules/auth/story/Decorator.module.scss similarity index 100% rename from src/modules/auth/story/Decorator.module.scss rename to frontend/src/modules/auth/story/Decorator.module.scss diff --git a/src/modules/auth/types/TAuthContent.ts b/frontend/src/modules/auth/types/TAuthContent.ts similarity index 100% rename from src/modules/auth/types/TAuthContent.ts rename to frontend/src/modules/auth/types/TAuthContent.ts diff --git a/src/modules/auth/types/TEmailProviderNames.ts b/frontend/src/modules/auth/types/TEmailProviderNames.ts similarity index 100% rename from src/modules/auth/types/TEmailProviderNames.ts rename to frontend/src/modules/auth/types/TEmailProviderNames.ts diff --git a/src/modules/auth/ui/OAuthButton/story/Decorator.module.scss b/frontend/src/modules/auth/ui/OAuthButton/story/Decorator.module.scss similarity index 100% rename from src/modules/auth/ui/OAuthButton/story/Decorator.module.scss rename to frontend/src/modules/auth/ui/OAuthButton/story/Decorator.module.scss diff --git a/src/modules/auth/ui/OAuthButton/story/OAuthButton.stories.tsx b/frontend/src/modules/auth/ui/OAuthButton/story/OAuthButton.stories.tsx similarity index 81% rename from src/modules/auth/ui/OAuthButton/story/OAuthButton.stories.tsx rename to frontend/src/modules/auth/ui/OAuthButton/story/OAuthButton.stories.tsx index 933e4c43..ba94d0cf 100644 --- a/src/modules/auth/ui/OAuthButton/story/OAuthButton.stories.tsx +++ b/frontend/src/modules/auth/ui/OAuthButton/story/OAuthButton.stories.tsx @@ -1,9 +1,10 @@ +import type { TStoryCombineProps } from '@shared/ui/StoryCombine'; + import type { Meta, StoryObj } from '@storybook/react'; +import type { TOAuthButtonProps } from '../types/TOAuthButtonProps'; +import { StoryCombine } from '@shared/ui/StoryCombine'; -import { StoryCombine, type TStoryCombineProps } from '@shared/ui/StoryCombine'; import { useTranslation } from 'react-i18next'; - -import { type TOAuthButtonProps } from '../types/TOAuthButtonProps'; import OAuthButton from '../ui/OAuthButton'; import st from './Decorator.module.scss'; @@ -50,13 +51,9 @@ export const OAuthButtonYandex: OAuthButtonStory = { decorators: Decorator }; -export const OAuthStoryCombine: OAuthButtonStory = { - render: () => -}; - const GROUPS: TStoryCombineProps = { component: OAuthButtonWithTranslation, - decorator: (story) =>
{story}
, + decorator: story =>
{story}
, groups: [ { name: 'Providers', @@ -69,9 +66,13 @@ const GROUPS: TStoryCombineProps = { ] }; +export const OAuthStoryCombine: OAuthButtonStory = { + render: () => +}; + function Decorator(Story: any) { return ( -
+
); @@ -81,5 +82,5 @@ function OAuthButtonWithTranslation(props: TOAuthButtonProps) { const { provider } = props; const { t } = useTranslation('auth', { keyPrefix: 'authorizationMethodContent.oauth.buttonOauth' }); - return {t(provider as any)}; + return {t(provider as any)}; } diff --git a/src/modules/auth/ui/OAuthButton/types/TOAuthButtonProps.ts b/frontend/src/modules/auth/ui/OAuthButton/types/TOAuthButtonProps.ts similarity index 100% rename from src/modules/auth/ui/OAuthButton/types/TOAuthButtonProps.ts rename to frontend/src/modules/auth/ui/OAuthButton/types/TOAuthButtonProps.ts diff --git a/src/modules/auth/ui/OAuthButton/ui/OAuthButton.module.scss b/frontend/src/modules/auth/ui/OAuthButton/ui/OAuthButton.module.scss similarity index 100% rename from src/modules/auth/ui/OAuthButton/ui/OAuthButton.module.scss rename to frontend/src/modules/auth/ui/OAuthButton/ui/OAuthButton.module.scss diff --git a/src/modules/auth/ui/OAuthButton/ui/OAuthButton.tsx b/frontend/src/modules/auth/ui/OAuthButton/ui/OAuthButton.tsx similarity index 67% rename from src/modules/auth/ui/OAuthButton/ui/OAuthButton.tsx rename to frontend/src/modules/auth/ui/OAuthButton/ui/OAuthButton.tsx index 996f909e..b9a3faa2 100644 --- a/src/modules/auth/ui/OAuthButton/ui/OAuthButton.tsx +++ b/frontend/src/modules/auth/ui/OAuthButton/ui/OAuthButton.tsx @@ -1,7 +1,7 @@ +import type { TOAuthButtonProps } from '../types/TOAuthButtonProps'; import { UnstyledButton } from '@shared/ui/Button'; -import { clsx } from 'clsx'; -import type { TOAuthButtonProps } from '../types/TOAuthButtonProps'; +import { clsx } from 'clsx'; import { PROVIDER_ICONS } from '../../../constant'; import st from './OAuthButton.module.scss'; @@ -10,9 +10,9 @@ function OAuthButton(props: TOAuthButtonProps) { const { children, provider, ...otherProps } = props; return ( - -
-
{PROVIDER_ICONS[provider]}
+ +
+
{PROVIDER_ICONS[provider]}
{children}
diff --git a/src/modules/auth/utils/getEmailProvider.ts b/frontend/src/modules/auth/utils/getEmailProvider.ts similarity index 100% rename from src/modules/auth/utils/getEmailProvider.ts rename to frontend/src/modules/auth/utils/getEmailProvider.ts diff --git a/src/modules/auth/view/styles/Auth.module.scss b/frontend/src/modules/auth/view/styles/Auth.module.scss similarity index 100% rename from src/modules/auth/view/styles/Auth.module.scss rename to frontend/src/modules/auth/view/styles/Auth.module.scss diff --git a/src/modules/auth/view/ui/Auth/Auth.tsx b/frontend/src/modules/auth/view/ui/Auth/Auth.tsx similarity index 83% rename from src/modules/auth/view/ui/Auth/Auth.tsx rename to frontend/src/modules/auth/view/ui/Auth/Auth.tsx index efe6c263..70ecebb5 100644 --- a/src/modules/auth/view/ui/Auth/Auth.tsx +++ b/frontend/src/modules/auth/view/ui/Auth/Auth.tsx @@ -1,10 +1,10 @@ 'use client'; +import type { JSX } from 'react'; +import type { TAuthContent } from '../../../types/TAuthContent'; import { FadeTransition } from '@shared/ui/FadeTransition'; -import { SizeTransition } from '@shared/ui/SizeTransition'; -import { type JSX } from 'react'; -import { type TAuthContent } from '../../../types/TAuthContent'; +import { SizeTransition } from '@shared/ui/SizeTransition'; import { useAuth } from '../../../vm/useAuth'; import st from '../../styles/Auth.module.scss'; import { Pending } from '../Pending/Pending'; @@ -32,8 +32,8 @@ function Auth() { const selectedContent = CONTENT_MAP[authContent]; return ( - - + + {selectedContent} diff --git a/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.module.scss b/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.module.scss similarity index 100% rename from src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.module.scss rename to frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.module.scss diff --git a/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.tsx b/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.tsx new file mode 100644 index 00000000..6762ad2f --- /dev/null +++ b/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.tsx @@ -0,0 +1,92 @@ +'use client'; + +import type { TAuthProvider } from '@shared/query'; +import type { ChangeEvent } from 'react'; +import { authProviders } from '@shared/query'; +import { LoadingButton } from '@shared/ui/Button'; +import { FormInput } from '@shared/ui/Input'; +import { Loader } from '@shared/ui/Loader'; +import { Separator } from '@shared/ui/Separator'; +import { clsx } from 'clsx'; +import { Trans, useTranslation } from 'react-i18next'; + +import OAuthButton from '../../../../ui/OAuthButton/ui/OAuthButton'; +import { useAuthorizationMethod } from '../../../../vm/useAuthorizationMethod'; +import st from './AuthorizationMethodContent.module.scss'; + +function AuthorizationMethodContent() { + const { + contentDisabled, + emailInput, + emailInputError, + emailInputRef, + emailPending, + onEmailInputBlur, + onEmailInputChanged, + onEmailSubmitted, + onOauthRedirect, + redirectPending + } = useAuthorizationMethod(); + const { t } = useTranslation('auth', { keyPrefix: 'authorizationMethodContent' }); + + return ( +
+
+

+ + {'Welcome to '} + {'Flippo'} + {'!\r'} + +

+
+ +
+
+ +
+ {authProviders.map((provider: TAuthProvider) => ( + onOauthRedirect(provider) } + provider={ provider } + > + {t(`oauth.buttonOauth.${provider}` as any) as string} + + ))} +
+
+ + {t('separator')} + +
+
{ + event.preventDefault(); + onEmailSubmitted(); + } } + > + ) => onEmailInputChanged(event.target.value) } + placeholder={ t('email.inputPlaceholder') } + ref={ emailInputRef } + size={ 'large' } + type={ 'text' } + value={ emailInput } + /> + + {t('email.buttonSubmit')} + + +
+ ); +} + +export default AuthorizationMethodContent; diff --git a/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.module.scss b/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.module.scss similarity index 100% rename from src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.module.scss rename to frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.module.scss diff --git a/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.tsx b/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.tsx new file mode 100644 index 00000000..65aa84af --- /dev/null +++ b/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.tsx @@ -0,0 +1,49 @@ +'use client'; + +import type { ChangeEvent } from 'react'; +import { LoadingButton } from '@shared/ui/Button'; +import { FormInput } from '@shared/ui/Input'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; + +import { useInputUsername } from '../../../../vm/useInputUsername'; +import st from './InputUsernameContent.module.scss'; + +function InputUsernameContent() { + const { onUsernameInputChanged, onUsernameSubmitted, usernameInput, usernameInputError, usernameInputRef } + = useInputUsername(); + const { t } = useTranslation('auth', { keyPrefix: 'inputUsernameContent' }); + + return ( +
+
+

{t('title')}

+

{t('hint')}

+
+
{ + event.preventDefault(); + onUsernameSubmitted(); + } } + > + ) => onUsernameInputChanged(event.target.value) } + placeholder={ t('inputPlaceholder') } + ref={ usernameInputRef } + size={ 'large' } + type={ 'text' } + value={ usernameInput } + /> + + {t('buttonSubmit')} + + +
+ ); +} + +export default InputUsernameContent; diff --git a/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.module.scss b/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.module.scss similarity index 100% rename from src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.module.scss rename to frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.module.scss diff --git a/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.tsx b/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.tsx new file mode 100644 index 00000000..ca69b118 --- /dev/null +++ b/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { ArrowIcon, EmailIcon } from '@shared/icons'; + +import { Button, LoadingButton } from '@shared/ui/Button'; +import { InputVerificationCode } from '@shared/ui/Input'; +import { Link } from '@shared/ui/Link'; +import { Loader } from '@shared/ui/Loader'; +import { Separator } from '@shared/ui/Separator'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; + +import { useVerificationCode } from '../../../../vm/useVerificationCode'; +import st from './VerificationCodeContent.module.scss'; + +function VerificationCodeContent() { + const { + checkVerificationCodeProcess, + email, + emailProvider, + modalDisabled, + onModalToAuthorizationMethod, + onResendCode, + onVerificationCodeChanged, + onVerificationCodeSubmitted, + requestCodeProcess, + resendCodeDisabled, + timer, + timeView, + verificationCodeError, + verifyInputRef + } = useVerificationCode(); + const { t } = useTranslation('auth', { keyPrefix: 'verificationCodeContent' }); + + return ( +
+
+
+

{t('title')}

+

{t('hint')}

+
+
+ {emailProvider + ? ( + } + rel={ 'noopener noreferrer' } + target={ '_blank' } + variant={ 'neutral' } + > + {email} + + ) + : ( + {email} + )} +
+
+
+ +
+ {verificationCodeError + ? ( + {verificationCodeError} + ) + : checkVerificationCodeProcess + ? ( + + + {t('verificationCode.status.check')} + + ) + : null} +
+ + {t('verificationCode.buttonResendCode') + + (timer + ? timeView.minutes !== 0 + ? ` ${timeView.minutes}:${timeView.seconds < 10 ? `0${timeView.seconds}` : timeView.seconds} ${t('verificationCode.time.minutes')}` + : ` ${timeView.seconds < 10 ? `0${timeView.seconds}` : timeView.seconds} ${t('verificationCode.time.minutes')}` + : '')} + +
+ + +
+ ); +} + +export default VerificationCodeContent; diff --git a/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx b/frontend/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx similarity index 86% rename from src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx rename to frontend/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx index 87dd52e2..3ec5086e 100644 --- a/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx +++ b/frontend/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx @@ -1,8 +1,8 @@ 'use client'; +import type { JSX } from 'react'; import { FadeTransition } from '@shared/ui/FadeTransition'; import { SizeTransition } from '@shared/ui/SizeTransition'; -import { type JSX } from 'react'; import { OauthStatus } from '../../../models/oauthCallback.model'; import { useOauthCallback } from '../../../vm/useOauthCallback'; @@ -29,8 +29,8 @@ function AuthCallback() { const selectedContent = CONTENT_MAP[oauthCallbackStatus]; return ( - - + + {selectedContent} diff --git a/src/modules/auth/view/ui/AuthCallback/Fail/Fail.module.scss b/frontend/src/modules/auth/view/ui/AuthCallback/Fail/Fail.module.scss similarity index 100% rename from src/modules/auth/view/ui/AuthCallback/Fail/Fail.module.scss rename to frontend/src/modules/auth/view/ui/AuthCallback/Fail/Fail.module.scss diff --git a/src/modules/auth/view/ui/AuthCallback/Fail/Fail.tsx b/frontend/src/modules/auth/view/ui/AuthCallback/Fail/Fail.tsx similarity index 55% rename from src/modules/auth/view/ui/AuthCallback/Fail/Fail.tsx rename to frontend/src/modules/auth/view/ui/AuthCallback/Fail/Fail.tsx index 07c481b8..3fe2ccdc 100644 --- a/src/modules/auth/view/ui/AuthCallback/Fail/Fail.tsx +++ b/frontend/src/modules/auth/view/ui/AuthCallback/Fail/Fail.tsx @@ -10,19 +10,19 @@ function Fail() { const { t } = useTranslation('auth', { keyPrefix: 'oauthCallbackContent.fail' }); return ( -
-
- +
+
+
-

{t('title')}

-

{`ERROR: ${t(`error.${errorMessage}` as any) as string}`}

+

{t('title')}

+

{`ERROR: ${t(`error.${errorMessage}` as any) as string}`}

-
- -
diff --git a/src/modules/auth/view/ui/AuthCallback/Success/Success.module.scss b/frontend/src/modules/auth/view/ui/AuthCallback/Success/Success.module.scss similarity index 100% rename from src/modules/auth/view/ui/AuthCallback/Success/Success.module.scss rename to frontend/src/modules/auth/view/ui/AuthCallback/Success/Success.module.scss diff --git a/src/modules/auth/view/ui/AuthCallback/Success/Success.tsx b/frontend/src/modules/auth/view/ui/AuthCallback/Success/Success.tsx similarity index 64% rename from src/modules/auth/view/ui/AuthCallback/Success/Success.tsx rename to frontend/src/modules/auth/view/ui/AuthCallback/Success/Success.tsx index 64924ec8..d426b2d2 100644 --- a/src/modules/auth/view/ui/AuthCallback/Success/Success.tsx +++ b/frontend/src/modules/auth/view/ui/AuthCallback/Success/Success.tsx @@ -8,12 +8,12 @@ function Success() { const { t } = useTranslation('auth', { keyPrefix: 'oauthCallbackContent.done' }); return ( -
- -

{t('title')}

- +
+ +

{t('title')}

+ {t('hint')} - +
); diff --git a/src/modules/auth/view/ui/Pending/Pending.module.scss b/frontend/src/modules/auth/view/ui/Pending/Pending.module.scss similarity index 100% rename from src/modules/auth/view/ui/Pending/Pending.module.scss rename to frontend/src/modules/auth/view/ui/Pending/Pending.module.scss diff --git a/src/modules/auth/view/ui/Pending/Pending.tsx b/frontend/src/modules/auth/view/ui/Pending/Pending.tsx similarity index 61% rename from src/modules/auth/view/ui/Pending/Pending.tsx rename to frontend/src/modules/auth/view/ui/Pending/Pending.tsx index 745842f1..8909571c 100644 --- a/src/modules/auth/view/ui/Pending/Pending.tsx +++ b/frontend/src/modules/auth/view/ui/Pending/Pending.tsx @@ -4,8 +4,8 @@ import st from './Pending.module.scss'; function Pending() { return ( -
- +
+
); } diff --git a/src/modules/auth/vm/useAuth.ts b/frontend/src/modules/auth/vm/useAuth.ts similarity index 80% rename from src/modules/auth/vm/useAuth.ts rename to frontend/src/modules/auth/vm/useAuth.ts index 7b5f89b2..7a3b11cf 100644 --- a/src/modules/auth/vm/useAuth.ts +++ b/frontend/src/modules/auth/vm/useAuth.ts @@ -3,16 +3,17 @@ import { useEffect, useRef } from 'react'; import { $authContent } from '../models/auth.model'; -const useAuth = () => { +function useAuth() { const authContent = useUnit($authContent); const modalRef = useRef(null); useEffect(() => { - if (modalRef.current) modalRef.current.focus(); + if (modalRef.current) + modalRef.current.focus(); }, []); return { authContent, modalRef }; -}; +} export { useAuth }; diff --git a/src/modules/auth/vm/useAuthorizationMethod.ts b/frontend/src/modules/auth/vm/useAuthorizationMethod.ts similarity index 90% rename from src/modules/auth/vm/useAuthorizationMethod.ts rename to frontend/src/modules/auth/vm/useAuthorizationMethod.ts index 8d112bfc..92f32c36 100644 --- a/src/modules/auth/vm/useAuthorizationMethod.ts +++ b/frontend/src/modules/auth/vm/useAuthorizationMethod.ts @@ -16,7 +16,7 @@ import { oauthRedirect } from '../models/authorizationMethod.model'; -const useAuthorizationMethod = () => { +function useAuthorizationMethod() { const [ emailInput, emailInputError, @@ -43,7 +43,8 @@ const useAuthorizationMethod = () => { const emailInputRef = useRef(null); useEffect(() => { - if (emailInputRef.current) onEmailInputRefChanged(emailInputRef.current); + if (emailInputRef.current) + onEmailInputRefChanged(emailInputRef.current); }, [onEmailInputRefChanged]); return { @@ -58,6 +59,6 @@ const useAuthorizationMethod = () => { onOauthRedirect, redirectPending }; -}; +} export { useAuthorizationMethod }; diff --git a/src/modules/auth/vm/useInputUsername.ts b/frontend/src/modules/auth/vm/useInputUsername.ts similarity index 66% rename from src/modules/auth/vm/useInputUsername.ts rename to frontend/src/modules/auth/vm/useInputUsername.ts index db9e7867..6fd78f21 100644 --- a/src/modules/auth/vm/useInputUsername.ts +++ b/frontend/src/modules/auth/vm/useInputUsername.ts @@ -9,13 +9,14 @@ import { usernameSubmitted } from '../models/inputUsername.model'; -const useInputUsername = () => { - const [usernameInput, usernameInputError, onUsernameInputChanged, onUsernameSubmitted, onUsernameInputRefChanged] = - useUnit([$usernameInput, $usernameInputError, usernameInputChanged, usernameSubmitted, usernameInputRefChanged]); +function useInputUsername() { + const [usernameInput, usernameInputError, onUsernameInputChanged, onUsernameSubmitted, onUsernameInputRefChanged] + = useUnit([$usernameInput, $usernameInputError, usernameInputChanged, usernameSubmitted, usernameInputRefChanged]); const usernameInputRef = useRef(null); useEffect(() => { - if (usernameInputRef.current) onUsernameInputRefChanged(usernameInputRef.current); + if (usernameInputRef.current) + onUsernameInputRefChanged(usernameInputRef.current); }, [onUsernameInputRefChanged]); return { @@ -25,6 +26,6 @@ const useInputUsername = () => { usernameInputError, usernameInputRef }; -}; +} export { useInputUsername }; diff --git a/src/modules/auth/vm/useOauthCallback.ts b/frontend/src/modules/auth/vm/useOauthCallback.ts similarity index 92% rename from src/modules/auth/vm/useOauthCallback.ts rename to frontend/src/modules/auth/vm/useOauthCallback.ts index 8e7b3f06..acfdcaca 100644 --- a/src/modules/auth/vm/useOauthCallback.ts +++ b/frontend/src/modules/auth/vm/useOauthCallback.ts @@ -2,7 +2,7 @@ import { useUnit } from 'effector-react'; import * as oauthCallBackModel from '../models/oauthCallback.model'; -const useOauthCallback = () => { +function useOauthCallback() { const [oauthCallbackStatus, errorMessage, onTryAgain, onCanceled] = useUnit([ oauthCallBackModel.$oauthCallbackStatus, oauthCallBackModel.$errorMessage, @@ -16,6 +16,6 @@ const useOauthCallback = () => { onCanceled, onTryAgain }; -}; +} export { useOauthCallback }; diff --git a/src/modules/auth/vm/useVerificationCode.ts b/frontend/src/modules/auth/vm/useVerificationCode.ts similarity index 91% rename from src/modules/auth/vm/useVerificationCode.ts rename to frontend/src/modules/auth/vm/useVerificationCode.ts index 24da5f9a..558251cb 100644 --- a/src/modules/auth/vm/useVerificationCode.ts +++ b/frontend/src/modules/auth/vm/useVerificationCode.ts @@ -1,6 +1,6 @@ 'use client'; -import { type TVerifyInputHandler } from '@shared/ui/Input'; +import type { TVerifyInputHandler } from '@shared/ui/Input'; import { useUnit } from 'effector-react'; import { useEffect, useRef } from 'react'; @@ -22,7 +22,7 @@ import { verificationCodeSubmitted } from '../models/verificationCode.model'; -const useVerificationCode = () => { +function useVerificationCode() { const [ timeView, modalDisabled, @@ -61,7 +61,8 @@ const useVerificationCode = () => { useEffect(() => { onVerificationCodeContentMounted(); - if (verifyInputRef.current) onInputRefChanged(verifyInputRef.current); + if (verifyInputRef.current) + onInputRefChanged(verifyInputRef.current); }, [onVerificationCodeContentMounted, onInputRefChanged]); return { @@ -80,6 +81,6 @@ const useVerificationCode = () => { verificationCodeError, verifyInputRef }; -}; +} export { useVerificationCode }; diff --git a/frontend/src/modules/header/api/index.ts b/frontend/src/modules/header/api/index.ts new file mode 100644 index 00000000..cff3233d --- /dev/null +++ b/frontend/src/modules/header/api/index.ts @@ -0,0 +1,31 @@ +import type { TFolder, TSet } from '@shared/schemas'; + +import type Surreal from 'surrealdb'; +import { createQuery } from '@farfetched/core'; + +export const userFoldersQu = createQuery<[{ db: Surreal; userId: string }], Pick[]>({ + handler: async ({ db/* , userId */ }) => { + const [result] = await db.query<[Pick[]]>(/* surql */ ` + SELECT id, name FROM folder LIMIT 20;`); // WHERE author = $userId LIMIT + // { userId } + + return result; + }, + initialData: [] +}); + +export const userRecentQu = createQuery<[{ db: Surreal; userId: string }], Pick[]>({ + handler: async ({ db, userId }) => { + const [result] = await db.query<[Pick[]]>( + /* surql */ ` + SELECT id, name FROM set WHERE author = $userId ORDER BY updated DESC LIMIT 20; + `, + { + userId + } + ); + + return result; + }, + initialData: [] +}); diff --git a/src/modules/header/index.ts b/frontend/src/modules/header/index.ts similarity index 100% rename from src/modules/header/index.ts rename to frontend/src/modules/header/index.ts diff --git a/frontend/src/modules/header/models/header.model.ts b/frontend/src/modules/header/models/header.model.ts new file mode 100644 index 00000000..9e4aeed3 --- /dev/null +++ b/frontend/src/modules/header/models/header.model.ts @@ -0,0 +1,73 @@ +import type { TSession } from '@settings/session'; + +import type { Store } from 'effector'; +import type { TInternationalizationLocales } from 'src/settings/i18next/i18next.constants'; +import type Surreal from 'surrealdb'; +import { $language, changeLanguageFx } from '@settings/i18next'; +import { authRoute, settingsRoute } from '@settings/routing'; +import { $session, sessionSignOut } from '@settings/session'; +import { $db } from '@settings/surreal'; +import { createEvent, sample } from 'effector'; + +import { userFoldersQu, userRecentQu } from '../api'; + +type TSessionForHeader = { avatarUrl: TSession['image'] } & Pick; + +export const logout = createEvent(); +export const toSettings = createEvent(); +export const toAuth = createEvent(); +export const $sessionForHeader = $session.map((store) => { + if (!store) + return null; + + const username = store.username || `${store.name} ${store.surname}` || store.email || store.userId.toString(); + + return { + avatarUrl: store.image, + userId: store.userId, + username + } as TSessionForHeader; +}); + +export const $currentLanguage = $language.map(store => store || 'en') as Store; +export const languageSwitch = createEvent(); + +export const $folders = userFoldersQu.$data.map(folders => folders); +export const $recent = userRecentQu.$data.map(recent => recent); + +userFoldersQu.$data.watch(data => console.warn(data)); + +userFoldersQu.$error.watch(e => console.error(e)); + +sample({ + clock: languageSwitch, + source: $language, + fn: (lng) => { + return lng === 'ru' ? 'en' : 'ru'; + }, + target: changeLanguageFx +}); + +sample({ clock: logout, target: sessionSignOut }); + +sample({ + clock: toSettings, + source: $sessionForHeader, + filter: session => !!session, + fn: (session: TSessionForHeader) => ({ + userId: session?.userId.toString() + }), + target: settingsRoute.open +}); + +sample({ + clock: $sessionForHeader, + source: $db, + filter: (_, session) => !!session, + fn: (db, session) => ({ db: db as Surreal, userId: (session as TSessionForHeader).userId.toString() }), + target: [userFoldersQu.start, userRecentQu.start] +}); + +userFoldersQu.start.watch(() => console.warn('FETCH FOLDER')); + +sample({ clock: toAuth, target: authRoute.open }); diff --git a/src/modules/header/story/Header.stories.tsx b/frontend/src/modules/header/story/Header.stories.tsx similarity index 61% rename from src/modules/header/story/Header.stories.tsx rename to frontend/src/modules/header/story/Header.stories.tsx index 88bfb4d7..ef0f1aef 100644 --- a/src/modules/header/story/Header.stories.tsx +++ b/frontend/src/modules/header/story/Header.stories.tsx @@ -1,6 +1,6 @@ -import { type Meta, type StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; -import { default as Header } from '../view/Header'; +import Header from '../view/Header'; const meta: Meta = { component: Header, diff --git a/frontend/src/modules/header/ui/LanguageButton/index.ts b/frontend/src/modules/header/ui/LanguageButton/index.ts new file mode 100644 index 00000000..10e205c3 --- /dev/null +++ b/frontend/src/modules/header/ui/LanguageButton/index.ts @@ -0,0 +1,3 @@ +import LanguageButton from './ui/LanguageButton'; + +export default LanguageButton; diff --git a/frontend/src/modules/header/ui/LanguageButton/story/LanguageButton.stories.tsx b/frontend/src/modules/header/ui/LanguageButton/story/LanguageButton.stories.tsx new file mode 100644 index 00000000..f07afc4a --- /dev/null +++ b/frontend/src/modules/header/ui/LanguageButton/story/LanguageButton.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { TInternationalizationLocales } from 'src/settings/i18next/i18next.constants'; +import { $language, changeLanguageFx } from '@settings/i18next'; +import { useUnit } from 'effector-react'; + +import LanguageButton from '../ui/LanguageButton'; + +const meta: Meta = { + argTypes: { + language: { control: false, description: 'Current language' }, + onLanguageSwitch: { control: false, description: 'Language change function', type: 'function' } + }, + component: LanguageButton, + title: 'Modules/Header/ui/LanguageButton' +}; + +export default meta; + +type LanguageButtonStory = StoryObj; + +export const Default: LanguageButtonStory = { + render: () => +}; + +function WithProps() { + const [language, changeLanguage] = useUnit([$language, changeLanguageFx]); + + const onChangeLanguage = () => { + changeLanguage(language === 'ru' ? 'en' : 'ru').catch(() => console.error('Failed switched')); + }; + + return ( + + ); +} diff --git a/frontend/src/modules/header/ui/LanguageButton/types/TLanguageButtonProps.ts b/frontend/src/modules/header/ui/LanguageButton/types/TLanguageButtonProps.ts new file mode 100644 index 00000000..4c80af78 --- /dev/null +++ b/frontend/src/modules/header/ui/LanguageButton/types/TLanguageButtonProps.ts @@ -0,0 +1,8 @@ +import type { TInternationalizationLocales } from 'src/settings/i18next/i18next.constants'; + +type TLanguageButtonProps = { + language: TInternationalizationLocales; + onLanguageSwitch: VoidFunction; +}; + +export type { TLanguageButtonProps }; diff --git a/frontend/src/modules/header/ui/LanguageButton/ui/LanguageButton.module.scss b/frontend/src/modules/header/ui/LanguageButton/ui/LanguageButton.module.scss new file mode 100644 index 00000000..e7136f96 --- /dev/null +++ b/frontend/src/modules/header/ui/LanguageButton/ui/LanguageButton.module.scss @@ -0,0 +1,22 @@ +@use 'mixins/_flex.scss' as flex; +@use 'mixins/_font.scss' as font; + +.languageButton { + &[aria-expanded='true'] { + background: var(--bg-2-hover); + color: var(--text-primary); + } +} + +.languageSwitch { + @include flex.display(flex, column, center, center, 4px); + @include font.label('default'); + + color: var(--text-5); + + & > span { + width: 100%; + text-align: center; + padding-top: 4px; + } +} diff --git a/frontend/src/modules/header/ui/LanguageButton/ui/LanguageButton.tsx b/frontend/src/modules/header/ui/LanguageButton/ui/LanguageButton.tsx new file mode 100644 index 00000000..aea22910 --- /dev/null +++ b/frontend/src/modules/header/ui/LanguageButton/ui/LanguageButton.tsx @@ -0,0 +1,35 @@ +import type { TLanguageButtonProps } from '../types/TLanguageButtonProps'; +import { LanguageIcon } from '@shared/icons'; +import { IconButton } from '@shared/ui/Button'; +import { Menu, MenuHandler, MenuList } from '@shared/ui/Menu'; + +import { useTranslation } from 'react-i18next'; +import { LanguageSwitch } from '../../LanguageSwitch'; +import st from './LanguageButton.module.scss'; + +function LanguageButton(props: TLanguageButtonProps) { + const { language, onLanguageSwitch } = props; + const { t } = useTranslation('header', { keyPrefix: 'profileButton' }); + + return ( + + + + + + + +
+ {t('switchLabel')} + +
+
+
+ ); +} + +export default LanguageButton; diff --git a/src/modules/header/ui/LanguageSwitch/index.ts b/frontend/src/modules/header/ui/LanguageSwitch/index.ts similarity index 100% rename from src/modules/header/ui/LanguageSwitch/index.ts rename to frontend/src/modules/header/ui/LanguageSwitch/index.ts diff --git a/src/modules/header/ui/LanguageSwitch/story/LanguageSwitch.stories.tsx b/frontend/src/modules/header/ui/LanguageSwitch/story/LanguageSwitch.stories.tsx similarity index 67% rename from src/modules/header/ui/LanguageSwitch/story/LanguageSwitch.stories.tsx rename to frontend/src/modules/header/ui/LanguageSwitch/story/LanguageSwitch.stories.tsx index fda1f017..45a17108 100644 --- a/src/modules/header/ui/LanguageSwitch/story/LanguageSwitch.stories.tsx +++ b/frontend/src/modules/header/ui/LanguageSwitch/story/LanguageSwitch.stories.tsx @@ -1,8 +1,8 @@ -import { type Meta, type StoryObj } from '@storybook/react'; -import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { TLanguageSwitchProps } from '../types/TLanguageSwitchProps'; -import { type TLanguageSwitchProps } from '../types/TLanguageSwitchProps'; -import { default as LanguageSwitch } from '../ui/LanguageSwitch'; +import { useState } from 'react'; +import LanguageSwitch from '../ui/LanguageSwitch'; const meta: Meta = { argTypes: { @@ -24,8 +24,8 @@ export const Default: LanguageSwitchStory = { function WithChangeLanguage() { const [language, setLanguage] = useState('ru'); const changeLanguage = () => { - setLanguage((prev) => (prev === 'ru' ? 'en' : 'ru')); + setLanguage(prev => (prev === 'ru' ? 'en' : 'ru')); }; - return ; + return ; } diff --git a/src/modules/header/ui/LanguageSwitch/types/TLanguageSwitchProps.ts b/frontend/src/modules/header/ui/LanguageSwitch/types/TLanguageSwitchProps.ts similarity index 64% rename from src/modules/header/ui/LanguageSwitch/types/TLanguageSwitchProps.ts rename to frontend/src/modules/header/ui/LanguageSwitch/types/TLanguageSwitchProps.ts index 33683361..9c0191cb 100644 --- a/src/modules/header/ui/LanguageSwitch/types/TLanguageSwitchProps.ts +++ b/frontend/src/modules/header/ui/LanguageSwitch/types/TLanguageSwitchProps.ts @@ -1,7 +1,7 @@ type TLanguageSwitchProps = { 'aria-labelledby'?: string; - language: string; - onLanguageSwitch: () => void; + 'language': string; + 'onLanguageSwitch': ()=> void; }; export type { TLanguageSwitchProps }; diff --git a/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.module.scss b/frontend/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.module.scss similarity index 100% rename from src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.module.scss rename to frontend/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.module.scss diff --git a/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.tsx b/frontend/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.tsx similarity index 53% rename from src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.tsx rename to frontend/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.tsx index 68fee436..1869b995 100644 --- a/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.tsx +++ b/frontend/src/modules/header/ui/LanguageSwitch/ui/LanguageSwitch.tsx @@ -1,7 +1,7 @@ +import type { TLanguageSwitchProps } from '../types/TLanguageSwitchProps'; import { EnIcon, RuIcon } from '@shared/icons'; -import { useMemo } from 'react'; -import { type TLanguageSwitchProps } from '../types/TLanguageSwitchProps'; +import { useMemo } from 'react'; import st from './LanguageSwitch.module.scss'; function LanguageSwitch(props: TLanguageSwitchProps) { @@ -10,22 +10,22 @@ function LanguageSwitch(props: TLanguageSwitchProps) { const isRu = useMemo(() => language === 'ru', [language]); return ( -
(table?: Table) => { - return z.custom<`${Table}:${string}`>( - (val: RecordId<`${Table}`> | string) => { - if (val instanceof RecordId) { - val = val.toString(); - } - return typeof val === 'string' && table ? val.startsWith(table + ':') : false; - }, - { - message: ['Must be a record', table && `Table must be: "${table}"`] - .filter((a: string | undefined) => a) - .join('; ') - } - ); -}; - -export { record }; diff --git a/src/shared/schemas/repetition.schema.ts b/src/shared/schemas/repetition.schema.ts deleted file mode 100644 index c17081ef..00000000 --- a/src/shared/schemas/repetition.schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; - -import { record } from './record.schema'; - -const RepetitionSchema = z.object({ - cards: z.array(record('card')), - id: record('repetition'), - in: record('user'), - out: record('set') -}); - -type TRepetition = z.infer; - -export { RepetitionSchema, type TRepetition }; diff --git a/src/shared/schemas/setTo.schema.ts b/src/shared/schemas/setTo.schema.ts deleted file mode 100644 index b3b0fdc2..00000000 --- a/src/shared/schemas/setTo.schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -import { record } from './record.schema'; - -const SetToSchema = z.object({ - id: record('set_to'), - in: record('set'), - out: record('tag') -}); - -type TSetTo = z.infer; - -export { SetToSchema, type TSetTo }; diff --git a/src/shared/schemas/source.schema.ts b/src/shared/schemas/source.schema.ts deleted file mode 100644 index c71ee688..00000000 --- a/src/shared/schemas/source.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -import { record } from './record.schema'; - -const SourceType = z.union([record('folder'), record('set')]); -type TSourceType = z.infer; - -export { SourceType, type TSourceType }; diff --git a/src/shared/ui/Button/IconButton/ui/IconButton.tsx b/src/shared/ui/Button/IconButton/ui/IconButton.tsx deleted file mode 100644 index ee845c4e..00000000 --- a/src/shared/ui/Button/IconButton/ui/IconButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { UnstyledButton } from '@shared/ui/Button'; -import clsx from 'clsx'; - -import { type TIconButtonProps } from '../types/TIconButtonProps'; -import st from './IconButton.module.scss'; - -function IconButton(props: TIconButtonProps) { - const { children, size, variant, ...otherProps } = props; - - return ( - - {children} - - ); -} - -export default IconButton; diff --git a/src/shared/ui/Button/LoadingButton/types/TLoadingButtonProps.ts b/src/shared/ui/Button/LoadingButton/types/TLoadingButtonProps.ts deleted file mode 100644 index 671ee4b7..00000000 --- a/src/shared/ui/Button/LoadingButton/types/TLoadingButtonProps.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type TButtonProps } from '@shared/ui/Button'; -import { type TLoaderProps } from '@shared/ui/Loader'; - -type TLoadingButtonProps = { - isLoading: boolean; -} & Omit, 'as' | 'iconRight'> & - Partial>; - -export { type TLoadingButtonProps }; diff --git a/src/shared/ui/Button/ui/UnstyledButton.tsx b/src/shared/ui/Button/ui/UnstyledButton.tsx deleted file mode 100644 index b11ee5ea..00000000 --- a/src/shared/ui/Button/ui/UnstyledButton.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { type ElementType } from 'react'; - -import { type TUnstyledButtonProps } from '../types/TUnstyledButtonProps'; - -function UnstyledButton(props: TUnstyledButtonProps) { - const { as: Element = 'button', children, ...otherProps } = props; - - return {children}; -} - -export default UnstyledButton; diff --git a/src/shared/ui/Dialog/types/TDialogProps.ts b/src/shared/ui/Dialog/types/TDialogProps.ts deleted file mode 100644 index 55d4c896..00000000 --- a/src/shared/ui/Dialog/types/TDialogProps.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { type DialogHTMLAttributes } from 'react'; - -type TDialogProps = DialogHTMLAttributes; - -export { type TDialogProps }; diff --git a/src/shared/ui/Dialog/ui/Dialog.tsx b/src/shared/ui/Dialog/ui/Dialog.tsx deleted file mode 100644 index bf7e25e3..00000000 --- a/src/shared/ui/Dialog/ui/Dialog.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { useClickOutside } from '@shared/hooks'; -import clsx from 'clsx'; -import { type ForwardedRef, forwardRef, useImperativeHandle, useRef } from 'react'; - -import { type TDialogProps } from '../types/TDialogProps'; -import st from './Dialog.module.scss'; - -function Dialog(props: TDialogProps, ref: ForwardedRef) { - const { children, className, ...otherProps } = props; - const dialogRef = useRef(null); - - useImperativeHandle(ref, () => dialogRef.current as HTMLDialogElement); - useClickOutside(dialogRef, () => dialogRef.current?.close()); - - return ( - - {children} - - ); -} - -export default forwardRef(Dialog); diff --git a/src/shared/ui/FadeTransition/types/TFadeTransitionProps.ts b/src/shared/ui/FadeTransition/types/TFadeTransitionProps.ts deleted file mode 100644 index 3d502a2c..00000000 --- a/src/shared/ui/FadeTransition/types/TFadeTransitionProps.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type MotionProps } from 'framer-motion'; -import { type HTMLProps, type PropsWithChildren } from 'react'; - -/** - * Copy from csstype - @type {CSSProperties['opacity']} - * - * @typedef {TOpacity} - */ -type TOpacity = - | '-moz-initial' - | 'inherit' - | 'initial' - | 'revert' - | 'revert-layer' - | 'unset' - | ({} & number) - | ({} & string) - | undefined; - -type TFadeTransitionProps = PropsWithChildren< - Omit< - { - animateOpacity?: TOpacity; - contentKey: bigint | null | number | string | undefined; - exitOpacity?: TOpacity; - initialOpacity?: TOpacity; - } & HTMLProps & - Partial, - 'animate' | 'exit' | 'initial' - > ->; - -export type { TFadeTransitionProps }; diff --git a/src/shared/ui/FadeTransition/ui/FadeTransition.tsx b/src/shared/ui/FadeTransition/ui/FadeTransition.tsx deleted file mode 100644 index af5cbeef..00000000 --- a/src/shared/ui/FadeTransition/ui/FadeTransition.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion'; -import { type ForwardedRef, forwardRef } from 'react'; - -import { type TFadeTransitionProps } from '../types/TFadeTransitionProps'; - -function FadeTransition(props: TFadeTransitionProps, ref: ForwardedRef) { - const { - animateOpacity = 1, - children, - className = '', - contentKey, - exitOpacity = 0, - initialOpacity = 0, - transition, - ...otherProps - } = props; - - return ( - - - {children} - - - ); -} - -export default forwardRef(FadeTransition); diff --git a/src/shared/ui/Input/FormInput/types/TFormInputProps.ts b/src/shared/ui/Input/FormInput/types/TFormInputProps.ts deleted file mode 100644 index f45799a4..00000000 --- a/src/shared/ui/Input/FormInput/types/TFormInputProps.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type TInputProps } from '@shared/ui/Input'; - -type TFormInputProps = { - errorMessage?: null | string; -} & TInputProps; - -export { type TFormInputProps }; diff --git a/src/shared/ui/Input/FormInput/ui/FormInput.tsx b/src/shared/ui/Input/FormInput/ui/FormInput.tsx deleted file mode 100644 index ea7a95de..00000000 --- a/src/shared/ui/Input/FormInput/ui/FormInput.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Input } from '@shared/ui/Input'; -import { clsx } from 'clsx'; - -import type { TFormInputProps } from '../types/TFormInputProps'; - -import st from './FormInput.module.scss'; - -function FormInput(props: TFormInputProps) { - const { className, errorMessage, ...otherProps } = props; - - return ( -
- -
-

{errorMessage}

-
-
- ); -} - -export default FormInput; diff --git a/src/shared/ui/Input/Input/ui/Input.tsx b/src/shared/ui/Input/Input/ui/Input.tsx deleted file mode 100644 index aac94c00..00000000 --- a/src/shared/ui/Input/Input/ui/Input.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import clsx from 'clsx'; -import { type ForwardedRef, forwardRef, useImperativeHandle, useRef } from 'react'; - -import { type TInputProps } from '../types/TInputProps'; -import st from './Input.module.scss'; - -function Input(props: TInputProps, ref: ForwardedRef) { - const { children, className, icon, size, ...otherProps } = props; - const inputRef = useRef(null); - - useImperativeHandle(ref, () => { - return inputRef.current; - }); - - const onClickInput = () => { - if (inputRef.current) inputRef.current.focus(); - }; - - return ( -
-
- {icon ? icon : null} - -
- {children} -
- ); -} - -export default forwardRef(Input); diff --git a/src/shared/ui/Input/SearchInput/types/TSearchInputProps.ts b/src/shared/ui/Input/SearchInput/types/TSearchInputProps.ts deleted file mode 100644 index ce4d921f..00000000 --- a/src/shared/ui/Input/SearchInput/types/TSearchInputProps.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type TInputProps } from '../../Input/types/TInputProps'; - -type TSearchInputProps = { onClickClearButton: () => void; value: string } & Omit< - TInputProps, - 'icon' | 'type' | 'value' ->; - -export { type TSearchInputProps }; diff --git a/src/shared/ui/Input/SearchInput/ui/SearchInput.tsx b/src/shared/ui/Input/SearchInput/ui/SearchInput.tsx deleted file mode 100644 index 0b337c7f..00000000 --- a/src/shared/ui/Input/SearchInput/ui/SearchInput.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { CloseIcon } from '@shared/icons'; -import { SearchIcon } from '@shared/icons'; -import { IconButton } from '@shared/ui/Button'; -import { type TIconButtonProps } from '@shared/ui/Button'; - -import { type TInputProps } from '../../Input/types/TInputProps'; -import Input from '../../Input/ui/Input'; -import { type TSearchInputProps } from '../types/TSearchInputProps'; -import st from './SearchInput.module.scss'; - -const SIZE_CLEAR_BUTTON: { [key in TInputProps['size']]: TIconButtonProps['size'] } = { - large: 'small', - regular: 'x-small' -}; - -function SearchInput(props: TSearchInputProps) { - const { onClickClearButton, size, value } = props; - - return ( - } type={'search'} {...props} className={value ? st['paddingRight-0'] : ''}> - {value && value.length !== 0 ? ( -
- - - -
- ) : null} - - ); -} - -export default SearchInput; diff --git a/src/shared/ui/Loader/icons/DotsFadeIcon.tsx b/src/shared/ui/Loader/icons/DotsFadeIcon.tsx deleted file mode 100644 index 263faf73..00000000 --- a/src/shared/ui/Loader/icons/DotsFadeIcon.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { type TLoadingIconProps } from './types/TLoadingIconProps'; - -function DotsFade(props: TLoadingIconProps) { - return ( - - - - - - - - - - - - ); -} - -export default DotsFade; diff --git a/src/shared/ui/Loader/icons/LoadingIcon.tsx b/src/shared/ui/Loader/icons/LoadingIcon.tsx deleted file mode 100644 index e2bc123d..00000000 --- a/src/shared/ui/Loader/icons/LoadingIcon.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { type TLoadingIconProps } from './types/TLoadingIconProps'; - -function SpinnerIcon(props: TLoadingIconProps) { - return ( - - - - - - - - - - ); -} - -export default SpinnerIcon; diff --git a/src/shared/ui/Menu/types/TMenuListProps.ts b/src/shared/ui/Menu/types/TMenuListProps.ts deleted file mode 100644 index b8a67e65..00000000 --- a/src/shared/ui/Menu/types/TMenuListProps.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type MotionProps } from 'framer-motion'; -import { type ComponentProps } from 'react'; - -type TMenuList = ComponentProps<'menu'> & MotionProps; - -export type { TMenuList }; diff --git a/src/shared/ui/Menu/ui/MenuItem.tsx b/src/shared/ui/Menu/ui/MenuItem.tsx deleted file mode 100644 index 1853d2f2..00000000 --- a/src/shared/ui/Menu/ui/MenuItem.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import clsx from 'clsx'; -import { type ForwardedRef, forwardRef, type MouseEvent } from 'react'; - -import { type TMenuItemProps } from '../types/TMenuItemProps'; -import st from './Menu.module.scss'; -import { useMenu } from './MenuContext'; - -function MenuItem(props: TMenuItemProps, ref: ForwardedRef) { - const { blockClose, children, className = '', onClick, ...otherProps } = props; - const { onClose } = useMenu(); - - const onClickItem = (event: MouseEvent) => { - if (onClick) onClick(event); - - if (!blockClose) onClose(); - }; - - return ( - - ); -} - -export default forwardRef(MenuItem); diff --git a/src/shared/ui/Menu/ui/MenuList.tsx b/src/shared/ui/Menu/ui/MenuList.tsx deleted file mode 100644 index bd9bfcd0..00000000 --- a/src/shared/ui/Menu/ui/MenuList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import clsx from 'clsx'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Children, cloneElement, type ForwardedRef, forwardRef, isValidElement, useImperativeHandle } from 'react'; -import { createPortal } from 'react-dom'; - -import { type TMenuList } from '../types/TMenuListProps'; -import st from './Menu.module.scss'; -import { useMenu } from './MenuContext'; - -function MenuList(props: TMenuList, ref: ForwardedRef) { - const { children, className, ...otherProps } = props; - const { animation, isOpen, menu, x, y } = useMenu(); - - useImperativeHandle(ref, () => menu.current); - - return createPortal( - - {isOpen ? ( - - {Children.map( - children, - (child) => isValidElement(child) && cloneElement(child, { ...child.props, role: 'menuitem' }) - )} - - ) : null} - , - document.body - ); -} - -export default forwardRef(MenuList); diff --git a/src/shared/ui/Select/ui/Option.tsx b/src/shared/ui/Select/ui/Option.tsx deleted file mode 100644 index 3a6f7300..00000000 --- a/src/shared/ui/Select/ui/Option.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import clsx from 'clsx'; -import { useEffect } from 'react'; - -import { type TOptionProps } from '../types/TOptionProps'; -import st from './Select.module.scss'; -import { useSelect } from './SelectContext'; - -function Option(props: TOptionProps) { - const { className, icon, index, title, value, ...otherProps } = props; - const { activeIndex, listTitleRef, onChange, selected, setActiveIndex, setSelectedIndex } = useSelect(); - - const handleOption = () => { - onChange(value); - setSelectedIndex(index as number); - }; - - useEffect(() => { - if (listTitleRef.current) listTitleRef.current[index as number] = title; - - if (selected === value) setSelectedIndex(index as number); - }, [title, index, listTitleRef, selected, value, setSelectedIndex]); - - return ( - - ); -} - -export default Option; diff --git a/src/shared/ui/Select/ui/Select.tsx b/src/shared/ui/Select/ui/Select.tsx deleted file mode 100644 index d791809d..00000000 --- a/src/shared/ui/Select/ui/Select.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useClickOutside } from '@shared/hooks'; -import { ChevronIcon } from '@shared/icons'; -import clsx from 'clsx'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Children, cloneElement, isValidElement, memo, useCallback, useMemo, useRef, useState } from 'react'; -import useMeasure from 'react-use-measure'; - -import { type TSelectContextValue } from '../types/TSelectContextValue'; -import { type TSelectProps } from '../types/TSelectProps'; -import st from './Select.module.scss'; -import { SelectContextProvider } from './SelectContext'; - -function Select(props: TSelectProps) { - const { children, defaultOption, icon, onSelected, placeholder, placementDropdown = 'right', selected } = props; - const [isOpen, setIsOpen] = useState(false); - const [scope, { width }] = useMeasure(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [activeIndex, setActiveIndex] = useState(null); - - const listTitleRef = useRef<(null | string)[]>([]); - const listBoxRef = useRef(null); - - useClickOutside(listBoxRef, () => setIsOpen(false)); - - const onOptionClick = useCallback( - (value: string) => { - setIsOpen(false); - - onSelected(value); - }, - [onSelected] - ); - - const onComboBoxClick = () => { - if (!listBoxRef.current) setIsOpen((prev) => !prev); - }; - - const contextValue = useMemo( - () => - ({ - activeIndex, - isOpen, - listTitleRef, - onChange: onOptionClick, - selected, - selectedIndex, - setActiveIndex, - setSelectedIndex - }) as TSelectContextValue, - [activeIndex, isOpen, onOptionClick, selected, selectedIndex] - ); - - return ( - - -
- {placeholder} -
- - - {isOpen ? ( - -

- {placeholder} -

- {Children.map( - children, - (child, index) => - isValidElement(child) && - cloneElement(child, { - ...child.props, - index: child.props.index || index + 1 - }) - )} -
- ) : null} -
-
-
- ); -} - -export default memo(Select); diff --git a/src/shared/ui/Select/ui/SelectContext.tsx b/src/shared/ui/Select/ui/SelectContext.tsx deleted file mode 100644 index 86f07c9e..00000000 --- a/src/shared/ui/Select/ui/SelectContext.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createContext, useContext } from 'react'; - -import { type TSelectContextProviderProps, type TSelectContextValue } from '../types/TSelectContextValue'; - -export const SelectContext = createContext(null); -SelectContext.displayName = 'Flippo.SelectContext'; - -export function useSelect() { - const context = useContext(SelectContext); - - if (!context) { - throw new Error( - 'useSelect() must be used within a Select. It happens when you use Option components outside the Select component.' - ); - } - - return context; -} - -export function SelectContextProvider({ children, value }: TSelectContextProviderProps) { - return {children}; -} diff --git a/src/shared/ui/TabList/Tab/ui/Tab.tsx b/src/shared/ui/TabList/Tab/ui/Tab.tsx deleted file mode 100644 index a4029649..00000000 --- a/src/shared/ui/TabList/Tab/ui/Tab.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { motion } from 'framer-motion'; - -import { type TTabProps } from '../types/TTabProps'; -import st from './Tab.module.scss'; - -function Tab(props: TTabProps) { - const { icon, onClick, title, ...otherProps } = props; - - return ( - - ); -} - -export default Tab; - -function SelectedLine() { - return ; -} diff --git a/src/shared/ui/TabList/ui/TabList.tsx b/src/shared/ui/TabList/ui/TabList.tsx deleted file mode 100644 index 7f49134d..00000000 --- a/src/shared/ui/TabList/ui/TabList.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { memo } from 'react'; - -import Tab from '../Tab/ui/Tab'; -import { type TTabListProps } from '../types/TTabListProps'; -import st from './TabList.module.scss'; - -function TabList(props: TTabListProps) { - const { onTabChange, selected, tabs } = props; - - return ( -
- {tabs.map((tab) => ( - - ))} -
- ); -} - -export default memo(TabList); diff --git a/src/tests/example.testplane.js b/src/tests/example.testplane.js deleted file mode 100644 index 99d10358..00000000 --- a/src/tests/example.testplane.js +++ /dev/null @@ -1,9 +0,0 @@ -describe('test', () => { - it('example', async ({ browser }) => { - await browser.url('https://github.com/gemini-testing/testplane'); - - await expect(browser.$('.f4.my-3')).toHaveText( - 'Testplane (ex-hermione) browser test runner based on mocha and wdio' - ); - }); -}); diff --git a/src/widgets/Modal/NewFolderModal/index.ts b/src/widgets/Modal/NewFolderModal/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/widgets/Modal/NewFolderModal/story/Story.stories.tsx b/src/widgets/Modal/NewFolderModal/story/Story.stories.tsx deleted file mode 100644 index 424e53d2..00000000 --- a/src/widgets/Modal/NewFolderModal/story/Story.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { type Meta, type StoryObj } from '@storybook/react'; - -import NewFolderModal from '../ui/NewFolderModal'; - -const meta: Meta = { - component: NewFolderModal, - title: 'Widgets/Modal/NewFolderModal' -}; - -export default meta; - -type NewFolderModalStory = StoryObj; - -export const Default: NewFolderModalStory = { - parameters: { - layout: '' - } -}; diff --git a/src/widgets/Modal/NewFolderModal/ui/NewFolderModal.tsx b/src/widgets/Modal/NewFolderModal/ui/NewFolderModal.tsx deleted file mode 100644 index f3f0db52..00000000 --- a/src/widgets/Modal/NewFolderModal/ui/NewFolderModal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { SuccessIcon } from '@shared/icons'; -import { Button } from '@shared/ui/Button'; -import { Dialog } from '@shared/ui/Dialog'; -import { FormInput } from '@shared/ui/Input'; -import { createPortal } from 'react-dom'; -import { useTranslation } from 'react-i18next'; - -import st from './NewFolderModal.module.scss'; - -function NewFolderModal() { - const { t } = useTranslation('modal', { keyPrefix: 'createFolder' }); - - return createPortal( - -
-
-

{t('title')}

- -
-
- - -
-
-
, - document.body - ); -} - -export default NewFolderModal; diff --git a/src/widgets/Modal/NewFolderModal/ui/useNewFolderModal.ts b/src/widgets/Modal/NewFolderModal/ui/useNewFolderModal.ts deleted file mode 100644 index 4b1aa3ef..00000000 --- a/src/widgets/Modal/NewFolderModal/ui/useNewFolderModal.ts +++ /dev/null @@ -1 +0,0 @@ -const useNewFolderModal = () => {}; diff --git a/src/widgets/ToastNotification/ui/Toast.tsx b/src/widgets/ToastNotification/ui/Toast.tsx deleted file mode 100644 index d8cf580b..00000000 --- a/src/widgets/ToastNotification/ui/Toast.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client'; - -import { CloseIcon, ErrorIcon, SuccessIcon, WarningIcon } from '@shared/icons/index'; -import { Button, IconButton } from '@shared/ui/Button'; -import { CountDownCircle } from '@shared/ui/CountDownCircle'; -import { Separator } from '@shared/ui/Separator'; -import clsx from 'clsx'; -import { type JSX, memo, type MouseEvent, useEffect, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { type TNotificationKind, type TToastProps } from '../types/TToastProps'; -import st from './Toast.module.scss'; - -function Toast(props: TToastProps) { - const { - content, - id, - message, - onAutomaticClosingAction, - onClickButtonClose, - onClickContent, - onClose, - statusIconSize, - timeout = 5000, - variant - } = props; - - const timerRef = useRef(null); - const { t } = useTranslation(); - - const statusIcons: { [key in TNotificationKind]: JSX.Element } = useMemo( - () => ({ - error: , - success: , - timer: , - warning: - }), - [statusIconSize, timeout] - ); - - useEffect(() => { - if (timeout && onClose && !timerRef.current) { - timerRef.current = setTimeout(() => { - if (onAutomaticClosingAction) onAutomaticClosingAction(); - onClose(id); - }, timeout); - } - - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - }, [timeout, onClose, id, onAutomaticClosingAction]); - - const onClickHandler = (event: MouseEvent) => { - if (onClickContent) { - onClickContent(event); - - if (onClose) onClose(id); - } - }; - - const onCloseToast = () => { - if (onClose) { - if (onClickButtonClose) onClickButtonClose(); - onClose(id); - } - - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - - return ( -
-
-
{statusIcons[variant]}
- {content ? content :

{message}

} -
-
- {variant === 'timer' ? ( - <> - - - - ) : ( - - - - )} -
-
- ); -} - -export default memo(Toast); diff --git a/tsconfig.app.json b/tsconfig.app.json deleted file mode 100644 index 1bb4827f..00000000 --- a/tsconfig.app.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "composite": true, - "types": ["node", "react", "react-dom", "testplane"], - - /* Bundler mode */ - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "emitDeclarationOnly": true, - "jsx": "react-jsx", - "esModuleInterop": true, - "incremental": true, - - /* Linting */ - "strict": true, - "alwaysStrict": true, - "allowSyntheticDefaultImports": true, - "allowJs": true, - "resolveJsonModule": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "noImplicitReturns": true, - - "baseUrl": ".", - "paths": { - "@*": ["src/*/index.ts"], - "@app/*": ["src/app/*/index.ts"], - "@modules/*": ["src/modules/*/index.ts"], - "@pages/*": ["src/pages/*/index.ts"], - "@settings/*": ["src/settings/*/index.ts"], - "@shared/*": ["src/shared/*/index.ts"], - "@widgets/*": ["src/widgets/*/index.ts"] - } - }, - "include": [ - "src/**/*.ts", - "src/**/tests/*.testplane.js", - "src/**/*.tsx", - "src/settings/**/*.d.ts", - ".storybook/**/*.ts", - ".storybook/**/*.tsx", - "testplane.config.cts", - "vite.config.ts" - ], - "exclude": ["node_modules", "eslint.config.js", "prettier.config.js", "stylelint.config.js"] -} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 8a85dd64..00000000 --- a/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "forceConsistentCasingInFileNames": true, - "strict": true - }, - "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], - "exclude": ["node_modules"] -} diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index ac9a7c2f..00000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ESNext"], - "module": "ESNext", - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "composite": true, - - /* Bundler mode */ - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "emitDeclarationOnly": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..4c9d6538 --- /dev/null +++ b/turbo.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "tasks": { + "dependencies:install" : { + + }, + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "lint": { + "dependsOn": ["lint"] + }, + "transit": { + "dependsOn": ["^transit"] + }, + "check:types": { + "dependsOn": ["transit"] + }, + "dev": { + "cache": false, + "persistent": true + } + } +}