diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index 245fe85..eaed93a 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -19,7 +19,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import models, schemas
-from ..auth import create_access_token, get_current_admin, hash_password, verify_password
+from ..auth import create_access_token, get_current_admin, get_user_from_token, hash_password, verify_password
from ..config import settings
from ..database import get_db
from ..rate_limit import clear as clear_rate_limit
@@ -113,7 +113,45 @@ from ..schemas import (
auth_router = APIRouter()
# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
-router = APIRouter(dependencies=[Depends(get_current_admin)])
+router = APIRouter(
+ dependencies=[
+ Depends(get_current_admin),
+ Depends(admin_access_guard),
+ ]
+)
+
+
+async def admin_access_guard(
+ request: Request,
+ token: str = Depends(oauth2_scheme),
+ db: Session = Depends(get_db),
+) -> models.AdminUser:
+ """نقشگارد — 'admin' همهکاره؛ 'creator' فقط مقالهنویسی: ساخت پیشنویس،
+ خواندن/ویرایش پیشنویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
+ user = get_user_from_token(token, db)
+ if user.role == "admin":
+ return user
+ if user.role != "creator":
+ raise HTTPException(status_code=403, detail="نقش ناشناخته")
+
+ path = request.url.path.rstrip("/")
+ method = request.method
+
+ # آپلود تصویر مقالهها — مجاز
+ if path in ("/api/admin/upload", "/api/admin/import-image") and method == "POST":
+ return user
+ # فهرست و ساخت مقاله — مجاز (ساخت همیشه پیشنویس است و نویسندهاش ثبت میشود)
+ if path == "/api/admin/articles" and method in ("GET", "POST"):
+ return user
+ # خواندن/ویرایش پیشنویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک میشود
+ m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
+ if m and method in ("GET", "PUT", "PATCH"):
+ article = db.get(models.Article, int(m.group(1)))
+ if article is not None and article.author_id == user.id:
+ return user
+ # ۴۰۴ نه ۴۰۳ — مقالههای بقیه برای نویسنده اصلاً وجود ندارند
+ raise HTTPException(status_code=404, detail="مقاله یافت نشد")
+ raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
@auth_router.post("/auth/login", response_model=TokenOut)
@@ -127,7 +165,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
detail="نام کاربری یا رمز عبور اشتباه است",
)
clear_rate_limit(request)
- return TokenOut(access_token=create_access_token(user.username))
+ return TokenOut(access_token=create_access_token(user.username, user.role), role=user.role)
@auth_router.post("/auth/change-password")
@@ -144,13 +182,21 @@ def change_password(
return {"ok": True}
-def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
+def register_crud(
+ path: str, model, out_schema, create_schema, patch_schema=None,
+ creator_scoped: bool = False,
+) -> None:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
- حذف و ترتیببندی) روی روتر مدیریت."""
+ حذف و ترتیببندی) روی روتر مدیریت.
+
+ creator_scoped=True (فقط مقالهها): نقش creator فقط رکوردهای خودش را میبیند،
+ ساختش همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود."""
@router.get(f"/{path}", response_model=list[out_schema])
- def list_items(db: Session = Depends(get_db)):
+ def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
query = db.query(model)
+ if creator_scoped and admin.role == "creator":
+ query = query.filter(model.author_id == admin.id)
if hasattr(model, "position"):
query = query.order_by(model.position, model.id)
else:
@@ -158,8 +204,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return query.all()
@router.post(f"/{path}", response_model=out_schema, status_code=201)
- def create_item(payload: create_schema, db: Session = Depends(get_db)):
+ def create_item(payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = model(**payload.model_dump())
+ if creator_scoped:
+ # نویسنده ثبت میشود؛ ساخت توسط creator همیشه پیشنویس است
+ item.author_id = admin.id
+ if admin.role == "creator":
+ item.status = "draft"
if hasattr(model, "position"):
# آیتم جدید در انتهای لیست قرار میگیرد
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
@@ -173,19 +224,26 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return item
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
- def get_item(item_id: int, db: Session = Depends(get_db)):
+ def get_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
+ if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
+ # مقالهٔ بقیه برای creator وجود ندارد
+ raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return item
@router.put(f"/{path}/{{item_id}}", response_model=out_schema)
- def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db)):
+ def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
+ if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
+ raise HTTPException(status_code=404, detail="مقاله یافت نشد")
for key, value in payload.model_dump().items():
setattr(item, key, value)
+ if creator_scoped and admin.role == "creator":
+ item.status = "draft" # نویسنده حق انتشار ندارد
try:
db.commit()
except IntegrityError:
@@ -197,13 +255,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
if patch_schema is not None:
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
- def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
+ def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
+ if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
+ raise HTTPException(status_code=404, detail="مقاله یافت نشد")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(item, key, value)
+ if creator_scoped and admin.role == "creator":
+ item.status = "draft" # نویسنده حق انتشار ندارد
try:
db.commit()
except IntegrityError:
@@ -213,7 +275,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return item
@router.post(f"/{path}/reorder", response_model=list[out_schema])
- def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
+ def reorder_items(payload: ReorderIn, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
+ if creator_scoped and admin.role == "creator":
+ raise HTTPException(status_code=403, detail="ترتیب مقالهها فقط توسط مدیر تغییر میکند")
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
if not hasattr(model, "position"):
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
@@ -227,7 +291,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return db.query(model).order_by(model.position, model.id).all()
@router.delete(f"/{path}/{{item_id}}", status_code=204)
- def delete_item(item_id: int, db: Session = Depends(get_db)):
+ def delete_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
+ if creator_scoped and admin.role == "creator":
+ raise HTTPException(status_code=403, detail="حذف مقاله فقط توسط مدیر انجام میشود")
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 0532e31..644b5ef 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -21,17 +21,14 @@ def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
-def create_access_token(username: str) -> str:
+def create_access_token(username: str, role: str = "admin") -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
- payload = {"sub": username, "exp": expire}
+ payload = {"sub": username, "role": role, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
-def get_current_admin(
- token: str = Depends(oauth2_scheme),
- db: Session = Depends(get_db),
-) -> AdminUser:
- """وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
+def get_user_from_token(token: str, db: Session) -> AdminUser:
+ """decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقشگارد"""
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="احراز هویت نامعتبر است",
@@ -50,3 +47,11 @@ def get_current_admin(
if user is None:
raise credentials_error
return user
+
+
+def get_current_admin(
+ token: str = Depends(oauth2_scheme),
+ db: Session = Depends(get_db),
+) -> AdminUser:
+ """وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
+ return get_user_from_token(token, db)
diff --git a/backend/app/migrations.py b/backend/app/migrations.py
index fbd9306..e116e10 100644
--- a/backend/app/migrations.py
+++ b/backend/app/migrations.py
@@ -153,6 +153,14 @@ def run_ddl_migrations() -> None:
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
)
+ # نقشها — 'admin' همهکاره، 'creator' فقط نویسندهٔ محتوا
+ conn.execute(
+ text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
+ )
+ # نویسندهٔ مقاله — نقش creator فقط مقالههای خودش را میبیند/ویرایش میکند
+ conn.execute(
+ text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author_id INTEGER")
+ )
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
diff --git a/backend/app/models.py b/backend/app/models.py
index c1478cc..6a2507e 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -21,6 +21,8 @@ class AdminUser(Base):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
+ # 'admin' همهکاره — 'creator' فقط نویسندهٔ محتوا (پیشنویس مقالههای خودش)
+ role: Mapped[str] = mapped_column(String(16), default='admin')
class Brand(Base):
@@ -116,6 +118,7 @@ class Article(Base):
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
faqs: Mapped[list] = mapped_column(JSON, default=list)
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
+ author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
# draft | published — پیشنویس فقط برای ادمین دیده میشود
status: Mapped[str] = mapped_column(String(16), default="published")
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index c939824..769c77d 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -421,6 +421,7 @@ class LoginIn(BaseModel):
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
+ role: str = "admin"
class ChangePasswordIn(BaseModel):
diff --git a/frontend/src/admin/AdminBar.tsx b/frontend/src/admin/AdminBar.tsx
index e5bf138..665f434 100644
--- a/frontend/src/admin/AdminBar.tsx
+++ b/frontend/src/admin/AdminBar.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import Swal from 'sweetalert2'
+import { Link } from 'react-router-dom'
import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
@@ -126,7 +127,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
export function AdminBar() {
- const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
+ const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang, role, canManageSite } = useAdmin()
const [pwOpen, setPwOpen] = useState(false)
return (
@@ -140,6 +141,7 @@ export function AdminBar() {
حالت مدیریت
+ {canManageSite && (
+ )}