Merge pull request 'front' (#1) from front into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2025-09-23 20:38:12 +04:00
38 changed files with 17700 additions and 16 deletions

View File

@@ -11,7 +11,8 @@ SMTP_USERNAME=email@example.com
SMTP_FROM=email@example.com
SMTP_PASSWORD=password
# OTP_CODE_EXPIRED_TIME = 5
# OTP_CODE_EXPIRED_TIME=5
# OTP_CODE_MOCKUP=0000
REDIS_HOST=redis
REDIS_PORT=6379

View File

@@ -47,14 +47,3 @@ async def login_user(
@router.post("/logout")
async def logout_user(response: Response):
response.delete_cookie(jwt_security.config.JWT_ACCESS_COOKIE_NAME)
@router.get("/current")
async def get_current_user(users_service: Annotated[UsersService, Depends(get_users_service)],
payload: TokenPayload = Depends(jwt_security.access_token_required)):
user = await users_service.get_user(int(payload.sub))
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return user

View File

@@ -1,6 +1,7 @@
from typing import Annotated, List
from fastapi import APIRouter, Depends, status
from authx import TokenPayload
from app.api.dependencies import get_users_service
from app.common.security import jwt_security
@@ -12,7 +13,6 @@ router = APIRouter(
tags=["Users"],
)
# dependencies=[Depends(jwt_security.access_token_required)]
@router.post("", status_code=status.HTTP_201_CREATED)
async def add_user(
@@ -29,3 +29,11 @@ async def get_users(
) -> List[UserReadSchema]:
users = await users_service.get_users()
return users
@router.get("/current")
async def get_current_user(
users_service: Annotated[UsersService, Depends(get_users_service)],
access_token_payload: TokenPayload = Depends(jwt_security.access_token_required),
) -> UserReadSchema:
return await users_service.get_user(int(access_token_payload.sub))

View File

@@ -22,6 +22,7 @@ smtp_from = os.getenv("SMTP_FROM")
smtp_password = os.getenv("SMTP_PASSWORD")
otp_code_expired_time = int(os.getenv("OTP_CODE_EXPIRED_TIME", "5")) # minutes
otp_code_mockup = os.getenv("OTP_CODE_MOCKUP", None)
redis_host = os.getenv("REDIS_HOST")
redis_port = int(os.getenv("REDIS_PORT"))

View File

@@ -2,6 +2,7 @@ import os
import dotenv
from fastapi import FastAPI, APIRouter
from fastapi.middleware.cors import CORSMiddleware
BASE_DIR: str = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
@@ -10,8 +11,17 @@ if os.path.exists(os.path.join(BASE_DIR, ".env")):
from app.common.security import jwt_security
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
jwt_security.handle_errors(app)
main_router = APIRouter(prefix="/api")

View File

@@ -35,7 +35,7 @@ class AuthManager:
if user is None:
return None
is_verified = await self._otp_service.verify_code(user.id, schema.otp_code)
is_verified = await self._otp_service.verify_otp_code(user.id, schema.otp_code, delete_code=True)
if not is_verified:
return None

View File

@@ -1,6 +1,7 @@
import random
from typing import Type
import app.config
from app.repositories import OTPRepository
from app.schemas.otp_schemas import OTPReadSchema
@@ -11,14 +12,19 @@ class OTPService:
async def create_otp_code(self, user_id: int) -> OTPReadSchema:
code = str(random.randint(1000, 9999))
if app.config.otp_code_mockup is not None:
code = app.config.otp_code_mockup
return await self.otp_repo.add_one(user_id, code)
async def verify_code(self, user_id: int, code: str) -> bool:
async def verify_otp_code(self, user_id: int, code: str, delete_code: bool = False) -> bool:
user_otp_codes = await self.otp_repo.find_all(user_id=user_id)
for user_otp_code in user_otp_codes:
if user_otp_code.code == code:
await self.otp_repo.delete_one(user_otp_code.id)
if delete_code:
await self.otp_repo.delete_one(user_otp_code.id)
return True
return False

0
backend/entrypoint.sh Normal file → Executable file
View File

36
front/.dockerignore Normal file
View File

@@ -0,0 +1,36 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
**/.env
**/.env.*
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.yml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md
.nuxt

24
front/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

1
front/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v24.7.0

52
front/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,52 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.fixAll.eslint": "explicit"
},
"eslint.workingDirectories": [
"."
],
"[vue]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[javascriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[xml]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[html]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
"[scss]": {
"editor.defaultFormatter": "vscode.css-language-features"
},
"[css]": {
"editor.defaultFormatter": "vscode.css-language-features"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"editor.tabSize": 2
}

55
front/Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
####################################################
################### BASE IMAGE #####################
####################################################
FROM node:24.7.0-alpine AS base
WORKDIR /app
####################################################
############### INSTALL DEPENDENCIES ###############
####################################################
FROM base AS deps
COPY package*.json ./
RUN npm ci --omit=dev --prefer-offline --no-audit
####################################################
#################### BUILD APP #####################
####################################################
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
####################################################
###################### FINAL #######################
####################################################
FROM base AS runner
# COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.output ./.output
COPY package*.json ./
RUN addgroup -g 1001 -S nodejs && \
adduser -S nuxtuser -u 1001 && \
chown -R nuxtuser:nodejs /app
USER nuxtuser
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
CMD ["node", ".output/server/index.mjs"]

75
front/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

15
front/app/app.config.ts Normal file
View File

@@ -0,0 +1,15 @@
export default defineAppConfig({
// https://ui.nuxt.com/getting-started/theme#design-system
ui: {
colors: {
primary: 'emerald',
neutral: 'slate',
},
button: {
defaultVariants: {
// Set default button color to neutral
// color: 'neutral'
},
},
},
})

7
front/app/app.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<NuxtLayout>
<UApp>
<NuxtPage />
</UApp>
</NuxtLayout>
</template>

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";

View File

View File

@@ -0,0 +1,13 @@
<template>
<footer class="container mx-auto p-5">
All rights reserved &copy;
</footer>
</template>
<script lang="ts" setup>
</script>
<style scoped lang="scss">
@use './style.scss';
</style>

View File

@@ -0,0 +1,29 @@
<template>
<header class="container mx-auto p-5 flex flex-row justify-end">
<ul class="flex gap-5">
<li>
<NuxtLink
to="/"
@click.prevent="logout()"
>
Выход
</NuxtLink>
</li>
</ul>
</header>
</template>
<script lang="ts" setup>
const logout = () => {
useCookie(AuthConfig.AccessTokenCookieName).value = null
navigateTo(RoutePaths.Auth.Login)
const toast = useToast()
toast.add({ title: 'Выход', description: 'Вы вышли из системы', color: 'info' })
}
</script>
<style scoped lang="scss">
@use './style.scss';
</style>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
</script>
<template>
<div class="min-h-screen w-full flex flex-col">
<LayoutHeader />
<main class="flex-1 container mx-auto px-5 flex flex-col">
<slot />
</main>
<LayoutFooter />
</div>
</template>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
</script>
<template>
<div class="min-h-screen w-full flex flex-col">
<main class="flex-1 container mx-auto px-5 flex flex-col">
<slot />
</main>
<LayoutFooter />
</div>
</template>

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async () => {
console.log('Auth middleware')
const accessToken = useCookie(AuthConfig.AccessTokenCookieName)
const isAuthenticated = ref(accessToken.value != null)
if (!isAuthenticated.value) {
return navigateTo(RoutePaths.Auth.Login)
}
})

View File

@@ -0,0 +1,160 @@
<template>
<div class="flex-1 flex items-center justify-center">
<UForm
:schema="schema"
:state="state"
class="space-y-4 min-w-1/4"
@submit="loginFormOnSubmit"
>
<UFormField
label="Email"
name="email"
>
<UInput
v-model="state.email"
class="w-full"
/>
</UFormField>
<UFormField
label="Пароль"
name="password"
>
<UInput
v-model="state.password"
class="w-full"
type="password"
/>
</UFormField>
<UButton type="submit">
Войти
</UButton>
</UForm>
<UModal
v-model:open="otpCodeModalOpen"
:dismissible="false"
:close="{
color: 'primary',
variant: 'outline',
class: 'rounded-full'
}"
title="Введите код подтверждения"
:description="`Код подтверждения выслан на почту ${state.email}`"
>
<template #body>
<div class="w-full gap-5 flex flex-col justify-center items-center">
<UPinInput
id="pin-input"
v-model="otpCode"
otp
mask
:length="4"
type="number"
:autofocus="true"
/>
<span
:v-if="otpCodeErrorText !== ''"
>
{{ otpCodeErrorText }}
</span>
</div>
</template>
</UModal>
</div>
</template>
<script lang="ts" setup>
import * as v from 'valibot'
definePageMeta({
layout: 'no-authenticated',
})
const schema = v.object({
email: v.pipe(v.string(), v.email('Неверный формат email')),
password: v.pipe(v.string()),
})
const state = reactive({
email: '',
password: '',
})
const toast = useToast()
const otpCode = ref([])
const lastOtpCode = ref([])
const otpCodeErrorText = ref('')
const otpCodeModalOpen = ref(false)
const isOtpLoading = ref(false)
const isOtpVerified = ref(false)
// Watcher для отслеживания изменения пин-кода
watch(otpCode, async (newValue) => {
// Проверяем, что введены все 4 цифры
if (newValue.join('').length === 4 && !isOtpVerified.value && newValue.join('') !== lastOtpCode.value.join('')) {
lastOtpCode.value = otpCode.value
await verifyOtpCode()
}
})
async function loginFormOnSubmit() {
const { $api } = useNuxtApp()
try {
await $api(
'auth/login', {
method: 'POST',
body: state,
})
otpCodeModalOpen.value = true
}
catch {
toast.add({ title: 'Ошибка', description: 'Неверный email или пароль', color: 'warning' })
}
}
async function verifyOtpCode() {
if (isOtpLoading.value) return
isOtpLoading.value = true
const { $api } = useNuxtApp()
try {
const otpLoginResponse = await $api(
'auth/otp', {
method: 'POST',
body: {
...state,
otp_code: otpCode.value.join(''),
},
},
)
isOtpVerified.value = true
otpCodeErrorText.value = ''
toast.add({ title: 'Успешно', description: 'Вход выполнен', color: 'success' })
useCookie(AuthConfig.AccessTokenCookieName).value = otpLoginResponse.token
setTimeout(async () => {
otpCodeModalOpen.value = false
navigateTo(RoutePaths.Main)
}, 500)
}
catch {
otpCodeErrorText.value = 'Неверный код подтверждения'
isOtpLoading.value = false
}
}
</script>
<style lang="scss">
</style>

