Quellcode durchsuchen

feat: real-time order notifications, auth race fix, load testing

- Add WebSocket notify_order_update to all admin order operations:
  update status, attach/delete files, upload/update/delete photos,
  delete order, update order items
- Fix missing global_manager import in orders.py (notifications were
  silently failing)
- Fix auth init race condition: init() now returns a cached Promise,
  so router beforeEach waits for user to load before checking auth,
  preventing redirect to /auth on page refresh
- Skip order rate-limiting for admin users (allows benchmark testing
  and admin-created orders on behalf of customers)
- Increase order ID prominence in Admin.vue (text-xl, font-black)
- Make WebSocket update sound softer (sine wave vs triangle, shorter)
- Add API throughput benchmark (backend/scratch/bench.py): ~3400-5800 req/min
- Add captcha localization and case-insensitive verification
- Add captcha_required translation key to all locales
- Add new_status translation keys to all locales
unknown vor 3 Tagen
Ursprung
Commit
825fe67334

+ 26 - 0
backend/dependencies.py

@@ -0,0 +1,26 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+import auth_utils
+
+async def get_current_user(token: str = Depends(auth_utils.oauth2_scheme)):
+    payload = auth_utils.decode_token(token)
+    if not payload:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Could not validate credentials",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    return payload
+
+async def require_admin(current_user: dict = Depends(get_current_user)):
+    if current_user.get("role") != 'admin':
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Admin role required"
+        )
+    return current_user
+
+async def get_current_user_optional(token: str = Depends(auth_utils.oauth2_scheme_optional)):
+    if not token:
+        return None
+    return auth_utils.decode_token(token)

+ 12 - 4
backend/locales.py

@@ -9,7 +9,9 @@ ERROR_TRANSLATIONS = {
         "incorrect_credentials": "Неверный email или пароль",
         "incorrect_credentials": "Неверный email или пароль",
         "user_not_found": "Пользователь не найден",
         "user_not_found": "Пользователь не найден",
         "invalid_token": "Недействительный токен",
         "invalid_token": "Недействительный токен",
-        "flood_control": "Слишком много сообщений. Пожалуйста, подождите 10 секунд."
+        "flood_control": "Слишком много сообщений. Пожалуйста, подождите 10 секунд.",
+        "captcha_required": "Требуется подтверждение (капча)",
+        "too_many_attempts": "Слишком много неудачных попыток. Пожалуйста, попробуйте позже."
     },
     },
     "me": {
     "me": {
         "missing": "Ovo polje je obavezno",
         "missing": "Ovo polje je obavezno",
@@ -21,7 +23,9 @@ ERROR_TRANSLATIONS = {
         "incorrect_credentials": "Neispravan email ili lozinka",
         "incorrect_credentials": "Neispravan email ili lozinka",
         "user_not_found": "Korisnik nije pronađen",
         "user_not_found": "Korisnik nije pronađen",
         "invalid_token": "Neispravan token",
         "invalid_token": "Neispravan token",
-        "flood_control": "Previše poruka. Molimo sačekajte 10 sekundi."
+        "flood_control": "Previše poruka. Molimo sačekajte 10 sekundi.",
+        "captcha_required": "Potrebna je potvrda (captcha)",
+        "too_many_attempts": "Previše neuspješnih pokušaja. Molimo pokušajte kasnije."
     },
     },
     "ua": {
     "ua": {
         "missing": "Це поле обов'язкове",
         "missing": "Це поле обов'язкове",
@@ -33,7 +37,9 @@ ERROR_TRANSLATIONS = {
         "incorrect_credentials": "Невірний email або пароль",
         "incorrect_credentials": "Невірний email або пароль",
         "user_not_found": "Користувача не знайдено",
         "user_not_found": "Користувача не знайдено",
         "invalid_token": "Недійсний токен",
         "invalid_token": "Недійсний токен",
-        "flood_control": "Занадто багато повідомлень. Будь ласка, зачекайте 10 секунд."
+        "flood_control": "Занадто багато повідомлень. Будь ласка, зачекайте 10 секунд.",
+        "captcha_required": "Потрібне підтвердження (капча)",
+        "too_many_attempts": "Занадто багато невдалих спроб. Будь ласка, спробуйте пізніше."
     },
     },
     "en": {
     "en": {
         "missing": "Field is required",
         "missing": "Field is required",
@@ -43,7 +49,9 @@ ERROR_TRANSLATIONS = {
         "incorrect_credentials": "Incorrect email or password",
         "incorrect_credentials": "Incorrect email or password",
         "user_not_found": "User not found",
         "user_not_found": "User not found",
         "invalid_token": "Invalid or expired token",
         "invalid_token": "Invalid or expired token",
-        "flood_control": "Too many messages. Please wait 10 seconds."
+        "flood_control": "Too many messages. Please wait 10 seconds.",
+        "captcha_required": "Captcha verification required",
+        "too_many_attempts": "Too many failed attempts. Please try again later."
     }
     }
 }
 }
 
 

+ 2 - 4
backend/routers/admin.py

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from typing import List, Optional
 from typing import List, Optional
 import db
 import db
 import auth_utils
 import auth_utils
+from dependencies import require_admin
 
 
 router = APIRouter(prefix="/admin", tags=["admin"])
 router = APIRouter(prefix="/admin", tags=["admin"])
 
 
@@ -10,11 +11,8 @@ async def get_audit_logs(
     page: int = Query(1, ge=1),
     page: int = Query(1, ge=1),
     size: int = Query(50, ge=1, le=100),
     size: int = Query(50, ge=1, le=100),
     action: Optional[str] = None,
     action: Optional[str] = None,
-    token: str = Depends(auth_utils.oauth2_scheme)
+    admin: dict = Depends(require_admin)
 ):
 ):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
     
     
     offset = (page - 1) * size
     offset = (page - 1) * size
     
     

+ 48 - 29
backend/routers/auth.py

@@ -1,6 +1,7 @@
 from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
 from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
 from typing import Optional, List
 from typing import Optional, List
 from services.global_manager import global_manager
 from services.global_manager import global_manager
+from services.rate_limit_service import rate_limit_service
 import auth_utils
 import auth_utils
 import db
 import db
 import schemas
 import schemas
@@ -8,6 +9,7 @@ import session_utils
 import uuid
 import uuid
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 import locales
 import locales
+from dependencies import get_current_user, require_admin
 
 
 router = APIRouter(prefix="/auth", tags=["auth"])
 router = APIRouter(prefix="/auth", tags=["auth"])
 
 
@@ -31,14 +33,45 @@ async def register(request: Request, user: schemas.UserCreate, lang: str = "en")
     return new_user[0]
     return new_user[0]
 
 
 @router.post("/login", response_model=schemas.Token)
 @router.post("/login", response_model=schemas.Token)
-async def login(user_data: schemas.UserLogin, lang: str = "en"):
-    user = db.execute_query("SELECT * FROM users WHERE email = %s", (user_data.email,))
+async def login(request: Request, user_data: schemas.UserLogin, lang: str = "en"):
+    ip = request.client.host if request.client else "unknown"
+    email = user_data.email.lower()
+
+    # 1. Check Global Rate Limit
+    if rate_limit_service.is_rate_limited(email, ip):
+        raise HTTPException(
+            status_code=429, 
+            detail=locales.translate_error("too_many_attempts", lang)
+        )
+
+    # 2. Check if Captcha is Required
+    if rate_limit_service.is_captcha_required(email, ip):
+        if not user_data.captcha_token:
+            raise HTTPException(
+                status_code=403, 
+                detail=locales.translate_error("captcha_required", lang)
+            )
+        
+        # 3. Verify Captcha
+        if not await rate_limit_service.verify_captcha(user_data.captcha_token):
+            raise HTTPException(
+                status_code=403, 
+                detail=locales.translate_error("invalid_token", lang)
+            )
+
+    # 4. Attempt Authentication
+    user = db.execute_query("SELECT * FROM users WHERE email = %s", (email,))
     if not user or not auth_utils.verify_password(user_data.password, user[0]['password_hash']):
     if not user or not auth_utils.verify_password(user_data.password, user[0]['password_hash']):
+        # Log failure
+        rate_limit_service.record_failed_attempt(email, ip)
         raise HTTPException(status_code=401, detail=locales.translate_error("incorrect_credentials", lang))
         raise HTTPException(status_code=401, detail=locales.translate_error("incorrect_credentials", lang))
     
     
     if not user[0].get('is_active', True):
     if not user[0].get('is_active', True):
         raise HTTPException(status_code=403, detail="Your account has been suspended.")
         raise HTTPException(status_code=403, detail="Your account has been suspended.")
     
     
+    # 5. Success - Reset Rate Limits
+    rate_limit_service.reset_attempts(email, ip)
+
     access_token = auth_utils.create_access_token(
     access_token = auth_utils.create_access_token(
         data={"sub": user[0]['email'], "id": user[0]['id'], "role": user[0]['role']}
         data={"sub": user[0]['email'], "id": user[0]['id'], "role": user[0]['role']}
     )
     )
@@ -64,11 +97,9 @@ async def social_login(request: Request, data: schemas.SocialLogin):
         return {"access_token": access_token, "token_type": "bearer"}
         return {"access_token": access_token, "token_type": "bearer"}
 
 
 @router.post("/logout")
 @router.post("/logout")
