Przeglądaj źródła

feat: audit logging, user suspension, admin tab i18n fixes

unknown 6 dni temu
rodzic
commit
89e1c4851d

+ 2 - 1
backend/main.py

@@ -8,7 +8,7 @@ import os
 
 import locales
 import config
-from routers import auth, orders, catalog, portfolio, files, chat, blog
+from routers import auth, orders, catalog, portfolio, files, chat, blog, admin
 
 app = FastAPI(title="Radionica 3D API")
 
@@ -63,6 +63,7 @@ app.include_router(portfolio.router)
 app.include_router(files.router)
 app.include_router(chat.router)
 app.include_router(blog.router)
+app.include_router(admin.router)
 
 # Mount Static Files
 if not os.path.exists("uploads"):

+ 50 - 0
backend/routers/admin.py

@@ -0,0 +1,50 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from typing import List, Optional
+import db
+import auth_utils
+
+router = APIRouter(prefix="/admin", tags=["admin"])
+
+@router.get("/audit-logs")
+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)
+):
+    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
+    
+    query = """
+        SELECT a.*, u.email as user_email 
+        FROM audit_logs a
+        LEFT JOIN users u ON a.user_id = u.id
+    """
+    params = []
+    
+    if action:
+        query += " WHERE a.action = %s"
+        params.append(action)
+    
+    query += " ORDER BY a.created_at DESC LIMIT %s OFFSET %s"
+    params.extend([size, offset])
+    
+    logs = db.execute_query(query, tuple(params))
+    
+    # Total count for pagination
+    count_query = "SELECT COUNT(*) as total FROM audit_logs"
+    if action:
+        count_query += " WHERE action = %s"
+        total = db.execute_query(count_query, (action,))
+    else:
+        total = db.execute_query(count_query)
+        
+    return {
+        "logs": logs,
+        "total": total[0]['total'],
+        "page": page,
+        "size": size
+    }

+ 12 - 7
backend/routers/auth.py

@@ -27,7 +27,7 @@ async def register(request: Request, user: schemas.UserCreate, lang: str = "en")
     params = (user.email, hashed_password, user.first_name, user.last_name, user.phone, user.shipping_address, user.preferred_language, 'user', ip_address)
     
     user_id = db.execute_commit(query, params)
-    new_user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, ip_address, created_at FROM users WHERE id = %s", (user_id,))
+    new_user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, ip_address, created_at FROM users WHERE id = %s", (user_id,))
     return new_user[0]
 
 @router.post("/login", response_model=schemas.Token)
@@ -36,6 +36,9 @@ async def login(user_data: schemas.UserLogin, lang: str = "en"):
     if not user or not auth_utils.verify_password(user_data.password, user[0]['password_hash']):
         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.")
+    
     access_token = auth_utils.create_access_token(
         data={"sub": user[0]['email'], "id": user[0]['id'], "role": user[0]['role']}
     )
@@ -43,8 +46,10 @@ async def login(user_data: schemas.UserLogin, lang: str = "en"):
 
 @router.post("/social-login", response_model=schemas.Token)
 async def social_login(request: Request, data: schemas.SocialLogin):
-    user = db.execute_query("SELECT id, email, role FROM users WHERE email = %s", (data.email,))
+    user = db.execute_query("SELECT id, email, role, is_active FROM users WHERE email = %s", (data.email,))
     if user:
+        if not user[0].get('is_active', True):
+            raise HTTPException(status_code=403, detail="Your account has been suspended.")
         access_token = auth_utils.create_access_token(
             data={"sub": user[0]['email'], "id": user[0]['id'], "role": user[0]['role']}
         )
@@ -89,7 +94,7 @@ async def reset_password(request: schemas.ResetPassword):
 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, ip_address, created_at FROM users WHERE id = %s", (payload.get("id"),))
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, 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]
 
