Explorar o código

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 hai 3 días
pai
achega
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 или пароль",
         "user_not_found": "Пользователь не найден",
         "invalid_token": "Недействительный токен",
-        "flood_control": "Слишком много сообщений. Пожалуйста, подождите 10 секунд."
+        "flood_control": "Слишком много сообщений. Пожалуйста, подождите 10 секунд.",
+        "captcha_required": "Требуется подтверждение (капча)",
+        "too_many_attempts": "Слишком много неудачных попыток. Пожалуйста, попробуйте позже."
     },
     "me": {
         "missing": "Ovo polje je obavezno",
@@ -21,7 +23,9 @@ ERROR_TRANSLATIONS = {
         "incorrect_credentials": "Neispravan email ili lozinka",
         "user_not_found": "Korisnik nije pronađen",
         "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": {
         "missing": "Це поле обов'язкове",
@@ -33,7 +37,9 @@ ERROR_TRANSLATIONS = {
         "incorrect_credentials": "Невірний email або пароль",
         "user_not_found": "Користувача не знайдено",
         "invalid_token": "Недійсний токен",
-        "flood_control": "Занадто багато повідомлень. Будь ласка, зачекайте 10 секунд."
+        "flood_control": "Занадто багато повідомлень. Будь ласка, зачекайте 10 секунд.",
+        "captcha_required": "Потрібне підтвердження (капча)",
+        "too_many_attempts": "Занадто багато невдалих спроб. Будь ласка, спробуйте пізніше."
     },
     "en": {
         "missing": "Field is required",
@@ -43,7 +49,9 @@ ERROR_TRANSLATIONS = {
         "incorrect_credentials": "Incorrect email or password",
         "user_not_found": "User not found",
         "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
 import db
 import auth_utils
+from dependencies import require_admin
 
 router = APIRouter(prefix="/admin", tags=["admin"])
 
@@ -10,11 +11,8 @@ async def get_audit_logs(
     page: int = Query(1, ge=1),
     size: int = Query(50, ge=1, le=100),
     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
     

+ 48 - 29
backend/routers/auth.py

@@ -1,6 +1,7 @@
 from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
 from typing import Optional, List
 from services.global_manager import global_manager
+from services.rate_limit_service import rate_limit_service
 import auth_utils
 import db
 import schemas
@@ -8,6 +9,7 @@ import session_utils
 import uuid
 from datetime import datetime, timedelta
 import locales
+from dependencies import get_current_user, require_admin
 
 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]
 
 @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']):
+        # Log failure
+        rate_limit_service.record_failed_attempt(email, ip)
         raise HTTPException(status_code=401, detail=locales.translate_error("incorrect_credentials", lang))
     
     if not user[0].get('is_active', True):
         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(
         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"}
 
 @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"}
 
 @router.post("/forgot-password")
@@ -91,18 +122,15 @@ async def reset_password(request: schemas.ResetPassword):
     return {"message": "Password reset successfully"}
 
 @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)
-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 = []
     params = []
     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]
 
 @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
     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}
 
 @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,))
     if existing_user:
@@ -159,10 +181,7 @@ async def admin_create_user(data: schemas.UserCreate, token: str = Depends(auth_
     return user[0]
 
 @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 = []
     params = []

+ 9 - 32
backend/routers/catalog.py

@@ -4,6 +4,7 @@ import db
 import schemas
 import auth_utils
 import json
+from dependencies import require_admin
 
 router = APIRouter(tags=["catalog"])
 
@@ -21,10 +22,7 @@ async def get_services():
     return db.execute_query("SELECT * FROM services WHERE is_active = TRUE")
 
 @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")
     for r in rows:
         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
 
 @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
     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)
@@ -44,10 +39,7 @@ async def admin_create_material(data: schemas.MaterialCreate, token: str = Depen
     return {"id": 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 = []
     params = []
     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}
 
 @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,))
     return {"id": mat_id, "status": "deleted"}
 
 @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")
 
 @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)"
     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}
 
 @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 = []
     params = []
     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}
 
 @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,))
     return {"id": srv_id, "status": "deleted"}

+ 7 - 10
backend/routers/chat.py

@@ -6,6 +6,7 @@ import auth_utils
 import datetime
 import schemas
 import locales
+from dependencies import get_current_user
 
 router = APIRouter(tags=["chat"])
 