11
front/app/pages/index.vue Normal file
View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
definePageMeta({
middleware: ['auth'],
})
</script>
<template>
<div class="flex-1 flex justify-center items-center">
<h2>Главная</h2>
</div>
</template>

108
front/app/plugins/api.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { H3Error } from 'h3'
type IResponse = Response & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_data: any
} | null
interface ApiErrorOptions {
response: IResponse
error: H3Error | Error
}
/**
* Обработчик ошибок API
*/
function handleApiError({ response, error }: ApiErrorOptions) {
if (!response) {
console.error('Network error:', error.message)
return
}
const errorMessage = response._data?.message || error?.message || 'Unknown error'
const toast = useToast()
switch (response.status) {
case 401:
// console.error('Unauthorized access. Redirecting to login.')
return navigateTo(RoutePaths.Auth.Login)
case 403:
// console.error('Forbidden:', errorMessage)
break
case 404:
// console.error('Not found:', errorMessage)
break
case 400:
// console.error('Bad request', errorMessage)
break
case 500:
// console.error('Server error:', errorMessage)
toast.add({ title: 'Ошибка', description: 'Произошла ошибка на сервере, попробуйте позже', color: 'error' })
break
default:
console.error(`Error ${response.status}:`, errorMessage)
}
throw createError({
statusCode: response.status,
statusMessage: errorMessage,
fatal: false,
})
}
export default defineNuxtPlugin({
name: 'api-plugin',
setup(nuxtApp) {
const config = nuxtApp.$config
let baseURL = '/'
baseURL = config.public.baseApiUrl
const api = $fetch.create({
baseURL,
onRequest({ request, options }) {
// Добавляем заголовок авторизации, если есть токен
const tokenAuthenticated = useCookie(AuthConfig.AccessTokenCookieName)
if (tokenAuthenticated.value) {
options.headers = {
...options.headers,
Authorization: `Bearer ${tokenAuthenticated.value}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
if (import.meta.dev) {
console.log('[API Request]', request, options)
}
},
async onResponse({ response }) {
if (import.meta.dev) {
console.log('[API Response]', response.status, response._data)
}
},
async onResponseError({ response, error }): Promise<void> {
handleApiError({
response: response as IResponse,
error: error as H3Error,
})
},
})
return {
provide: {
api,
},
}
},
})

3
front/app/types/auth.ts Normal file
View File

@@ -0,0 +1,3 @@
export const AuthConfig = {
AccessTokenCookieName: 'access_token_cookie',
}

View File

@@ -0,0 +1,6 @@
export const RoutePaths = {
Main: '/',
Auth: {
Login: '/auth/login',
},
}

5
front/compose.yml Normal file
View File

@@ -0,0 +1,5 @@
services:
front:
build: .
ports: [ 127.0.0.1:3000:3000 ]
restart: unless-stopped

42
front/eslint.config.mjs Normal file
View File

@@ -0,0 +1,42 @@
import stylistic from '@stylistic/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
files: ['**/*.{js,ts,jsx,tsx,vue,mjs}'],
ignores: [
'**/.nuxt/**',
'**/.output/**',
'**/assets/**',
'**/node_modules/**',
],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
extraFileExtensions: ['.vue'],
},
},
plugins: {
'@stylistic': stylistic,
vue,
},
rules: {
...stylistic.configs.recommended.rules,
...vue.configs['strongly-recommended'].rules,
'vue/max-attributes-per-line': ['error', {
singleline: {
max: 1,
},
multiline: {
max: 1,
},
}],
},
})

26
front/nuxt.config.ts Normal file
View File

@@ -0,0 +1,26 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
ssr: false,
modules: [
'@nuxt/eslint',
'@nuxt/ui',
'@nuxt/image',
],
imports: {
autoImport: true,
dirs: [
'./types',
],
},
css: [
'~/assets/css/nuxtui.css',
'~/assets/scss/main.scss',
],
runtimeConfig: {
public: {
baseApiUrl: 'http://localhost:8000/api',
},
},
})

16911
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
front/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "nuxt-app",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"start": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@nuxt/image": "1.11.0",
"@nuxt/ui": "3.3.2",
"nuxt": "^4.0.3",
"typescript": "^5.6.3",
"valibot": "^1.1.0",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"eslint": "^9.34.0",
"pre-commit": "^1.2.2",
"sass": "^1.91.0"
},
"pre-commit": [
"lint:fix"
]
}

BIN
front/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
front/public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow: /

18
front/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}