@@ -107,7 +112,7 @@ async def update_me(data: schemas.UserUpdate, token: str = Depends(auth_utils.oa
         query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = %s"
         params.append(user_id)
         db.execute_commit(query, tuple(params))
-    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, ip_address, created_at FROM users WHERE id = %s", (user_id,))
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, ip_address, created_at FROM users WHERE id = %s", (user_id,))
     return user[0]
 
 @router.get("/admin/users")
@@ -117,7 +122,7 @@ async def admin_get_users(page: int = 1, size: int = 50, search: Optional[str] =
         raise HTTPException(status_code=403, detail="Admin role required")
     
     offset = (page - 1) * size
-    base_query = "SELECT id, email, first_name, last_name, phone, role, can_chat, ip_address, created_at FROM users"
+    base_query = "SELECT id, email, first_name, last_name, phone, role, can_chat, is_active, ip_address, created_at FROM users"
     count_query = "SELECT COUNT(*) as total FROM users"
     params = []
     if search and search.strip():
@@ -150,7 +155,7 @@ async def admin_create_user(data: schemas.UserCreate, token: str = Depends(auth_
         (data.email, hashed_password, data.first_name, data.last_name, data.phone, 'user', True)
     )
     
-    user = db.execute_query("SELECT id, email, first_name, last_name, phone, role, can_chat, created_at FROM users WHERE id = %s", (user_id,))
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, role, can_chat, is_active, created_at FROM users WHERE id = %s", (user_id,))
     return user[0]
 
 @router.patch("/users/{target_id}/admin", response_model=schemas.UserResponse)
@@ -170,7 +175,7 @@ async def admin_update_user(target_id: int, data: schemas.UserUpdate, token: str
         params.append(target_id)
         db.execute_commit(query, tuple(params))
         
-    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, ip_address, created_at FROM users WHERE id = %s", (target_id,))
+    user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, ip_address, created_at FROM users WHERE id = %s", (target_id,))
     if not user: raise HTTPException(status_code=404, detail="User not found")
     return user[0]
 

+ 33 - 3
backend/routers/blog.py

@@ -5,6 +5,7 @@ from datetime import datetime
 import db
 import mysql.connector
 import auth_utils
+from services.audit_service import audit_service
 
 router = APIRouter(prefix="/blog", tags=["blog"])
 
@@ -62,7 +63,7 @@ async def get_post(id_or_slug: str):
     raise HTTPException(status_code=404, detail="Post not found")
 
 @router.post("/", response_model=Post)
-async def create_post(post: PostCreate, token: str = Depends(auth_utils.oauth2_scheme)):
+async def create_post(post: PostCreate, request: Request, 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")
@@ -84,12 +85,22 @@ async def create_post(post: PostCreate, token: str = Depends(auth_utils.oauth2_s
     try:
         new_id = db.execute_commit(query, values)
         res = db.execute_query("SELECT * FROM posts WHERE id = %s", (new_id,))
+        
+        await audit_service.log(
+            user_id=payload.get("id"),
+            action="create_blog_post",
+            target_type="blog_post",
+            target_id=new_id,
+            details={"slug": post.slug, "title": post.title_en},
+            request=request
+        )
+        
         return res[0]
     except mysql.connector.Error as err:
         raise HTTPException(status_code=400, detail=str(err))
 
 @router.put("/{post_id}", response_model=Post)
-async def update_post(post_id: int, post: PostUpdate, token: str = Depends(auth_utils.oauth2_scheme)):
+async def update_post(post_id: int, post: PostUpdate, request: Request, 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")
@@ -112,10 +123,20 @@ async def update_post(post_id: int, post: PostUpdate, token: str = Depends(auth_
     res = db.execute_query("SELECT * FROM posts WHERE id = %s", (post_id,))
     if not res:
         raise HTTPException(status_code=404, detail="Post not found")
+        
+    await audit_service.log(
+        user_id=payload.get("id"),
+        action="update_blog_post",
+        target_type="blog_post",
+        target_id=post_id,
+        details={"slug": post.slug, "title": post.title_en},
+        request=request
+    )
+    
     return res[0]
 
 @router.delete("/{post_id}")
-async def delete_post(post_id: int, token: str = Depends(auth_utils.oauth2_scheme)):
+async def delete_post(post_id: int, request: Request, 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")
@@ -123,4 +144,13 @@ async def delete_post(post_id: int, token: str = Depends(auth_utils.oauth2_schem
     # but we can check existence first or modify execute_commit.
     # For now, let's just execute it.
     db.execute_commit("DELETE FROM posts WHERE id = %s", (post_id,))
+    
+    await audit_service.log(
+        user_id=payload.get("id"),
+        action="delete_blog_post",
+        target_type="blog_post",
+        target_id=post_id,
+        request=request
+    )
+    
     return {"message": "Post deleted successfully"}

+ 23 - 0
backend/routers/orders.py

@@ -13,6 +13,7 @@ import preview_utils
 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
 
 router = APIRouter(prefix="/orders", tags=["orders"])
 
@@ -178,6 +179,7 @@ async def update_order_admin(
     order_id: int, 
     data: schemas.AdminOrderUpdate, 
     background_tasks: BackgroundTasks,
+    request: Request,
     token: str = Depends(auth_utils.oauth2_scheme)
 ):
     payload = auth_utils.decode_token(token)
@@ -245,6 +247,16 @@ async def update_order_admin(
         query = f"UPDATE orders SET {', '.join(update_fields)} WHERE id = %s"
         params.append(order_id)
         db.execute_commit(query, tuple(params))
+        
+        # LOG ACTION
+        await audit_service.log(
+            user_id=payload.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
+        )
     return {"id": order_id, "status": "updated"}
 
 @router.post("/{order_id}/attach-file")
@@ -292,6 +304,7 @@ async def admin_attach_file(
 async def admin_delete_file(
     order_id: int,
     file_id: int,
+    request: Request,
     token: str = Depends(auth_utils.oauth2_scheme)
 ):
     payload = auth_utils.decode_token(token)
@@ -312,4 +325,14 @@ async def admin_delete_file(
         print(f"Error removing file from disk: {e}")
         
     db.execute_commit("DELETE FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
+    
+    # LOG ACTION
+    await audit_service.log(
+        user_id=payload.get("id"),
+        action="delete_order_file",
+        target_type="order",
+        target_id=order_id,
+        details={"file_id": file_id},
+        request=request
+    )
     return {"status": "success"}

+ 2 - 0
backend/schemas.py

@@ -115,6 +115,7 @@ class UserUpdate(BaseModel):
     shipping_address: Optional[str] = None
     preferred_language: Optional[str] = None
     can_chat: Optional[bool] = None
+    is_active: Optional[bool] = None
 
 class UserLogin(BaseModel):
     email: EmailStr
@@ -130,6 +131,7 @@ class UserResponse(BaseModel):
     preferred_language: str = "en"
     role: str
     can_chat: bool
+    is_active: bool
     ip_address: Optional[str] = None
     created_at: datetime
     

+ 34 - 0
backend/services/audit_service.py

@@ -0,0 +1,34 @@
+import db
+import json
+from fastapi import Request
+from typing import Optional, Any
+
+class AuditService:
+    async def log(
+        self, 
+        user_id: int, 
+        action: str, 
+        target_type: Optional[str] = None, 
+        target_id: Optional[int] = None, 
+        details: Optional[Any] = None,
+        request: Optional[Request] = None
+    ):
+        ip_address = None
+        if request:
+            # Try to get real IP if behind proxy
+            ip_address = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
+        
+        details_str = None
+        if details:
+            if isinstance(details, (dict, list)):
+                details_str = json.dumps(details, ensure_ascii=False)
+            else:
+                details_str = str(details)
+        
+        query = """
+            INSERT INTO audit_logs (user_id, action, target_type, target_id, details, ip_address)
+            VALUES (%s, %s, %s, %s, %s, %s)
+        """
+        db.execute_commit(query, (user_id, action, target_type, target_id, details_str, ip_address))
+
+audit_service = AuditService()

+ 12 - 0
src/lib/api.ts

@@ -551,3 +551,15 @@ export const adminUpdateUser = async (userId: number, data: any) => {
   if (!response.ok) throw new Error("Failed to update user");
   return response.json();
 };
+
+export const adminGetAuditLogs = async (page = 1, size = 50, action = "") => {
+  const token = localStorage.getItem("token");
+  const query = new URLSearchParams({ page: page.toString(), size: size.toString() });
+  if (action) query.append("action", action);
+  
+  const response = await fetch(`${API_BASE_URL}/admin/audit-logs?${query.toString()}`, {
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to fetch audit logs");
+  return response.json();
+};

+ 17 - 4
src/locales/en.json

@@ -57,7 +57,13 @@
       "sending": "Sending...",
       "toggleAdminRole": "Toggle Admin role",
       "viewOriginal": "View Original Snapshot",
-      "saveChanges": "Save Changes"
+      "saveChanges": "Save Changes",
+      "suspendAccount": "Suspend account",
+      "activateAccount": "Activate account",
+      "makePublic": "Make Public",
+      "makePrivate": "Make Private",
+      "allowChat": "Allow Chat",
+      "forbidChat": "Forbid Chat"
     },
     "addNew": "Add New",
     "allStatuses": "All Statuses",
@@ -80,7 +86,7 @@
       "lastName": "Last Name",
       "name": "Name",
       "noPhotos": "No photos yet",
-      "noPortfolio": "No Portfolio",
+      "noPortfolio": "Do not publish in portfolio",
       "noUsers": "No users found",
       "notifyUser": "Notify User",
       "originalSnapshot": "Original Snapshot",
@@ -103,7 +109,12 @@
       "techType": "Technology Type",
       "title": "Title",
       "customColorDirInfo": "Custom Color (No directory info)",
-      "customColorPlaceholder": "Custom color..."
+      "customColorPlaceholder": "Custom color...",
+      "timestamp": "Timestamp",
+      "action": "Action",
+      "target": "Target",
+      "details": "Details",
+      "user": "User"
     },
     "labels": {
       "actions": "Actions",
@@ -164,7 +175,9 @@
       "services": "Services",
       "users": "Users",
       "posts": "Blog",
-      "portfolio": "Portfolio"
+      "portfolio": "Portfolio",
+      "blog": "Blog",
+      "audit": "Audit"
     },
     "questions": {
       "deletePhoto": "Are you sure you want to delete this photo?"

+ 18 - 5
src/locales/me.json

@@ -57,7 +57,13 @@
       "sending": "Slanje...",
       "toggleAdminRole": "Promijeni admin ulogu",
       "viewOriginal": "Vidi originalne parametre",
-      "saveChanges": "Sačuvaj promjene"
+      "saveChanges": "Sačuvaj promjene",
+      "suspendAccount": "Suspenduj nalog",
+      "activateAccount": "Aktiviraj nalog",
+      "makePublic": "Učini javnim",
+      "makePrivate": "Učini privatnim",
+      "allowChat": "Dozvoli čet",
+      "forbidChat": "Zabrani čet"
     },
     "addNew": "Dodaj novo",
     "allStatuses": "Svi statusi",
@@ -80,7 +86,7 @@
       "lastName": "Prezime",
       "name": "Naziv",
       "noPhotos": "Nema fotografija",
-      "noPortfolio": "Portfolio nije dozvoljen",
+      "noPortfolio": "Ne objavljivati u portfoliju",
       "noUsers": "Korisnici nisu pronađeni",
       "notifyUser": "Obavijesti korisnika",
       "originalSnapshot": "Originalni parametri",
@@ -103,13 +109,18 @@
       "techType": "Tip tehnologije",
       "title": "Naslov",
       "customColorDirInfo": "Prilagođena boja (nema info u direktorijumu)",
-      "customColorPlaceholder": "Prilagođena boja..."
+      "customColorPlaceholder": "Prilagođena boja...",
+      "timestamp": "Vrijeme",
+      "action": "Akcija",
+      "target": "Cilj",
+      "details": "Detalji",
+      "user": "Korisnik"
     },
     "labels": {
       "actions": "Akcije",
       "chat": "Čat",
       "contact": "Kontakt",
-      "registered": "Registracija",
+      "registered": "Registrovan",
       "role": "Uloga",
       "user": "Korisnik"
     },
@@ -164,7 +175,9 @@
       "services": "Usluge",
       "users": "Korisnici",
       "posts": "Blog",
-      "portfolio": "Portfolio"
+      "portfolio": "Portfolio",
+      "blog": "Blog",
+      "audit": "Audit"
     },
     "questions": {
       "deletePhoto": "Da li ste sigurni da želite obrisati ovu fotografiju?"

+ 17 - 4
src/locales/ru.json

@@ -57,7 +57,13 @@
       "sending": "Отправка...",
       "toggleAdminRole": "Переключить роль админа",
       "viewOriginal": "Оригинал",
-      "saveChanges": "Сохранить изменения"
+      "saveChanges": "Сохранить изменения",
+      "suspendAccount": "Заблокировать аккаунт",
+      "activateAccount": "Разблокировать аккаунт",
+      "makePublic": "Сделать публичным",
+      "makePrivate": "Сделать приватным",
+      "allowChat": "Разрешить чат",
+      "forbidChat": "Запретить чат"
     },
     "addNew": "Добавить",
     "allStatuses": "Все статусы",
@@ -80,7 +86,7 @@
       "lastName": "Фамилия",
       "name": "Название",
       "noPhotos": "Нет фото",
-      "noPortfolio": "Портфолио пусто",
+      "noPortfolio": "Не публиковать в портфолио",
       "noUsers": "Пользователи не найдены",
       "notifyUser": "Уведомить клиента",
       "originalSnapshot": "Снимок заказа",
@@ -103,7 +109,12 @@
       "techType": "Тип технологии",
       "title": "Заголовок",
       "customColorDirInfo": "Своя цвет (нет инфо в справочнике)",
-      "customColorPlaceholder": "Свой цвет..."
+      "customColorPlaceholder": "Свой цвет...",
+      "timestamp": "Время",
+      "action": "Действие",
+      "target": "Объект",
+      "details": "Детали",
+      "user": "Пользователь"
     },
     "labels": {
       "actions": "Действия",
@@ -164,7 +175,9 @@
       "services": "Услуги",
       "users": "Пользователи",
       "posts": "Блог",
-      "portfolio": "Портфолио"
+      "portfolio": "Портфолио",
+      "blog": "Блог",
+      "audit": "Аудит"
     },
     "questions": {
       "deletePhoto": "Вы уверены, что хотите удалить это фото?"

+ 83 - 5
src/locales/translations.json

@@ -222,6 +222,42 @@
         "me": "Sačuvaj promjene",
         "ru": "Сохранить изменения",
         "ua": "Зберегти зміни"
+      },
+      "suspendAccount": {
+        "en": "Suspend account",
+        "me": "Suspenduj nalog",
+        "ru": "Заблокировать аккаунт",
+        "ua": "Заблокувати акаунт"
+      },
+      "activateAccount": {
+        "en": "Activate account",
+        "me": "Aktiviraj nalog",
+        "ru": "Разблокировать аккаунт",
+        "ua": "Розблокувати акаунт"
+      },
+      "makePublic": {
+        "en": "Make Public",
+        "me": "Učini javnim",
+        "ru": "Сделать публичным",
+        "ua": "Зробити публічним"
+      },
+      "makePrivate": {
+        "en": "Make Private",
+        "me": "Učini privatnim",
+        "ru": "Сделать приватным",
+        "ua": "Зробити приватним"
+      },
+      "allowChat": {
+        "en": "Allow Chat",
+        "me": "Dozvoli čet",
+        "ru": "Разрешить чат",
+        "ua": "Дозволити чат"
+      },
+      "forbidChat": {
+        "en": "Forbid Chat",
+        "me": "Zabrani čet",
+        "ru": "Запретить чат",
+        "ua": "Заборонити чат"
       }
     },
     "addNew": {
@@ -346,10 +382,10 @@
         "ua": "Немає фото"
       },
       "noPortfolio": {
-        "en": "No Portfolio",
-        "me": "Portfolio nije dozvoljen",
-        "ru": "Портфолио пусто",
-        "ua": "Портфоліо порожнє"
+        "en": "Do not publish in portfolio",
+        "me": "Ne objavljivati u portfoliju",
+        "ru": "Не публиковать в портфолио",
+        "ua": "Не публікувати в портфоліо"
       },
       "noUsers": {
         "en": "No users found",
@@ -488,6 +524,36 @@
         "me": "Prilagođena boja...",
         "ru": "Свой цвет...",
         "ua": "Свій колір..."
+      },
+      "timestamp": {
+        "en": "Timestamp",
+        "me": "Vrijeme",
+        "ru": "Время",
+        "ua": "Час"
+      },
+      "action": {
+        "en": "Action",
+        "me": "Akcija",
+        "ru": "Действие",
+        "ua": "Дія"
+      },
+      "target": {
+        "en": "Target",
+        "me": "Cilj",
+        "ru": "Объект",
+        "ua": "Об'єкт"
+      },
+      "details": {
+        "en": "Details",
+        "me": "Detalji",
+        "ru": "Детали",
+        "ua": "Деталі"
+      },
+      "user": {
+        "en": "User",
+        "me": "Korisnik",
+        "ru": "Пользователь",
+        "ua": "Користувач"
       }
     },
     "labels": {
@@ -511,7 +577,7 @@
       },
       "registered": {
         "en": "Registered",
-        "me": "Registracija",
+        "me": "Registrovan",
         "ru": "Зарегистрирован",
         "ua": "Зареєстрований"
       },
@@ -804,6 +870,18 @@
         "me": "Portfolio",
         "ru": "Портфолио",
         "ua": "Портфоліо"
+      },
+      "blog": {
+        "en": "Blog",
+        "me": "Blog",
+        "ru": "Блог",
+        "ua": "Блог"
+      },
+      "audit": {
+        "en": "Audit",
+        "me": "Audit",
+        "ru": "Аудит",
+        "ua": "Аудит"
       }
     },
     "questions": {

+ 17 - 4
src/locales/ua.json

@@ -57,7 +57,13 @@
       "sending": "Надсилання...",
       "toggleAdminRole": "Перемкнути роль адміна",
       "viewOriginal": "Оригінал",
-      "saveChanges": "Зберегти зміни"
+      "saveChanges": "Зберегти зміни",
+      "suspendAccount": "Заблокувати акаунт",
+      "activateAccount": "Розблокувати акаунт",
+      "makePublic": "Зробити публічним",
+      "makePrivate": "Зробити приватним",
+      "allowChat": "Дозволити чат",
+      "forbidChat": "Заборонити чат"
     },
     "addNew": "Додати",
     "allStatuses": "Усі статуси",
@@ -80,7 +86,7 @@
       "lastName": "Прізвище",
       "name": "Назва",
       "noPhotos": "Немає фото",
-      "noPortfolio": "Портфоліо порожнє",
+      "noPortfolio": "Не публікувати в портфоліо",
       "noUsers": "Користувачів не знайдено",
       "notifyUser": "Повідомити клієнта",
       "originalSnapshot": "Знімок замовлення",
@@ -103,7 +109,12 @@
       "techType": "Тип технології",
       "title": "Заголовок",
       "customColorDirInfo": "Свій колір (немає інфо в довіднику)",
-      "customColorPlaceholder": "Свій колір..."
+      "customColorPlaceholder": "Свій колір...",
+      "timestamp": "Час",
+      "action": "Дія",
+      "target": "Об'єкт",
+      "details": "Деталі",
+      "user": "Користувач"
     },
     "labels": {
       "actions": "Дії",
@@ -164,7 +175,9 @@
       "services": "Послуги",
       "users": "Користувачі",
       "posts": "Блог",
-      "portfolio": "Портфоліо"
+      "portfolio": "Портфоліо",
+      "blog": "Блог",
+      "audit": "Аудит"
     },
     "questions": {
       "deletePhoto": "Ви впевнені, що хочете видалити це фото?"

+ 102 - 12
src/pages/Admin.vue

@@ -19,7 +19,7 @@
             'flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-bold transition-all',
             activeTab === tab.id ? 'bg-primary text-primary-foreground shadow-glow' : 'text-muted-foreground hover:bg-white/5 hover:text-foreground'
           ]">
-            <component :is="tab.icon" class="w-4 h-4" />{{ tab.label }}
+            <component :is="tab.icon" class="w-4 h-4" />{{ t('admin.tabs.' + tab.id) }}
           </button>
         </div>
       </div>
@@ -81,7 +81,7 @@
                   <button v-if="order.user_id" 
                     @click="handleToggleUserChat(order.user_id, order.can_chat)"
                     class="ml-auto p-1.5 rounded-lg hover:bg-muted transition-colors group/chat-perm"
-                    :title="order.can_chat ? 'Forbid Chat for this user' : 'Allow Chat for this user'">
+                    :title="order.can_chat ? t('admin.actions.forbidChat') : t('admin.actions.allowChat')">
                     <component :is="order.can_chat ? ToggleRight : ToggleLeft" 
                       class="w-5 h-5 transition-transform group-hover/chat-perm:scale-110" 
                       :class="order.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
@@ -331,6 +331,7 @@
                   <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.contact") }}</th>
                   <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.role") }}</th>
                   <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.labels.chat") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.fields.active") }}</th>
                   <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.registered") }}</th>
                   <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-right">{{ t("admin.labels.actions") }}</th>
                 </tr>
@@ -357,6 +358,11 @@
                       <component :is="u.can_chat ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
                     </button>
                   </td>
+                  <td class="p-4 text-center">
+                    <button @click="handleToggleUserActive(u.id, u.is_active)" class="inline-flex" :title="u.is_active ? t('admin.actions.suspendAccount') : t('admin.actions.activateAccount')">
+                      <component :is="u.is_active ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.is_active ? 'text-emerald-500' : 'text-rose-500'" />
+                    </button>
+                  </td>
                   <td class="p-4 text-xs text-muted-foreground">
                     {{ new Date(u.created_at).toLocaleDateString() }}
                   </td>
@@ -437,7 +443,7 @@
               <div class="flex items-center gap-2">
                  <button @click="handleTogglePhotoPublic(pi.id, pi.is_public, pi.allow_portfolio)" 
                     :class="`p-2 rounded-xl transition-all ${pi.is_public ? 'bg-emerald-500 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`"
-                    :title="pi.is_public ? 'Make Private' : 'Make Public'">
+                    :title="pi.is_public ? t('admin.actions.makePrivate') : t('admin.actions.makePublic')">
                     <Eye v-if="pi.is_public" class="w-4 h-4" /><EyeOff v-else class="w-4 h-4" />
                  </button>
                  <a :href="`http://localhost:8000/${pi.file_path}`" target="_blank" class="p-2 bg-white/10 text-white hover:bg-white/20 rounded-xl transition-all">
@@ -451,6 +457,60 @@
            </div>
         </div>
       </div>
+      
+      <!-- AUDIT LOGS -->
+      <div v-else-if="activeTab === 'audit'" class="space-y-4">
+        <div class="bg-card/40 border border-border/50 rounded-2xl overflow-hidden shadow-sm">
+          <div class="overflow-x-auto">
+            <table class="w-full text-left border-collapse">
+              <thead>
+                <tr class="bg-muted/30 border-b border-border/50">
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.timestamp") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.user") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.action") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.target") }}</th>
+                  <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.details") }}</th>
+                </tr>
+              </thead>
+              <tbody class="divide-y divide-border/30">
+                <tr v-for="log in auditLogs" :key="log.id" class="hover:bg-muted/20 transition-colors">
+                  <td class="p-4 text-[11px] font-medium whitespace-nowrap opacity-60">
+                    {{ new Date(log.created_at).toLocaleString() }}
+                  </td>
+                  <td class="p-4">
+                    <div class="text-[11px] font-bold truncate max-w-[150px]">{{ log.user_email || 'System' }}</div>
+                    <div class="text-[9px] opacity-40">{{ log.ip_address }}</div>
+                  </td>
+                  <td class="p-4">
+                    <span class="px-2 py-0.5 rounded-full text-[9px] font-bold uppercase bg-primary/10 text-primary border border-primary/20">
+                      {{ log.action }}
+                    </span>
+                  </td>
+                  <td class="p-4 whitespace-nowrap">
+                    <span v-if="log.target_type" class="text-[10px] font-bold text-muted-foreground">
+                      {{ log.target_type }} #{{ log.target_id }}
+                    </span>
+                  </td>
+                  <td class="p-4">
+                    <div class="text-[11px] max-w-[300px] truncate opacity-80" :title="log.details">
+                      {{ log.details }}
+                    </div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+        
+        <!-- Pagination -->
+        <div v-if="auditTotal > 50" class="flex items-center justify-center gap-2 py-4">
+           <button v-for="p in Math.ceil(auditTotal / 50)" :key="p" 
+             @click="auditPage = p" 
+             :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all', auditPage === p ? 'bg-primary text-white' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
+             {{ p }}
+           </button>
+        </div>
+      </div>
     </main>
 
     <!-- ——— MODALS ——— -->
@@ -736,7 +796,7 @@ import { useAuthStore } from "@/stores/auth";
 import { 
   adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, 
   adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, 
-  adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser 
+  adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser, adminGetAuditLogs 
 } from "@/lib/api";
 
 const { t, locale } = useI18n();
@@ -768,22 +828,36 @@ async function toggleAdminChat(orderId: number) {
   }
 }
 
-const tabs: { id: Tab; label: string; icon: any }[] = [
-  { id: "orders",    label: t("admin.tabs.orders"),    icon: Package },
-  { id: "materials", label: t("admin.tabs.materials"), icon: Layers },
-  { id: "services",  label: t("admin.tabs.services"),  icon: Database },
-  { id: "portfolio", label: t("admin.tabs.portfolio"), icon: ImageIcon },
-  { id: "users",     label: t("admin.tabs.users"),     icon: Users },
-  { id: "posts",     label: t("admin.tabs.posts"),     icon: Newspaper },
+const tabs: { id: Tab; icon: any }[] = [
+  { id: "orders",    icon: Package },
+  { id: "materials", icon: Layers },
+  { id: "services",  icon: Database },
+  { id: "portfolio", icon: ImageIcon },
+  { id: "users",     icon: Users },
+  { id: "posts",     icon: Newspaper },
+  { id: "audit",     icon: History },
 ];
 
-type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio";
+type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "audit";
 const activeTab    = ref<Tab>("orders");
 const orders       = ref<any[]>([]);
 const materials    = ref<any[]>([]);
 const services     = ref<any[]>([]);
 const posts        = ref<any[]>([]);
 const portfolioItems = ref<any[]>([]);
+const auditLogs = ref<any[]>([]);
+const auditTotal = ref(0);
+const auditPage = ref(1);
+
+async function fetchAuditLogs() {
+  try {
+    const res = await adminGetAuditLogs(auditPage.value);
+    auditLogs.value = res.logs;
+    auditTotal.value = res.total;
+  } catch (err: any) {
+    toast.error(err.message);
+  }
+}
 
 const usersResult  = ref({ users: [] as any[], total: 0, page: 1, size: 50 });
 const userSearch   = ref("");
@@ -861,6 +935,7 @@ async function fetchData() {
     else if (currentTab === "posts")     posts.value     = await getBlogPosts(false);
     else if (currentTab === "portfolio") portfolioItems.value = await adminGetAllPhotos();
     else if (currentTab === "users")     await fetchUsers();
+    else if (currentTab === "audit")     await fetchAuditLogs();
   } catch (err) { 
     console.error(err);
     toast.error(t("admin.toasts.loadError", { tab: activeTab.value })); 
@@ -881,6 +956,10 @@ watch([userPage, userSearch], () => {
   if (activeTab.value === 'users') fetchUsers();
 });
 
+watch(auditPage, () => {
+  if (activeTab.value === 'audit') fetchAuditLogs();
+});
+
 watch(activeTab, fetchData, { immediate: false });
 onMounted(async () => {
   if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
@@ -1048,6 +1127,17 @@ async function handleToggleUserChat(userId: number, current: boolean) {
   }
 }
 
+async function handleToggleUserActive(userId: number, current: boolean) {
+  if (!window.confirm(`Are you sure you want to ${current ? 'suspend' : 'activate'} this user account?`)) return;
+  try {
+    await adminUpdateUser(userId, { is_active: !current });
+    toast.success(t("admin.toasts.statusUpdated"));
+    if (activeTab.value === 'users') fetchUsers();
+  } catch {
+    toast.error(t("admin.toasts.genericError"));
+  }
+}
+
 async function handleUpdateUserRole(userId: number, role: string) {
   try {
     await adminUpdateUser(userId, { role });