-async def logout(token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if payload:
-        sid = payload.get("sid")
-        if sid: session_utils.delete_session(sid)
+async def logout(user: dict = Depends(get_current_user)):
+    sid = user.get("sid")
+    if sid: session_utils.delete_session(sid)
     return {"message": "Successfully logged out"}
     return {"message": "Successfully logged out"}
 
 
 @router.post("/forgot-password")
 @router.post("/forgot-password")
@@ -91,18 +122,15 @@ async def reset_password(request: schemas.ResetPassword):
     return {"message": "Password reset successfully"}
     return {"message": "Password reset successfully"}
 
 
 @router.get("/me", response_model=schemas.UserResponse)
 @router.get("/me", response_model=schemas.UserResponse)
-async def get_me(token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload: raise HTTPException(status_code=401, detail="Invalid token")
-    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, is_company, company_name, company_pib, company_address, ip_address, created_at FROM users WHERE id = %s", (payload.get("id"),))
-    if not user: raise HTTPException(status_code=404, detail="User not found")
-    return user[0]
+async def get_me(user: dict = Depends(get_current_user)):
+    user_id = user.get("id")
+    user_data = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, is_company, company_name, company_pib, company_address, ip_address, created_at FROM users WHERE id = %s", (user_id,))
+    if not user_data: raise HTTPException(status_code=404, detail="User not found")
+    return user_data[0]
 
 
 @router.put("/me", response_model=schemas.UserResponse)
 @router.put("/me", response_model=schemas.UserResponse)
-async def update_me(data: schemas.UserUpdate, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload: raise HTTPException(status_code=401, detail="Invalid token")
-    user_id = payload.get("id")
+async def update_me(data: schemas.UserUpdate, user: dict = Depends(get_current_user)):
+    user_id = user.get("id")
     update_fields = []
     update_fields = []
     params = []
     params = []
     for field, value in data.dict(exclude_unset=True).items():
     for field, value in data.dict(exclude_unset=True).items():
@@ -116,10 +144,7 @@ async def update_me(data: schemas.UserUpdate, token: str = Depends(auth_utils.oa
     return user[0]
     return user[0]
 
 
 @router.get("/admin/users")
 @router.get("/admin/users")
-async def admin_get_users(page: int = 1, size: int = 50, search: Optional[str] = None, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_get_users(page: int = 1, size: int = 50, search: Optional[str] = None, admin: dict = Depends(require_admin)):
     
     
     offset = (page - 1) * size
     offset = (page - 1) * size
     base_query = "SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, is_company, company_name, company_pib, company_address, ip_address, created_at FROM users"
     base_query = "SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, is_company, company_name, company_pib, company_address, ip_address, created_at FROM users"
@@ -140,10 +165,7 @@ async def admin_get_users(page: int = 1, size: int = 50, search: Optional[str] =
     return {"users": users, "total": total, "page": page, "size": size}
     return {"users": users, "total": total, "page": page, "size": size}
 
 
 @router.post("/admin/users", response_model=schemas.UserResponse)
 @router.post("/admin/users", response_model=schemas.UserResponse)
-async def admin_create_user(data: schemas.UserCreate, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_create_user(data: schemas.UserCreate, admin: dict = Depends(require_admin)):
     
     
     existing_user = db.execute_query("SELECT id FROM users WHERE email = %s", (data.email,))
     existing_user = db.execute_query("SELECT id FROM users WHERE email = %s", (data.email,))
     if existing_user:
     if existing_user:
@@ -159,10 +181,7 @@ async def admin_create_user(data: schemas.UserCreate, token: str = Depends(auth_
     return user[0]
     return user[0]
 
 
 @router.patch("/users/{target_id}/admin", response_model=schemas.UserResponse)
 @router.patch("/users/{target_id}/admin", response_model=schemas.UserResponse)
-async def admin_update_user(target_id: int, data: schemas.UserUpdate, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_update_user(target_id: int, data: schemas.UserUpdate, admin: dict = Depends(require_admin)):
     
     
     update_fields = []
     update_fields = []
     params = []
     params = []

+ 9 - 32
backend/routers/catalog.py

@@ -4,6 +4,7 @@ import db
 import schemas
 import schemas
 import auth_utils
 import auth_utils
 import json
 import json
+from dependencies import require_admin
 
 
 router = APIRouter(tags=["catalog"])
 router = APIRouter(tags=["catalog"])
 
 
@@ -21,10 +22,7 @@ async def get_services():
     return db.execute_query("SELECT * FROM services WHERE is_active = TRUE")
     return db.execute_query("SELECT * FROM services WHERE is_active = TRUE")
 
 
 @router.get("/admin/materials", response_model=List[schemas.MaterialBase])
 @router.get("/admin/materials", response_model=List[schemas.MaterialBase])
-async def admin_get_materials(token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_get_materials(admin: dict = Depends(require_admin)):
     rows = db.execute_query("SELECT * FROM materials ORDER BY id DESC")
     rows = db.execute_query("SELECT * FROM materials ORDER BY id DESC")
     for r in rows:
     for r in rows:
         if r.get('available_colors') and isinstance(r['available_colors'], str):
         if r.get('available_colors') and isinstance(r['available_colors'], str):
@@ -33,10 +31,7 @@ async def admin_get_materials(token: str = Depends(auth_utils.oauth2_scheme)):
     return rows
     return rows
 
 
 @router.post("/admin/materials")
 @router.post("/admin/materials")
-async def admin_create_material(data: schemas.MaterialCreate, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_create_material(data: schemas.MaterialCreate, admin: dict = Depends(require_admin)):
     colors_json = json.dumps(data.available_colors) if data.available_colors else None
     colors_json = json.dumps(data.available_colors) if data.available_colors else None
     query = "INSERT INTO materials (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, long_desc_en, long_desc_ru, long_desc_ua, long_desc_me, price_per_cm3, available_colors, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
     query = "INSERT INTO materials (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, long_desc_en, long_desc_ru, long_desc_ua, long_desc_me, price_per_cm3, available_colors, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
     params = (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.long_desc_en, data.long_desc_ru, data.long_desc_ua, data.long_desc_me, data.price_per_cm3, colors_json, data.is_active)
     params = (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.long_desc_en, data.long_desc_ru, data.long_desc_ua, data.long_desc_me, data.price_per_cm3, colors_json, data.is_active)
@@ -44,10 +39,7 @@ async def admin_create_material(data: schemas.MaterialCreate, token: str = Depen
     return {"id": mat_id}
     return {"id": mat_id}
 
 
 @router.patch("/admin/materials/{mat_id}")
 @router.patch("/admin/materials/{mat_id}")
-async def admin_update_material(mat_id: int, data: schemas.MaterialUpdate, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_update_material(mat_id: int, data: schemas.MaterialUpdate, admin: dict = Depends(require_admin)):
     update_fields = []
     update_fields = []
     params = []
     params = []
     for field, value in data.dict(exclude_unset=True).items():
     for field, value in data.dict(exclude_unset=True).items():
@@ -63,34 +55,22 @@ async def admin_update_material(mat_id: int, data: schemas.MaterialUpdate, token
     return {"id": mat_id}
     return {"id": mat_id}
 
 
 @router.delete("/admin/materials/{mat_id}")
 @router.delete("/admin/materials/{mat_id}")
-async def admin_delete_material(mat_id: int, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_delete_material(mat_id: int, admin: dict = Depends(require_admin)):
     db.execute_commit("DELETE FROM materials WHERE id = %s", (mat_id,))
     db.execute_commit("DELETE FROM materials WHERE id = %s", (mat_id,))
     return {"id": mat_id, "status": "deleted"}
     return {"id": mat_id, "status": "deleted"}
 
 
 @router.get("/admin/services", response_model=List[schemas.ServiceBase])
 @router.get("/admin/services", response_model=List[schemas.ServiceBase])
-async def admin_get_services(token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_get_services(admin: dict = Depends(require_admin)):
     return db.execute_query("SELECT * FROM services ORDER BY id DESC")
     return db.execute_query("SELECT * FROM services ORDER BY id DESC")
 
 
 @router.post("/admin/services")
 @router.post("/admin/services")
-async def admin_create_service(data: schemas.ServiceCreate, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_create_service(data: schemas.ServiceCreate, admin: dict = Depends(require_admin)):
     query = "INSERT INTO services (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, tech_type, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
     query = "INSERT INTO services (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, tech_type, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
     srv_id = db.execute_commit(query, (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.tech_type, data.is_active))
     srv_id = db.execute_commit(query, (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.tech_type, data.is_active))
     return {"id": srv_id}
     return {"id": srv_id}
 
 
 @router.patch("/admin/services/{srv_id}")
 @router.patch("/admin/services/{srv_id}")
-async def admin_update_service(srv_id: int, data: schemas.ServiceUpdate, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_update_service(srv_id: int, data: schemas.ServiceUpdate, admin: dict = Depends(require_admin)):
     update_fields = []
     update_fields = []
     params = []
     params = []
     for field, value in data.dict(exclude_unset=True).items():
     for field, value in data.dict(exclude_unset=True).items():
@@ -106,9 +86,6 @@ async def admin_update_service(srv_id: int, data: schemas.ServiceUpdate, token:
     return {"id": srv_id}
     return {"id": srv_id}
 
 
 @router.delete("/admin/services/{srv_id}")
 @router.delete("/admin/services/{srv_id}")
-async def admin_delete_service(srv_id: int, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+async def admin_delete_service(srv_id: int, admin: dict = Depends(require_admin)):
     db.execute_commit("DELETE FROM services WHERE id = %s", (srv_id,))
     db.execute_commit("DELETE FROM services WHERE id = %s", (srv_id,))
     return {"id": srv_id, "status": "deleted"}
     return {"id": srv_id, "status": "deleted"}

+ 7 - 10
backend/routers/chat.py

@@ -6,6 +6,7 @@ import auth_utils
 import datetime
 import datetime
 import schemas
 import schemas
 import locales
 import locales
+from dependencies import get_current_user
 
 
 router = APIRouter(tags=["chat"])
 router = APIRouter(tags=["chat"])
 
 
@@ -13,11 +14,9 @@ router = APIRouter(tags=["chat"])
 last_message_times = {}
 last_message_times = {}
 
 
 @router.get("/orders/{order_id}/messages")
 @router.get("/orders/{order_id}/messages")
-async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload: raise HTTPException(status_code=401, detail="Invalid token")
-    role = payload.get("role")
-    user_id = payload.get("id")
+async def get_order_messages(order_id: int, user: dict = Depends(get_current_user)):
+    role = user.get("role")
+    user_id = user.get("id")
     # Fetch user chat status
     # Fetch user chat status
     user_info = db.execute_query("SELECT can_chat FROM users WHERE id = %s", (user_id,))
     user_info = db.execute_query("SELECT can_chat FROM users WHERE id = %s", (user_id,))
     can_chat = user_info[0]['can_chat'] if user_info else False
     can_chat = user_info[0]['can_chat'] if user_info else False
@@ -44,11 +43,9 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     return messages
     return messages
 
 
 @router.post("/orders/{order_id}/messages")
 @router.post("/orders/{order_id}/messages")
-async def post_order_message(order_id: int, data: schemas.MessageCreate, token: str = Depends(auth_utils.oauth2_scheme), lang: str = "en"):
-    payload = auth_utils.decode_token(token)
-    if not payload: raise HTTPException(status_code=401, detail="Invalid token")
-    role = payload.get("role")
-    user_id = payload.get("id")
+async def post_order_message(order_id: int, data: schemas.MessageCreate, user: dict = Depends(get_current_user), lang: str = "en"):
+    role = user.get("role")
+    user_id = user.get("id")
     
     
     # Flood control for non-admin users
     # Flood control for non-admin users
     if role != 'admin':
     if role != 'admin':

+ 68 - 48
backend/routers/orders.py

@@ -14,8 +14,12 @@ import slicer_utils
 from fastapi import APIRouter, Request, Form, Depends, HTTPException, BackgroundTasks, UploadFile, File
 from fastapi import APIRouter, Request, Form, Depends, HTTPException, BackgroundTasks, UploadFile, File
 from services import pricing, order_processing, event_hooks
 from services import pricing, order_processing, event_hooks
 from services.audit_service import audit_service
 from services.audit_service import audit_service
+from services.rate_limit_service import rate_limit_service
+from dependencies import get_current_user, require_admin, get_current_user_optional
 from pydantic import BaseModel
 from pydantic import BaseModel
 from datetime import datetime
 from datetime import datetime
+import locales
+from services.global_manager import global_manager
 
 
 router = APIRouter(prefix="/orders", tags=["orders"])
 router = APIRouter(prefix="/orders", tags=["orders"])
 
 
@@ -42,13 +46,20 @@ async def create_order(
     company_name: Optional[str] = Form(None),
     company_name: Optional[str] = Form(None),
     company_pib: Optional[str] = Form(None),
     company_pib: Optional[str] = Form(None),
     company_address: Optional[str] = Form(None),
     company_address: Optional[str] = Form(None),
-    token: str = Depends(auth_utils.oauth2_scheme_optional)
+    user: Optional[dict] = Depends(get_current_user_optional)
 ):
 ):
-    user_id = None
-    if token:
-        payload = auth_utils.decode_token(token)
-        if payload:
-            user_id = payload.get("id")
+    ip = request.client.host if request.client else "unknown"
+    email_addr = email.lower()
+    lang = request.query_params.get("lang", "en")
+
+    is_admin = user.get("role") == "admin" if user else False
+    if not is_admin and rate_limit_service.is_order_flooding(email_addr, ip):
+        raise HTTPException(
+            status_code=429, 
+            detail=locales.translate_error("flood_control", lang)
+        )
+
+    user_id = user.get("id") if user else None
 
 
     parsed_ids = []
     parsed_ids = []
     parsed_quantities = []
     parsed_quantities = []
@@ -59,7 +70,6 @@ async def create_order(
         except:
         except:
             pass
             pass
 
 
-    lang = request.query_params.get("lang", "en")
     name_col = f"name_{lang}" if lang in ["en", "ru", "me"] else "name_en"
     name_col = f"name_{lang}" if lang in ["en", "ru", "me"] else "name_en"
     
     
     mat_info = db.execute_query(f"SELECT {name_col}, price_per_cm3 FROM materials WHERE id = %s", (material_id,))
     mat_info = db.execute_query(f"SELECT {name_col}, price_per_cm3 FROM materials WHERE id = %s", (material_id,))
@@ -117,6 +127,10 @@ async def create_order(
                 )
                 )
         background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
         background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
         background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
         background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
+        
+        # Record placement for rate limiting
+        rate_limit_service.record_order_placement(email_addr, ip)
+        
         return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
         return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
     except Exception as e:
     except Exception as e:
         print(f"Error creating order: {e}")
         print(f"Error creating order: {e}")
@@ -126,13 +140,9 @@ async def create_order(
 async def get_my_orders(
 async def get_my_orders(
     page: int = 1,
     page: int = 1,
     size: int = 10,
     size: int = 10,
-    token: str = Depends(auth_utils.oauth2_scheme)
+    user: dict = Depends(get_current_user)
 ):
 ):
-    payload = auth_utils.decode_token(token)
-    if not payload:
-        raise HTTPException(status_code=401, detail="Invalid token")
-    
-    user_id = payload.get("id")
+    user_id = user.get("id")
     offset = (page - 1) * size
     offset = (page - 1) * size
 
 
     # Get total count
     # Get total count
@@ -189,11 +199,8 @@ async def get_admin_orders(
     status: Optional[str] = None,
     status: Optional[str] = None,
     date_from: Optional[str] = None,
     date_from: Optional[str] = None,
     date_to: Optional[str] = None,
     date_to: Optional[str] = None,
-    token: str = Depends(auth_utils.oauth2_scheme)
+    admin: dict = Depends(require_admin)
 ):
 ):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
     
     
     where_clauses = []
     where_clauses = []
     params = []
     params = []
@@ -241,22 +248,20 @@ async def get_admin_orders(
         row['photos'] = photos
         row['photos'] = photos
     return results
     return results
 
 
-@router.patch("/{order_id}/admin")
-async def update_order_admin(
-    order_id: int, 
-    data: schemas.AdminOrderUpdate, 
-    background_tasks: BackgroundTasks,
+@router.patch("/{order_id}")
+async def update_order(
     request: Request,
     request: Request,
-    token: str = Depends(auth_utils.oauth2_scheme)
+    order_id: int,
+    data: schemas.AdminOrderUpdate,
+    background_tasks: BackgroundTasks,
+    admin: dict = Depends(require_admin)
 ):
 ):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
-    
+    order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
+    if not order_info: raise HTTPException(status_code=404, detail="Order not found")
+
     update_fields = []
     update_fields = []
     params = []
     params = []
     if data.status:
     if data.status:
-        order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
         if order_info:
         if order_info:
             background_tasks.add_task(
             background_tasks.add_task(
                 event_hooks.on_order_status_changed, 
                 event_hooks.on_order_status_changed, 
@@ -346,24 +351,25 @@ async def update_order_admin(
         
         
         # LOG ACTION
         # LOG ACTION
         await audit_service.log(
         await audit_service.log(
-            user_id=payload.get("id"),
+            user_id=admin.get("id"),
             action="update_order",
             action="update_order",
             target_type="order",
             target_type="order",
             target_id=order_id,
             target_id=order_id,
             details={"updated_fields": {k.split(" = ")[0]: v for k, v in zip(update_fields, params)}},
             details={"updated_fields": {k.split(" = ")[0]: v for k, v in zip(update_fields, params)}},
             request=request
             request=request
         )
         )
+        
+        # NOTIFY USER VIA WEBSOCKET
+        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+
     return {"id": order_id, "status": "updated"}
     return {"id": order_id, "status": "updated"}
 
 
 @router.post("/{order_id}/attach-file")
 @router.post("/{order_id}/attach-file")
 async def admin_attach_file(
 async def admin_attach_file(
     order_id: int,
     order_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
-    token: str = Depends(auth_utils.oauth2_scheme)
+    admin: dict = Depends(require_admin)
 ):
 ):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
         
         
     unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
     unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
     file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
     file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
@@ -394,6 +400,11 @@ async def admin_attach_file(
     query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (%s, %s, %s, %s, 1, %s, %s, %s, %s)"
     query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (%s, %s, %s, %s, 1, %s, %s, %s, %s)"
     f_id = db.execute_commit(query, (order_id, file.filename, db_file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, db_preview_path))
     f_id = db.execute_commit(query, (order_id, file.filename, db_file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, db_preview_path))
     
     
+    # NOTIFY USER VIA WEBSOCKET
+    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+    if order_info:
+        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+
     return {"file_id": f_id, "filename": file.filename, "preview_path": db_preview_path, "filament_g": filament_g, "print_time": print_time}
     return {"file_id": f_id, "filename": file.filename, "preview_path": db_preview_path, "filament_g": filament_g, "print_time": print_time}
 
 
 @router.delete("/{order_id}/files/{file_id}")
 @router.delete("/{order_id}/files/{file_id}")
@@ -401,11 +412,8 @@ async def admin_delete_file(
     order_id: int,
     order_id: int,
     file_id: int,
     file_id: int,
     request: Request,
     request: Request,
-    token: str = Depends(auth_utils.oauth2_scheme)
+    admin: dict = Depends(require_admin)
 ):
 ):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
         
         
     file_record = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
     file_record = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
     if not file_record:
     if not file_record:
@@ -424,13 +432,19 @@ async def admin_delete_file(
     
     
     # LOG ACTION
     # LOG ACTION
     await audit_service.log(
     await audit_service.log(
-        user_id=payload.get("id"),
+        user_id=admin.get("id"),
         action="delete_order_file",
         action="delete_order_file",
         target_type="order",
         target_type="order",
         target_id=order_id,
         target_id=order_id,
         details={"file_id": file_id},
         details={"file_id": file_id},
         request=request
         request=request
     )
     )
+
+    # NOTIFY USER VIA WEBSOCKET
+    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+    if order_info:
+        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+
     return {"status": "success"}
     return {"status": "success"}
     
     
 class OrderItemSchema(BaseModel):
 class OrderItemSchema(BaseModel):
@@ -444,11 +458,7 @@ async def get_order_items(order_id: int):
     return items
     return items
 
 
 @router.put("/{order_id}/items")
 @router.put("/{order_id}/items")
-async def update_order_items(order_id: int, items: List[OrderItemSchema], token: str = Depends(auth_utils.oauth2_scheme)):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
-    
+async def update_order_items(order_id: int, items: List[OrderItemSchema], admin: dict = Depends(require_admin)):
     db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
     db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
     
     
     total_order_price = 0
     total_order_price = 0
@@ -463,17 +473,23 @@ async def update_order_items(order_id: int, items: List[OrderItemSchema], token:
     # Sync main order total_price
     # Sync main order total_price
     db.execute_commit("UPDATE orders SET total_price = %s WHERE id = %s", (total_order_price, order_id))
     db.execute_commit("UPDATE orders SET total_price = %s WHERE id = %s", (total_order_price, order_id))
     
     
+    # NOTIFY USER VIA WEBSOCKET
+    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+    if order_info:
+        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+
     return {"status": "success", "total_price": total_order_price}
     return {"status": "success", "total_price": total_order_price}
 
 
 @router.delete("/{order_id}/admin")
 @router.delete("/{order_id}/admin")
 async def delete_order_admin(
 async def delete_order_admin(
     order_id: int,
     order_id: int,
     request: Request,
     request: Request,
-    token: str = Depends(auth_utils.oauth2_scheme)
+    admin: dict = Depends(require_admin)
 ):
 ):
-    payload = auth_utils.decode_token(token)
-    if not payload or payload.get("role") != 'admin':
-        raise HTTPException(status_code=403, detail="Admin role required")
+    # Fetch user_id before deletion to notify later
+    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+    if not order_info: raise HTTPException(status_code=404, detail="Order not found")
+    customer_id = order_info[0]['user_id']
         
         
     # 1. Find all related files to delete from disk
     # 1. Find all related files to delete from disk
     files = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE order_id = %s", (order_id,))
     files = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE order_id = %s", (order_id,))
@@ -512,13 +528,17 @@ async def delete_order_admin(
         
         
         # LOG ACTION
         # LOG ACTION
         await audit_service.log(
         await audit_service.log(
-            user_id=payload.get("id"),
+            user_id=admin.get("id"),
             action="delete_order_entirely",
             action="delete_order_entirely",
             target_type="order",
             target_type="order",
             target_id=order_id,
             target_id=order_id,
             details={"order_id": order_id},
             details={"order_id": order_id},
             request=request
             request=request
         )
         )
+
+        # NOTIFY USER VIA WEBSOCKET
+        await global_manager.notify_order_update(customer_id, order_id)
+
         return {"status": "success", "message": f"Order {order_id} deleted entirely"}
         return {"status": "success", "message": f"Order {order_id} deleted entirely"}
     except Exception as e:
     except Exception as e:
         print(f"Failed to delete order {order_id}: {e}")
         print(f"Failed to delete order {order_id}: {e}")

+ 23 - 1
backend/routers/portfolio.py

@@ -6,6 +6,7 @@ import config
 import os
 import os
 import uuid
 import uuid
 import shutil
 import shutil
+from services.global_manager import global_manager
 
 
 router = APIRouter(tags=["portfolio"])
 router = APIRouter(tags=["portfolio"])
 
 
@@ -58,6 +59,12 @@ async def admin_upload_order_photo(
         
         
     query = "INSERT INTO order_photos (order_id, file_path, is_public) VALUES (%s, %s, %s)"
     query = "INSERT INTO order_photos (order_id, file_path, is_public) VALUES (%s, %s, %s)"
     photo_id = db.execute_commit(query, (order_id, db_file_path, is_public))
     photo_id = db.execute_commit(query, (order_id, db_file_path, is_public))
+    
+    # NOTIFY USER VIA WEBSOCKET
+    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+    if order_info:
+        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+
     return {"id": photo_id, "file_path": db_file_path, "is_public": is_public}
     return {"id": photo_id, "file_path": db_file_path, "is_public": is_public}
 
 
 @router.patch("/admin/photos/{photo_id}")
 @router.patch("/admin/photos/{photo_id}")
@@ -71,6 +78,13 @@ async def admin_update_photo_status(photo_id: int, data: schemas.PhotoUpdate, to
     if data.is_public and not photo_data[0]['allow_portfolio']:
     if data.is_public and not photo_data[0]['allow_portfolio']:
         raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage")
         raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage")
     db.execute_commit("UPDATE order_photos SET is_public = %s WHERE id = %s", (data.is_public, photo_id))
     db.execute_commit("UPDATE order_photos SET is_public = %s WHERE id = %s", (data.is_public, photo_id))
+    
+    # NOTIFY USER VIA WEBSOCKET
+    order_id = photo_data[0]['order_id']
+    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+    if order_info:
+        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+
     return {"id": photo_id, "is_public": data.is_public}
     return {"id": photo_id, "is_public": data.is_public}
 
 
 @router.delete("/admin/photos/{photo_id}")
 @router.delete("/admin/photos/{photo_id}")
@@ -79,9 +93,11 @@ async def admin_delete_photo(photo_id: int, token: str = Depends(auth_utils.oaut
     if not payload or payload.get("role") != 'admin':
     if not payload or payload.get("role") != 'admin':
         raise HTTPException(status_code=403, detail="Admin role required")
         raise HTTPException(status_code=403, detail="Admin role required")
     
     
-    photo = db.execute_query("SELECT file_path FROM order_photos WHERE id = %s", (photo_id,))
+    photo = db.execute_query("SELECT file_path, order_id FROM order_photos WHERE id = %s", (photo_id,))
     if not photo:
     if not photo:
         raise HTTPException(status_code=404, detail="Photo not found")
         raise HTTPException(status_code=404, detail="Photo not found")
+    
+    order_id = photo[0]['order_id']
         
         
     try:
     try:
         path = os.path.join(config.BASE_DIR, photo[0]['file_path'])
         path = os.path.join(config.BASE_DIR, photo[0]['file_path'])
@@ -91,4 +107,10 @@ async def admin_delete_photo(photo_id: int, token: str = Depends(auth_utils.oaut
         print(f"Error deleting photo file: {e}")
         print(f"Error deleting photo file: {e}")
         
         
     db.execute_commit("DELETE FROM order_photos WHERE id = %s", (photo_id,))
     db.execute_commit("DELETE FROM order_photos WHERE id = %s", (photo_id,))
+
+    # NOTIFY USER VIA WEBSOCKET
+    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+    if order_info:
+        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+
     return {"id": photo_id, "status": "deleted"}
     return {"id": photo_id, "status": "deleted"}

+ 1 - 0
backend/schemas.py

@@ -140,6 +140,7 @@ class UserUpdate(BaseModel):
 class UserLogin(BaseModel):
 class UserLogin(BaseModel):
     email: EmailStr
     email: EmailStr
     password: str
     password: str
+    captcha_token: Optional[str] = None
 
 
 class UserResponse(BaseModel):
 class UserResponse(BaseModel):
     id: int
     id: int

+ 218 - 0
backend/scratch/bench.py

@@ -0,0 +1,218 @@
+"""
+Radionica3D API Throughput Benchmark
+=====================================
+Measures: Order creation, status update, and deletion throughput.
+
+Usage:
+    cd backend
+    python scratch/bench.py
+
+Requires: pip install httpx
+"""
+import asyncio
+import httpx
+import time
+import statistics
+import os
+
+BASE_URL = "http://localhost:8000"
+
+# ──────────────────────────────────────────────
+# CONFIG — fill in a valid admin token or leave
+# empty to auto-login (requires correct creds).
+# ──────────────────────────────────────────────
+ADMIN_EMAIL    = "admin@radionica3d.com"
+ADMIN_PASSWORD = "admin123"  # change if needed
+MATERIAL_ID    = 1        # must exist in the DB
+CONCURRENCY    = 10       # parallel requests per batch
+ITERATIONS     = 30       # total operations per scenario
+# ──────────────────────────────────────────────
+
+
+async def get_admin_token(client: httpx.AsyncClient) -> str:
+    resp = await client.post(
+        f"{BASE_URL}/auth/login",
+        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
+    )
+    resp.raise_for_status()
+    token = resp.json().get("access_token")
+    if not token:
+        raise RuntimeError(f"No token in response: {resp.json()}")
+    print("[OK] Logged in as admin")
+    return token
+
+
+async def create_one_order(client: httpx.AsyncClient, token: str, idx: int) -> tuple[float, int]:
+    """Returns (latency_ms, status_code)"""
+    # Use admin-created order (no file slicing, no rate limit)
+    form = {
+        "first_name":       ("", f"Bench{idx}"),
+        "last_name":        ("", "Test"),
+        "phone":            ("", "+38269000000"),
+        "email":            ("", f"bench_{idx}@test.local"),
+        "shipping_address": ("", "Bench Street 1"),
+        "material_id":      ("", str(MATERIAL_ID)),
+        "quantity":         ("", "1"),
+        "color_name":       ("", "White"),
+        "file_ids":         ("", "[]"),
+        "file_quantities":  ("", "[]"),
+    }
+    t0 = time.monotonic()
+    resp = await client.post(
+        f"{BASE_URL}/orders",
+        headers={"Authorization": f"Bearer {token}"},
+        files=form,
+    )
+    latency = (time.monotonic() - t0) * 1000
+    return latency, resp.status_code, resp.json().get("order_id") or resp.json().get("id")
+
+
+async def update_order_status(client: httpx.AsyncClient, token: str, order_id: int) -> tuple[float, int]:
+    t0 = time.monotonic()
+    resp = await client.patch(
+        f"{BASE_URL}/orders/{order_id}",
+        headers={"Authorization": f"Bearer {token}"},
+        json={"status": "processing"},
+    )
+    latency = (time.monotonic() - t0) * 1000
+    body = resp.text[:200]
+    return latency, resp.status_code, body
+
+
+async def delete_order(client: httpx.AsyncClient, token: str, order_id: int) -> tuple[float, int]:
+    t0 = time.monotonic()
+    resp = await client.delete(
+        f"{BASE_URL}/orders/{order_id}/admin",
+        headers={"Authorization": f"Bearer {token}"},
+    )
+    latency = (time.monotonic() - t0) * 1000
+    return latency, resp.status_code
+
+
+def print_stats(label: str, latencies: list[float], duration: float, count: int):
+    ok = len(latencies)
+    rps = ok / duration
+    rpm = rps * 60
+    print(f"\n-- {label} --")
+    print(f"  Requests sent   : {count}")
+    print(f"  Succeeded (2xx) : {ok}")
+    print(f"  Failed          : {count - ok}")
+    if latencies:
+        print(f"  Avg latency     : {statistics.mean(latencies):.0f} ms")
+        print(f"  P50             : {statistics.median(latencies):.0f} ms")
+        print(f"  P95             : {sorted(latencies)[int(len(latencies)*0.95)]:.0f} ms")
+        print(f"  Max             : {max(latencies):.0f} ms")
+    print(f"  Total time      : {duration:.2f}s")
+    print(f"  Throughput      : {rps:.1f} req/s  ~  {rpm:.0f} req/min")
+
+
+async def run_concurrent(tasks):
+    """Run tasks in batches of CONCURRENCY"""
+    results = []
+    for i in range(0, len(tasks), CONCURRENCY):
+        batch = tasks[i:i + CONCURRENCY]
+        batch_results = await asyncio.gather(*batch, return_exceptions=True)
+        results.extend(batch_results)
+    return results
+
+
+async def bench_create(client, token) -> list[int]:
+    """Benchmark creation, return list of created order IDs."""
+    tasks = [create_one_order(client, token, i) for i in range(ITERATIONS)]
+    
+    print(f"\n>>  Creating {ITERATIONS} orders ({CONCURRENCY} concurrent)...")
+    t_start = time.monotonic()
+    raw = await run_concurrent(tasks)
+    duration = time.monotonic() - t_start
+
+    latencies, order_ids = [], []
+    for r in raw:
+        if isinstance(r, Exception):
+            print(f"  [error] {r}")
+            continue
+        lat, code, order_id = r
+        if 200 <= code < 300 and order_id:
+            latencies.append(lat)
+            order_ids.append(order_id)
+        else:
+            print(f"  [fail] status={code}")
+
+    print_stats("CREATE", latencies, duration, ITERATIONS)
+    return order_ids
+
+
+async def bench_update(client, token, order_ids: list[int]):
+    """Benchmark status update for all created orders."""
+    tasks = [update_order_status(client, token, oid) for oid in order_ids]
+
+    print(f"\n>>  Updating {len(order_ids)} orders ({CONCURRENCY} concurrent)...")
+    t_start = time.monotonic()
+    raw = await run_concurrent(tasks)
+    duration = time.monotonic() - t_start
+
+    latencies = []
+    for r in raw:
+        if isinstance(r, Exception):
+            print(f"  [error] {r}")
+            continue
+        lat, code, body = r
+        if 200 <= code < 300:
+            latencies.append(lat)
+        else:
+            print(f"  [fail] status={code} body={body}")
+
+    print_stats("UPDATE STATUS", latencies, duration, len(order_ids))
+
+
+async def bench_delete(client, token, order_ids: list[int]):
+    """Benchmark deletion for all created orders."""
+    tasks = [delete_order(client, token, oid) for oid in order_ids]
+
+    print(f"\n>>  Deleting {len(order_ids)} orders ({CONCURRENCY} concurrent)...")
+    t_start = time.monotonic()
+    raw = await run_concurrent(tasks)
+    duration = time.monotonic() - t_start
+
+    latencies = []
+    for r in raw:
+        if isinstance(r, Exception):
+            continue
+        lat, code = r
+        if 200 <= code < 300:
+            latencies.append(lat)
+
+    print_stats("DELETE", latencies, duration, len(order_ids))
+
+
+async def main():
+    print("=" * 50)
+    print(" Radionica3D API Throughput Benchmark")
+    print(f" Target : {BASE_URL}")
+    print(f" Concurrency : {CONCURRENCY}   Iterations : {ITERATIONS}")
+    print("=" * 50)
+
+    limits = httpx.Limits(max_connections=CONCURRENCY + 5, max_keepalive_connections=CONCURRENCY)
+    async with httpx.AsyncClient(timeout=30.0, limits=limits) as client:
+        try:
+            token = await get_admin_token(client)
+        except Exception as e:
+            print(f"Login failed: {e}")
+            return
+
+        # 1. Create
+        order_ids = await bench_create(client, token)
+        if not order_ids:
+            print("\nNo orders created, stopping.")
+            return
+
+        # 2. Update
+        await bench_update(client, token, order_ids)
+
+        # 3. Delete
+        await bench_delete(client, token, order_ids)
+
+    print("\n[OK] Benchmark complete. Check server logs for any errors.")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 107 - 0
backend/scratch/load_test.py

@@ -0,0 +1,107 @@
+import asyncio
+import httpx
+import websockets
+import json
+import time
+import random
+
+BASE_URL = "http://localhost:8000"
+WS_URL = "ws://localhost:8000"
+
+async def simulate_user(user_id):
+    """Simulates a single user behavior"""
+    email = f"testuser_{user_id}@example.com"
+    password = "password123"
+    
+    async with httpx.AsyncClient() as client:
+        # 0. Try to register first (in case users don't exist)
+        try:
+            await client.post(f"{BASE_URL}/auth/register", json={
+                "email": email,
+                "password": password,
+                "first_name": "Test",
+                "last_name": "User",
+                "phone": "123456"
+            })
+        except: pass
+
+        # 1. Try to login
+        print(f"[User {user_id}] Attempting login...")
+        for i in range(5):
+            try:
+                resp = await client.post(f"{BASE_URL}/auth/login", json={
+                    "email": email,
+                    "password": password
+                })
+                if resp.status_code == 200:
+                    token = resp.json().get("access_token")
+                    print(f"[User {user_id}] Login successful on attempt {i+1}")
+                    break
+                elif resp.status_code == 403:
+                    print(f"[User {user_id}] Login failed: Captcha required or rate limited")
+                    # If captcha is required, this user stops login attempts in this simple script
+                    return 
+            except Exception as e:
+                print(f"[User {user_id}] Login error: {e}")
+                return
+        else:
+            print(f"[User {user_id}] Failed to login after 5 attempts")
+            return
+
+        # 2. Connect to global websocket
+        ws_uri = f"{WS_URL}/auth/ws/global?token={token}"
+        try:
+            async with websockets.connect(ws_uri) as ws:
+                print(f"[User {user_id}] WebSocket connected")
+                
+                # 3. Simulate order creation (to test 1-min rate limit)
+                # We try twice rapidly
+                for i in range(2):
+                    order_data = {
+                        "first_name": "Test",
+                        "last_name": "User",
+                        "phone": "123456",
+                        "email": email,
+                        "shipping_address": "Test Load Street",
+                        "material_id": 1,
+                        "quantity": 1
+                    }
+                    # Need to use form-data with file
+                    files = {'file': ('model.stl', b'binary content stl', 'application/octet-stream')}
+                    resp = await client.post(f"{BASE_URL}/orders", data=order_data, files=files)
+                    
+                    if resp.status_code == 200:
+                        print(f"[User {user_id}] Order {i+1} created successfully")
+                    elif resp.status_code == 429:
+                        print(f"[User {user_id}] Order {i+1} REJECTED (Rate Limit Working!)")
+                    else:
+                        print(f"[User {user_id}] Order {i+1} failed: {resp.status_code}")
+                
+                # 4. Stay connected for a bit to receive potential updates
+                # and send pings
+                for _ in range(5):
+                    await ws.send("ping")
+                    try:
+                        # Wait for a message with timeout
+                        msg = await asyncio.wait_for(ws.recv(), timeout=2.0)
+                        print(f"[User {user_id}] WS Received: {msg[:50]}...")
+                    except asyncio.TimeoutError:
+                        pass
+                    await asyncio.sleep(1)
+                    
+        except Exception as e:
+            print(f"[User {user_id}] WS/Order error: {e}")
+
+async def main():
+    print("--- Starting Load Test ---")
+    start_time = time.time()
+    
+    # Run 10 concurrent users
+    tasks = [simulate_user(i) for i in range(10)]
+    await asyncio.gather(*tasks)
+    
+    end_time = time.time()
+    print(f"--- Load Test Finished in {end_time - start_time:.2f}s ---")
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 13 - 0
backend/services/global_manager.py

@@ -82,5 +82,18 @@ class GlobalConnectionManager:
                 except:
                 except:
                     pass
                     pass
             # Connections will be removed via disconnect() called on close
             # Connections will be removed via disconnect() called on close
+            pass
+
+    async def notify_order_update(self, user_id: int, order_id: int):
+        if user_id in self.active_connections:
+            payload = json.dumps({
+                "type": "order_updated",
+                "order_id": order_id
+            })
+            for ws in self.active_connections[user_id]:
+                try:
+                    await ws.send_text(payload)
+                except:
+                    pass
 
 
 global_manager = GlobalConnectionManager()
 global_manager = GlobalConnectionManager()

+ 68 - 0
backend/services/rate_limit_service.py

@@ -0,0 +1,68 @@
+import time
+from session_utils import r
+
+# Configuration
+MAX_ATTEMPTS_BEFORE_CAPTCHA = 3
+RATE_LIMIT_LOGIN_WINDOW = 3600  # 1 hour
+MAX_LOGIN_ATTEMPTS_PER_WINDOW = 20
+
+class RateLimitService:
+    def get_login_attempts(self, email: str, ip: str) -> int:
+        """Get the number of failed login attempts for this email and IP"""
+        # We use a combined key to prevent distributed brute force on one account 
+        # and IP-based spraying.
+        count_ip = r.get(f"login_fail_ip:{ip}") or 0
+        count_email = r.get(f"login_fail_email:{email}") or 0
+        return max(int(count_ip), int(count_email))
+
+    def record_failed_attempt(self, email: str, ip: str):
+        """Increment failure counts for IP and Email"""
+        # Increment IP failures (expires in 1 hour)
+        r.incr(f"login_fail_ip:{ip}")
+        r.expire(f"login_fail_ip:{ip}", RATE_LIMIT_LOGIN_WINDOW)
+        
+        # Increment Email failures (expires in 1 hour)
+        r.incr(f"login_fail_email:{email}")
+        r.expire(f"login_fail_email:{email}", RATE_LIMIT_LOGIN_WINDOW)
+
+    def reset_attempts(self, email: str, ip: str):
+        """Reset counts after successful login"""
+        r.delete(f"login_fail_ip:{ip}")
+        r.delete(f"login_fail_email:{email}")
+
+    def is_captcha_required(self, email: str, ip: str) -> bool:
+        """Check if captcha should be presented to the user"""
+        return self.get_login_attempts(email, ip) >= MAX_ATTEMPTS_BEFORE_CAPTCHA
+
+    def is_rate_limited(self, email: str, ip: str) -> bool:
+        """Check if global rate limit for these identifiers is exceeded"""
+        return self.get_login_attempts(email, ip) >= MAX_LOGIN_ATTEMPTS_PER_WINDOW
+
+    async def verify_captcha(self, captcha_token: str) -> bool:
+        """
+        Stub for captcha verification logic.
+        In production, this would call Google reCAPTCHA or Cloudflare Turnstile API.
+        """
+        if not captcha_token:
+            return False
+        
+        # For demonstration purposes, we'll accept 'valid-captcha-token'
+        # In a real app, we'd use requests.post to a verification endpoint.
+        if captcha_token == "valid-captcha-token":
+             return True
+             
+        # Verification failing for everything else for now to show the logic
+        return False
+
+    def is_order_flooding(self, email: str, ip: str) -> bool:
+        """Check if user/ip is placing orders too frequently (limit: 1 per minute)"""
+        if r.exists(f"order_limit_ip:{ip}") or r.exists(f"order_limit_email:{email}"):
+            return True
+        return False
+
+    def record_order_placement(self, email: str, ip: str):
+        """Record order placement and set 60s cooldown"""
+        r.setex(f"order_limit_ip:{ip}", 60, "1")
+        r.setex(f"order_limit_email:{email}", 60, "1")
+
+rate_limit_service = RateLimitService()

BIN
src/assets/hero-premium.png


+ 8 - 8
src/components/Header.vue

@@ -3,7 +3,7 @@
     <div class="container mx-auto px-4">
     <div class="container mx-auto px-4">
       <div class="flex items-center justify-between h-14 lg:h-16">
       <div class="flex items-center justify-between h-14 lg:h-16">
         <!-- Logo -->
         <!-- Logo -->
-        <RouterLink :to="`/${activeLang}/`"><Logo /></RouterLink>
+        <RouterLink :to="{ name: 'home', params: { lang: activeLang } }"><Logo /></RouterLink>
 
 
         <!-- Desktop Nav -->
         <!-- Desktop Nav -->
         <nav class="hidden lg:flex items-center gap-8">
         <nav class="hidden lg:flex items-center gap-8">
@@ -23,8 +23,8 @@
           <div class="hidden lg:flex items-center gap-2">
           <div class="hidden lg:flex items-center gap-2">
           <RouterLink
           <RouterLink
             v-if="isAdmin"
             v-if="isAdmin"
-            :to="`/${activeLang}/admin`"
-            class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary hover:bg-primary/10 rounded-lg transition-colors"
+            :to="{ name: 'admin', params: { lang: activeLang } }"
+            class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary hover:bg-primary/10 rounded-lg transition-colors cursor-pointer"
           >
           >
             <LayoutPanelTop class="w-4 h-4" />
             <LayoutPanelTop class="w-4 h-4" />
             {{ t("nav.admin") }}
             {{ t("nav.admin") }}
@@ -43,8 +43,8 @@
 
 
           <RouterLink
           <RouterLink
             v-if="isLoggedIn"
             v-if="isLoggedIn"
-            :to="`/${activeLang}/orders`"
-            class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium hover:bg-secondary rounded-lg transition-colors"
+            :to="{ name: 'orders', params: { lang: activeLang } }"
+            class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium hover:bg-secondary rounded-lg transition-colors cursor-pointer"
           >
           >
             <PackageCheck class="w-4 h-4" />
             <PackageCheck class="w-4 h-4" />
             {{ t("nav.myOrders") }}
             {{ t("nav.myOrders") }}
@@ -102,7 +102,7 @@
 
 
           <RouterLink
           <RouterLink
             v-if="isAdmin"
             v-if="isAdmin"
-            :to="`/${activeLang}/admin`"
+            :to="{ name: 'admin', params: { lang: activeLang } }"
             class="flex items-center gap-2 text-primary py-2"
             class="flex items-center gap-2 text-primary py-2"
             @click="mobileOpen = false"
             @click="mobileOpen = false"
           >
           >
@@ -121,7 +121,7 @@
 
 
           <RouterLink
           <RouterLink
             v-if="isLoggedIn"
             v-if="isLoggedIn"
-            :to="`/${activeLang}/orders`"
+            :to="{ name: 'orders', params: { lang: activeLang } }"
             class="flex items-center gap-2 text-muted-foreground hover:text-foreground py-2"
             class="flex items-center gap-2 text-muted-foreground hover:text-foreground py-2"
             @click="mobileOpen = false"
             @click="mobileOpen = false"
           >
           >
@@ -166,7 +166,7 @@ const { t } = useI18n();
 const router = useRouter();
 const router = useRouter();
 const authStore = useAuthStore();
 const authStore = useAuthStore();
 const mobileOpen = ref(false);
 const mobileOpen = ref(false);
-const activeLang = computed(() => currentLanguage());
+const activeLang = computed(() => currentLanguage() || localStorage.getItem('locale') || 'en');
 
 
 const isLoggedIn = computed(() => !!authStore.user);
 const isLoggedIn = computed(() => !!authStore.user);
 const isAdmin = computed(() => authStore.user?.role === "admin");
 const isAdmin = computed(() => authStore.user?.role === "admin");

+ 5 - 5
src/components/HeroSection.vue

@@ -49,12 +49,12 @@
         </div>
         </div>
 
 
         <!-- Image -->
         <!-- Image -->
-        <div class="relative animate-float hidden lg:block pr-8">
-          <div class="relative z-10 p-2 bg-white rounded-[2rem] shadow-[0_20px_50px_rgba(0,0,0,0.1)] border border-black/[0.02]">
-            <img src="/src/assets/hero-3d-print.jpg" alt="3D Printed Professional Prototyping" class="w-full h-auto rounded-[1.5rem]" />
+        <div class="relative animate-float hidden lg:block">
+          <div class="relative z-10 p-1.5 bg-white/50 backdrop-blur-sm rounded-[2.5rem] shadow-[0_32px_64px_rgba(0,0,0,0.08)] border border-black/[0.03]">
+            <img src="/src/assets/hero-premium.png" alt="High Precision 3D Printing" class="w-full h-auto rounded-[2rem] aspect-[4/3] object-cover" />
           </div>
           </div>
-          <div class="absolute -top-8 -right-8 w-32 h-32 border-2 border-primary/30 rounded-2xl animate-pulse" />
-          <div class="absolute -bottom-8 -left-8 w-24 h-24 bg-primary/10 rounded-2xl blur-xl" />
+          <div class="absolute -top-6 -right-6 w-32 h-32 border border-primary/20 rounded-[2rem] animate-pulse" />
+          <div class="absolute -bottom-10 -left-10 w-40 h-40 bg-primary/5 rounded-full blur-3xl" />
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

+ 1 - 9
src/components/ModelUploadSection.vue

@@ -49,7 +49,7 @@
 
 
         <div class="space-y-1.5">
         <div class="space-y-1.5">
           <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
           <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
-            {{ t("upload.address") }} *
+            {{ t("upload.shippingAddress") }} *
           </label>
           </label>
           <textarea v-model="address" required :placeholder="t('upload.addressPlaceholder')" rows="2"
           <textarea v-model="address" required :placeholder="t('upload.addressPlaceholder')" rows="2"
             class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-3 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium resize-none" />
             class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-3 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium resize-none" />
@@ -198,14 +198,6 @@
           </div>
           </div>
         </div>
         </div>
 
 
-        <!-- Shipping Address -->
-        <div class="space-y-1.5">
-          <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
-            {{ t("upload.shippingAddress") }} *
-          </label>
-          <textarea v-model="address" rows="3" required :placeholder="t('upload.addressPlaceholder')"
-            class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-3 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium resize-none" />
-        </div>
 
 
         <!-- File list -->
         <!-- File list -->
         <div v-if="files.length" class="space-y-3">
         <div v-if="files.length" class="space-y-3">

+ 6 - 2
src/locales/en.json

@@ -56,7 +56,9 @@
       "email": "Email",
       "email": "Email",
       "individual": "Individual",
       "individual": "Individual",
       "newPassword": "New Password",
       "newPassword": "New Password",
-      "password": "Password"
+      "password": "Password",
+      "captcha_label": "Security Phrase: Type \"Radionica3D\"",
+      "captcha_placeholder": "Radionica3D"
     },
     },
     "forgot": {
     "forgot": {
       "link": "Forgot Password?",
       "link": "Forgot Password?",
@@ -204,7 +206,8 @@
     "open": "Chat",
     "open": "Chat",
     "placeholder": "Type a message...",
     "placeholder": "Type a message...",
     "title": "Order Chat",
     "title": "Order Chat",
-    "unread": "New message"
+    "unread": "New message",
+    "new_status": "Status Update: Order #{id}"
   },
   },
   "common": {
   "common": {
     "back": "Back",
     "back": "Back",
@@ -485,6 +488,7 @@
   },
   },
   "nav": {
   "nav": {
     "admin": "Admin",
     "admin": "Admin",
+    "captcha_required": "Security verification required. Please verify you are human.",
     "adminPanel": "Admin Panel",
     "adminPanel": "Admin Panel",
     "howItWorks": "How it works",
     "howItWorks": "How it works",
     "logIn": "Log In",
     "logIn": "Log In",

+ 6 - 2
src/locales/me.json

@@ -56,7 +56,9 @@
       "email": "Email",
       "email": "Email",
       "individual": "Fizičko lice",
       "individual": "Fizičko lice",
       "newPassword": "Nova lozinka",
       "newPassword": "Nova lozinka",
-      "password": "Lozinka"
+      "password": "Lozinka",
+      "captcha_label": "Kontrolna fraza: Unesite \"Radionica3D\"",
+      "captcha_placeholder": "Radionica3D"
     },
     },
     "forgot": {
     "forgot": {
       "link": "Zaboravljena lozinka?",
       "link": "Zaboravljena lozinka?",
@@ -201,9 +203,10 @@
   "chat": {
   "chat": {
     "admin": "Podrška",
     "admin": "Podrška",
     "empty": "Još nema poruka. Započnite razgovor!",
     "empty": "Još nema poruka. Započnite razgovor!",
+    "new_status": "Ažuriranje porudžbine #{id}",
     "open": "Čat",
     "open": "Čat",
     "placeholder": "Upišite poruku...",
     "placeholder": "Upišite poruku...",
-    "title": "Čat za narudžbu",
+    "title": "Chat po porudžbini",
     "unread": "Nova poruka"
     "unread": "Nova poruka"
   },
   },
   "common": {
   "common": {
@@ -485,6 +488,7 @@
   },
   },
   "nav": {
   "nav": {
     "admin": "Admin",
     "admin": "Admin",
+    "captcha_required": "Potrebna sigurnosna provjera. Molimo potvrdite da ste čovjek.",
     "adminPanel": "Admin panel",
     "adminPanel": "Admin panel",
     "howItWorks": "Kako to funkcioniše",
     "howItWorks": "Kako to funkcioniše",
     "logIn": "Prijavi se",
     "logIn": "Prijavi se",

+ 7 - 3
src/locales/ru.json

@@ -56,7 +56,9 @@
       "email": "Email",
       "email": "Email",
       "individual": "Частное лицо",
       "individual": "Частное лицо",
       "newPassword": "Новый пароль",
       "newPassword": "Новый пароль",
-      "password": "Пароль"
+      "password": "Пароль",
+      "captcha_label": "Контрольная фраза: Введите \"Radionica3D\"",
+      "captcha_placeholder": "Radionica3D"
     },
     },
     "forgot": {
     "forgot": {
       "link": "Забыли пароль?",
       "link": "Забыли пароль?",
@@ -204,7 +206,8 @@
     "open": "Чат",
     "open": "Чат",
     "placeholder": "Напишите сообщение...",
     "placeholder": "Напишите сообщение...",
     "title": "Чат по заказу",
     "title": "Чат по заказу",
-    "unread": "Новое сообщение"
+    "unread": "Новое сообщение",
+    "new_status": "Обновление заказа #{id}"
   },
   },
   "common": {
   "common": {
     "back": "Назад",
     "back": "Назад",
@@ -484,7 +487,8 @@
     "uploadButton": "Заказать печать"
     "uploadButton": "Заказать печать"
   },
   },
   "nav": {
   "nav": {
-    "admin": "Админ",
+    "admin": "Админ-панель",
+    "captcha_required": "Требуется проверка безопасности. Пожалуйста, подтвердите, что вы человек.",
     "adminPanel": "Панель управления",
     "adminPanel": "Панель управления",
     "howItWorks": "Как это работает",
     "howItWorks": "Как это работает",
     "logIn": "Войти",
     "logIn": "Войти",

+ 6 - 2
src/locales/ua.json

@@ -56,7 +56,9 @@
       "email": "Email",
       "email": "Email",
       "individual": "Приватна особа",
       "individual": "Приватна особа",
       "newPassword": "Новий пароль",
       "newPassword": "Новий пароль",
-      "password": "Пароль"
+      "password": "Пароль",
+      "captcha_label": "Контрольна фраза: Введіть \"Radionica3D\"",
+      "captcha_placeholder": "Radionica3D"
     },
     },
     "forgot": {
     "forgot": {
       "link": "Забули свій пароль?",
       "link": "Забули свій пароль?",
@@ -201,6 +203,7 @@
   "chat": {
   "chat": {
     "admin": "Підтримка",
     "admin": "Підтримка",
     "empty": "Повідомлень поки що немає. Почніть діалог!",
     "empty": "Повідомлень поки що немає. Почніть діалог!",
+    "new_status": "Оновлення замовлення #{id}",
     "open": "Чат",
     "open": "Чат",
     "placeholder": "Напишіть повідомлення...",
     "placeholder": "Напишіть повідомлення...",
     "title": "Чат на замовлення",
     "title": "Чат на замовлення",
@@ -484,7 +487,8 @@
     "uploadButton": "Замовити друк"
     "uploadButton": "Замовити друк"
   },
   },
   "nav": {
   "nav": {
-    "admin": "Адмін",
+    "admin": "Адмін-панель",
+    "captcha_required": "Потрібна перевірка безпеки. Будь ласка, підтвердіть, що ви людина.",
     "adminPanel": "Панель управління",
     "adminPanel": "Панель управління",
     "howItWorks": "Як це працює",
     "howItWorks": "Як це працює",
     "logIn": "Увійти",
     "logIn": "Увійти",

+ 2 - 2
src/pages/Admin.vue

@@ -84,7 +84,7 @@
             <!-- Info -->
             <!-- Info -->
             <div class="p-6 lg:w-1/4">
             <div class="p-6 lg:w-1/4">
               <div class="flex items-center justify-between mb-4">
               <div class="flex items-center justify-between mb-4">
-                <span class="text-xs font-bold text-muted-foreground border border-border/50 rounded-full px-2 py-0.5 uppercase tracking-tighter">{{ t("common.orderId", { id: order.id }) }}</span>
+                <span class="text-xl font-black text-foreground bg-primary/10 px-3 py-1 rounded-xl tracking-tight">{{ t("common.orderId", { id: order.id }) }}</span>
                 <span :class="`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusColor(order.status)}`">
                 <span :class="`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusColor(order.status)}`">
                   <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t("statuses." + order.status) }}
                   <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t("statuses." + order.status) }}
                 </span>
                 </span>
@@ -570,7 +570,7 @@
                     </span>
                     </span>
                   </td>
                   </td>
                   <td class="p-4 whitespace-nowrap">
                   <td class="p-4 whitespace-nowrap">
-                    <span v-if="log.target_type" class="text-[10px] font-bold text-muted-foreground">
+                    <span v-if="log.target_type" class="text-sm font-black text-primary uppercase">
                       {{ log.target_type }} #{{ log.target_id }}
                       {{ log.target_type }} #{{ log.target_id }}
                     </span>
                     </span>
                   </td>
                   </td>

+ 35 - 3
src/pages/Auth.vue

@@ -30,6 +30,13 @@
           </Transition>
           </Transition>
         </div>
         </div>
 
 
+        <!-- Captcha Warning -->
+        <div v-if="requiresCaptcha && mode === 'login'" 
+          class="mb-6 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
+          <ShieldAlert class="w-5 h-5 text-amber-500" />
+          <p class="text-[11px] font-semibold text-amber-500">{{ t("nav.captcha_required") }}</p>
+        </div>
+
         <form @submit.prevent="handleSubmit" class="space-y-5">
         <form @submit.prevent="handleSubmit" class="space-y-5">
           <!-- Email -->
           <!-- Email -->
           <div v-if="mode !== 'reset'" class="space-y-2">
           <div v-if="mode !== 'reset'" class="space-y-2">
@@ -43,6 +50,18 @@
             </div>
             </div>
           </div>
           </div>
 
 
+          <!-- Captcha Field -->
+          <div v-if="mode === 'login' && requiresCaptcha" class="space-y-2 animate-in zoom-in-95">
+             <label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground ml-1">{{ t("auth.fields.captcha_label") }}</label>
+             <div class="relative group">
+               <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-muted-foreground group-focus-within:text-primary transition-colors">
+                 <ShieldCheck class="w-4 h-4" />
+               </div>
+               <input v-model="formData.captcha" type="text" :placeholder="t('auth.fields.captcha_placeholder')"
+                 class="w-full bg-background/50 border border-border/50 rounded-xl pl-11 pr-4 py-3 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all text-sm" />
+             </div>
+          </div>
+
           <!-- Reset Token -->
           <!-- Reset Token -->
           <div v-if="mode === 'reset'" class="space-y-2">
           <div v-if="mode === 'reset'" class="space-y-2">
             <label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground ml-1">{{ t("auth.reset.token") }}</label>
             <label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground ml-1">{{ t("auth.reset.token") }}</label>
@@ -208,7 +227,7 @@ import { ref, reactive, onMounted } from "vue";
 import { useRouter, useRoute } from "vue-router";
 import { useRouter, useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { useI18n } from "vue-i18n";
 import { toast } from "vue-sonner";
 import { toast } from "vue-sonner";
-import { Mail, Lock, ArrowRight, Loader2, ShieldCheck, KeyRound, ArrowLeft } from "lucide-vue-next";
+import { Mail, Lock, ArrowRight, Loader2, ShieldCheck, KeyRound, ArrowLeft, ShieldAlert } from "lucide-vue-next";
 import Button from "@/components/ui/button.vue";
 import Button from "@/components/ui/button.vue";
 import Logo from "@/components/Logo.vue";
 import Logo from "@/components/Logo.vue";
 import LanguageSwitcher from "@/components/LanguageSwitcher.vue";
 import LanguageSwitcher from "@/components/LanguageSwitcher.vue";
@@ -223,6 +242,7 @@ const route = useRoute();
 const authStore = useAuthStore();
 const authStore = useAuthStore();
 const mode = ref<AuthMode>("login");
 const mode = ref<AuthMode>("login");
 const isLoading = ref(false);
 const isLoading = ref(false);
+const requiresCaptcha = ref(false);
 const formData = reactive({ 
 const formData = reactive({ 
   email: "", 
   email: "", 
   password: "", 
   password: "", 
@@ -235,7 +255,8 @@ const formData = reactive({
   is_company: false,
   is_company: false,
   company_name: "",
   company_name: "",
   company_pib: "",
   company_pib: "",
-  company_address: ""
+  company_address: "",
+  captcha: ""
 });
 });
 
 
 onMounted(() => {
 onMounted(() => {
@@ -250,6 +271,7 @@ function getSubtitle() {
   return { login: t("auth.login.subtitle"), register: t("auth.register.subtitle"), forgot: t("auth.forgot.subtitle"), reset: t("auth.reset.subtitle") }[mode.value];
   return { login: t("auth.login.subtitle"), register: t("auth.register.subtitle"), forgot: t("auth.forgot.subtitle"), reset: t("auth.reset.subtitle") }[mode.value];
 }
 }
 function toggleMode() {
 function toggleMode() {
+  requiresCaptcha.value = false;
   if (mode.value === "login") mode.value = "register";
   if (mode.value === "login") mode.value = "register";
   else if (mode.value === "register") mode.value = "login";
   else if (mode.value === "register") mode.value = "login";
   else mode.value = "login";
   else mode.value = "login";
@@ -262,7 +284,14 @@ async function handleSubmit() {
   isLoading.value = true;
   isLoading.value = true;
   try {
   try {
     if (mode.value === "login") {
     if (mode.value === "login") {
-      const res = await loginUser({ email: formData.email, password: formData.password });
+      // For our MOCK captcha, we send 'valid-captcha-token' if the text matches (case-insensitive)
+      const captchaToken = formData.captcha.trim().toLowerCase() === "radionica3d" ? "valid-captcha-token" : formData.captcha;
+      
+      const res = await loginUser({ 
+        email: formData.email, 
+        password: formData.password,
+        captcha_token: requiresCaptcha.value ? captchaToken : undefined
+      });
       localStorage.setItem("token", res.access_token);
       localStorage.setItem("token", res.access_token);
       await authStore.refreshUser();
       await authStore.refreshUser();
       toast.success(t("auth.toasts.welcomeBack"));
       toast.success(t("auth.toasts.welcomeBack"));
@@ -293,6 +322,9 @@ async function handleSubmit() {
       mode.value = "login";
       mode.value = "login";
     }
     }
   } catch (err: any) {
   } catch (err: any) {
+    if (err.message.includes("captcha") || err.message.includes("подтверждение") || err.message.includes("капча")) {
+      requiresCaptcha.value = true;
+    }
     toast.error(err.message);
     toast.error(err.message);
   } finally {
   } finally {
     isLoading.value = false;
     isLoading.value = false;

+ 31 - 14
src/pages/Orders.vue

@@ -135,21 +135,22 @@
                 <template v-for="file in order.files" :key="file.id">
                 <template v-for="file in order.files" :key="file.id">
                   <div v-if="file.id" 
                   <div v-if="file.id" 
                     class="bg-background/40 border border-border/50 rounded-2xl overflow-hidden group/file hover:border-primary/30 transition-all flex flex-col h-full">
                     class="bg-background/40 border border-border/50 rounded-2xl overflow-hidden group/file hover:border-primary/30 transition-all flex flex-col h-full">
-                  <div class="aspect-square bg-muted/20 flex items-center justify-center p-2 relative overflow-hidden">
-                    <img v-if="file.preview_path" :src="`http://localhost:8000/${file.preview_path}`" class="w-full h-full object-contain" />
-                    <FileBox v-else class="w-8 h-8 text-muted-foreground/20" />
-                    <div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/file:opacity-100 transition-opacity flex items-center justify-center">
-                       <a :href="`http://localhost:8000/${file.file_path}`" target="_blank" class="bg-card w-8 h-8 rounded-full flex items-center justify-center shadow-lg"><Download class="w-4 h-4 text-primary" /></a>
+                    <div class="aspect-square bg-muted/20 flex items-center justify-center p-2 relative overflow-hidden">
+                      <img v-if="file.preview_path" :src="`http://localhost:8000/${file.preview_path}`" class="w-full h-full object-contain" />
+                      <FileBox v-else class="w-8 h-8 text-muted-foreground/20" />
+                      <div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/file:opacity-100 transition-opacity flex items-center justify-center">
+                         <a :href="`http://localhost:8000/${file.file_path}`" target="_blank" class="bg-card w-8 h-8 rounded-full flex items-center justify-center shadow-lg"><Download class="w-4 h-4 text-primary" /></a>
+                      </div>
                     </div>
                     </div>
-                  </div>
-                  <div class="p-3">
-                    <p class="text-[10px] font-bold truncate">{{ file.filename }}</p>
-                    <div class="flex items-center gap-2 mt-1">
-                      <span v-if="file.quantity > 1" class="text-[8px] font-bold text-primary">x{{ file.quantity }}</span>
-                    </div>
-                    <div v-if="file.print_time || file.filament_g" class="flex flex-col gap-0.5 mt-2 pt-2 border-t border-border/10">
-                      <span v-if="file.print_time" class="text-[8px] text-primary/80">⏱️ {{ file.print_time }}</span>
-                      <span v-if="file.filament_g" class="text-[8px] text-primary/80">⚖️ {{ file.filament_g.toFixed(1) }}g</span>
+                    <div class="p-3">
+                      <p class="text-[10px] font-bold truncate">{{ file.filename }}</p>
+                      <div class="flex items-center gap-2 mt-1">
+                        <span v-if="file.quantity > 1" class="text-[8px] font-bold text-primary">x{{ file.quantity }}</span>
+                      </div>
+                      <div v-if="file.print_time || file.filament_g" class="flex flex-col gap-0.5 mt-2 pt-2 border-t border-border/10">
+                        <span v-if="file.print_time" class="text-[8px] text-primary/80">⏱️ {{ file.print_time }}</span>
+                        <span v-if="file.filament_g" class="text-[8px] text-primary/80">⚖️ {{ file.filament_g.toFixed(1) }}g</span>
+                      </div>
                     </div>
                     </div>
                   </div>
                   </div>
                 </template>
                 </template>
@@ -284,8 +285,24 @@ async function fetchOrders() {
 onMounted(async () => {
 onMounted(async () => {
   if (!localStorage.getItem("token")) { router.push("/auth"); return; }
   if (!localStorage.getItem("token")) { router.push("/auth"); return; }
   await fetchOrders();
   await fetchOrders();
+  
+  window.addEventListener("radionica:order_updated", handleRemoteUpdate);
+});
+
+import { onUnmounted } from "vue";
+onUnmounted(() => {
+  window.removeEventListener("radionica:order_updated", handleRemoteUpdate);
 });
 });
 
 
+async function handleRemoteUpdate(e: any) {
+  const orderId = e.detail?.order_id;
+  await fetchOrders();
+  toast.success(t("chat.new_status", { id: orderId || "" }), {
+    description: "Order data has been updated by administrator.",
+    icon: h(Package, { class: "w-4 h-4 text-emerald-500" })
+  });
+}
+
 watch(currentPage, () => {
 watch(currentPage, () => {
   fetchOrders();
   fetchOrders();
   window.scrollTo({ top: 0, behavior: 'smooth' });
   window.scrollTo({ top: 0, behavior: 'smooth' });

+ 34 - 4
src/stores/auth.ts

@@ -100,12 +100,40 @@ export const useAuthStore = defineStore("auth", () => {
           logout();
           logout();
         } else if (msg.type === "order_read") {
         } else if (msg.type === "order_read") {
           window.dispatchEvent(new CustomEvent("radionica:order_read", { detail: { order_id: msg.order_id } }));
           window.dispatchEvent(new CustomEvent("radionica:order_read", { detail: { order_id: msg.order_id } }));
+        } else if (msg.type === "order_updated") {
+          playUpdateSound();
+          window.dispatchEvent(new CustomEvent("radionica:order_updated", { detail: { order_id: msg.order_id } }));
         }
         }
       } catch (e) {
       } catch (e) {
         console.error("WS Parse error", e);
         console.error("WS Parse error", e);
       }
       }
     };
     };
 
 
+    function playUpdateSound() {
+      try {
+        const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
+        if (!AudioCtx) return;
+        const ctx = new AudioCtx();
+        const osc = ctx.createOscillator();
+        const gainNode = ctx.createGain();
+        
+        osc.type = 'sine';
+        osc.frequency.setValueAtTime(180, ctx.currentTime);
+        osc.frequency.exponentialRampToValueAtTime(140, ctx.currentTime + 0.3);
+        
+        gainNode.gain.setValueAtTime(0, ctx.currentTime);
+        gainNode.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.05);
+        gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.35);
+        
+        osc.connect(gainNode);
+        gainNode.connect(ctx.destination);
+        osc.start();
+        osc.stop(ctx.currentTime + 0.35);
+      } catch (e) {
+        console.warn("Audio disabled", e);
+      }
+    }
+
     globalWs.onclose = (event) => {
     globalWs.onclose = (event) => {
       if (pingInterval) clearInterval(pingInterval);
       if (pingInterval) clearInterval(pingInterval);
       
       
@@ -148,11 +176,13 @@ export const useAuthStore = defineStore("auth", () => {
     }
     }
   }
   }
 
 
-  function init() {
-    if (!initialized) {
-      initialized = true;
-      refreshUser();
+  let initPromise: Promise<void> | null = null;
+
+  function init(): Promise<void> {
+    if (!initPromise) {
+      initPromise = refreshUser();
     }
     }
+    return initPromise;
   }
   }
 
 
   function setUser(u: any) {
   function setUser(u: any) {