@@ -13,11 +14,9 @@ router = APIRouter(tags=["chat"])
 last_message_times = {}
 
 @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
     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
@@ -44,11 +43,9 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     return 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
     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 services import pricing, order_processing, event_hooks
 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 datetime import datetime
+import locales
+from services.global_manager import global_manager
 
 router = APIRouter(prefix="/orders", tags=["orders"])
 
@@ -42,13 +46,20 @@ async def create_order(
     company_name: Optional[str] = Form(None),
     company_pib: 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_quantities = []
@@ -59,7 +70,6 @@ async def create_order(
         except:
             pass
 
-    lang = request.query_params.get("lang", "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,))
@@ -117,6 +127,10 @@ async def create_order(
                 )
         background_tasks.add_task(order_processing.process_order_slicing, 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"}
     except Exception as e:
         print(f"Error creating order: {e}")
@@ -126,13 +140,9 @@ async def create_order(
 async def get_my_orders(
     page: int = 1,
     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
 
     # Get total count
@@ -189,11 +199,8 @@ async def get_admin_orders(
     status: Optional[str] = None,
     date_from: 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 = []
     params = []
@@ -241,22 +248,20 @@ async def get_admin_orders(
         row['photos'] = photos
     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,
-    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 = []
     params = []
     if data.status:
-        order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
         if order_info:
             background_tasks.add_task(
                 event_hooks.on_order_status_changed, 
@@ -346,24 +351,25 @@ async def update_order_admin(
         
         # LOG ACTION
         await audit_service.log(
-            user_id=payload.get("id"),
+            user_id=admin.get("id"),
             action="update_order",
             target_type="order",
             target_id=order_id,
             details={"updated_fields": {k.split(" = ")[0]: v for k, v in zip(update_fields, params)}},
             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"}
 
 @router.post("/{order_id}/attach-file")
 async def admin_attach_file(
     order_id: int,
     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]}"
     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)"
     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}
 
 @router.delete("/{order_id}/files/{file_id}")
@@ -401,11 +412,8 @@ async def admin_delete_file(
     order_id: int,
     file_id: int,
     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))
     if not file_record:
@@ -424,13 +432,19 @@ async def admin_delete_file(
     
     # LOG ACTION
     await audit_service.log(
-        user_id=payload.get("id"),
+        user_id=admin.get("id"),
         action="delete_order_file",
         target_type="order",
         target_id=order_id,
         details={"file_id": file_id},
         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"}
     
 class OrderItemSchema(BaseModel):
@@ -444,11 +458,7 @@ async def get_order_items(order_id: int):
     return 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,))
     
     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
     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}
 
 @router.delete("/{order_id}/admin")
 async def delete_order_admin(
     order_id: int,
     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
     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
         await audit_service.log(
-            user_id=payload.get("id"),
+            user_id=admin.get("id"),
             action="delete_order_entirely",
             target_type="order",
             target_id=order_id,
             details={"order_id": order_id},
             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"}
     except Exception as 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 uuid
 import shutil
+from services.global_manager import global_manager
 
 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)"
     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}
 
 @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']:
         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))
+    
+    # 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}
 
 @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':
         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:
         raise HTTPException(status_code=404, detail="Photo not found")
+    
+    order_id = photo[0]['order_id']
         
     try:
         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}")
         
     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"}

+ 1 - 0
backend/schemas.py

@@ -140,6 +140,7 @@ class UserUpdate(BaseModel):
 class UserLogin(BaseModel):
     email: EmailStr
     password: str
+    captcha_token: Optional[str] = None
 
 class UserResponse(BaseModel):
     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:
                     pass
             # 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()

+ 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=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="flex items-center justify-between h-14 lg:h-16">
         <!-- Logo -->
-        <RouterLink :to="`/${activeLang}/`"><Logo /></RouterLink>
+        <RouterLink :to="{ name: 'home', params: { lang: activeLang } }"><Logo /></RouterLink>
 
         <!-- Desktop Nav -->
         <nav class="hidden lg:flex items-center gap-8">
@@ -23,8 +23,8 @@
           <div class="hidden lg:flex items-center gap-2">
           <RouterLink
             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" />
             {{ t("nav.admin") }}
@@ -43,8 +43,8 @@
 
           <RouterLink
             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" />
             {{ t("nav.myOrders") }}
@@ -102,7 +102,7 @@
 
           <RouterLink
             v-if="isAdmin"
-            :to="`/${activeLang}/admin`"
+            :to="{ name: 'admin', params: { lang: activeLang } }"
             class="flex items-center gap-2 text-primary py-2"
             @click="mobileOpen = false"
           >
@@ -121,7 +121,7 @@
 
           <RouterLink
             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"
             @click="mobileOpen = false"
           >
@@ -166,7 +166,7 @@ const { t } = useI18n();
 const router = useRouter();
 const authStore = useAuthStore();
 const mobileOpen = ref(false);
-const activeLang = computed(() => currentLanguage());
+const activeLang = computed(() => currentLanguage() || localStorage.getItem('locale') || 'en');
 
 const isLoggedIn = computed(() => !!authStore.user);
 const isAdmin = computed(() => authStore.user?.role === "admin");

+ 5 - 5
src/components/HeroSection.vue

@@ -49,12 +49,12 @@
         </div>
 
         <!-- 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 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>

+ 1 - 9
src/components/ModelUploadSection.vue

@@ -49,7 +49,7 @@
 
         <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.address") }} *
+            {{ t("upload.shippingAddress") }} *
           </label>
           <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" />
@@ -198,14 +198,6 @@
           </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 -->
         <div v-if="files.length" class="space-y-3">

+ 6 - 2
src/locales/en.json

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

+ 6 - 2
src/locales/me.json

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

+ 7 - 3
src/locales/ru.json

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

+ 6 - 2
src/locales/ua.json

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

+ 2 - 2
src/pages/Admin.vue

@@ -84,7 +84,7 @@
             <!-- Info -->
             <div class="p-6 lg:w-1/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)}`">
                   <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t("statuses." + order.status) }}
                 </span>
@@ -570,7 +570,7 @@
                     </span>
                   </td>
                   <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 }}
                     </span>
                   </td>

+ 35 - 3
src/pages/Auth.vue

@@ -30,6 +30,13 @@
           </Transition>
         </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">
           <!-- Email -->
           <div v-if="mode !== 'reset'" class="space-y-2">
@@ -43,6 +50,18 @@
             </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 -->
           <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>
@@ -208,7 +227,7 @@ import { ref, reactive, onMounted } from "vue";
 import { useRouter, useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
 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 Logo from "@/components/Logo.vue";
 import LanguageSwitcher from "@/components/LanguageSwitcher.vue";
@@ -223,6 +242,7 @@ const route = useRoute();
 const authStore = useAuthStore();
 const mode = ref<AuthMode>("login");
 const isLoading = ref(false);
+const requiresCaptcha = ref(false);
 const formData = reactive({ 
   email: "", 
   password: "", 
@@ -235,7 +255,8 @@ const formData = reactive({
   is_company: false,
   company_name: "",
   company_pib: "",
-  company_address: ""
+  company_address: "",
+  captcha: ""
 });
 
 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];
 }
 function toggleMode() {
+  requiresCaptcha.value = false;
   if (mode.value === "login") mode.value = "register";
   else if (mode.value === "register") mode.value = "login";
   else mode.value = "login";
@@ -262,7 +284,14 @@ async function handleSubmit() {
   isLoading.value = true;
   try {
     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);
       await authStore.refreshUser();
       toast.success(t("auth.toasts.welcomeBack"));
@@ -293,6 +322,9 @@ async function handleSubmit() {
       mode.value = "login";
     }
   } catch (err: any) {
+    if (err.message.includes("captcha") || err.message.includes("подтверждение") || err.message.includes("капча")) {
+      requiresCaptcha.value = true;
+    }
     toast.error(err.message);
   } finally {
     isLoading.value = false;

+ 31 - 14
src/pages/Orders.vue

@@ -135,21 +135,22 @@
                 <template v-for="file in order.files" :key="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">
-                  <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 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>
                 </template>
@@ -284,8 +285,24 @@ async function fetchOrders() {
 onMounted(async () => {
   if (!localStorage.getItem("token")) { router.push("/auth"); return; }
   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, () => {
   fetchOrders();
   window.scrollTo({ top: 0, behavior: 'smooth' });

+ 34 - 4
src/stores/auth.ts

@@ -100,12 +100,40 @@ export const useAuthStore = defineStore("auth", () => {
           logout();
         } else if (msg.type === "order_read") {
           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) {
         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) => {
       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) {