From 79813ff52cb6c2c6ad59e12fb43e66b757c641c3 Mon Sep 17 00:00:00 2001 From: aramesh-site deploy Date: Tue, 29 Sep 2026 08:38:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=D9=86=D9=82=D8=B4=20=D8=B3=D9=88?= =?UTF-8?q?=D9=85=20=C2=ABcreator=C2=BB=20=E2=80=94=20=D9=86=D9=88=DB=8C?= =?UTF-8?q?=D8=B3=D9=86=D8=AF=D9=87=D9=94=20=D9=85=D8=AD=D8=AA=D9=88=D8=A7?= =?UTF-8?q?=20=D8=A8=D8=A7=20=D8=AF=D8=B3=D8=AA=D8=B1=D8=B3=DB=8C=20=D9=85?= =?UTF-8?q?=D8=AD=D8=AF=D9=88=D8=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - بک‌اند: ستون role برای admin_users (admin/creator) + author_id برای مقاله‌ها - نقش‌گارد روی کل روتر مدیریتی: creator فقط لیست/ساخت/خواندن/ویرایش پیش‌نویس‌های «خودش» را می‌بیند؛ بقیهٔ مسیرهای مدیریتی ۴۰۳ - ساخت توسط creator همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود (حتی تلاش برای publish در payload نادیده گرفته می‌شود) - حذف/ترتیب‌بندی/پیام‌ها/خبرنامه/سایر بخش‌ها برای creator ممنوع - لاگین نقش را برمی‌گرداند؛ پنل: AdminBar نویسنده فقط «نوشتن مقاله» + زبان ویرایش + رمز + خروج دارد؛ دکمه‌های انتشار/حذف مقاله فقط برای admin - ورود creator مستقیم به صفحهٔ نوشتن مقاله هدایت می‌شود --- backend/app/api/admin.py | 90 +++++++++++++++++++++---- backend/app/auth.py | 19 ++++-- backend/app/migrations.py | 8 +++ backend/app/models.py | 3 + backend/app/schemas.py | 1 + frontend/src/admin/AdminBar.tsx | 17 ++++- frontend/src/admin/AdminContext.tsx | 40 +++++++++-- frontend/src/admin/AdminLoginPage.tsx | 4 +- frontend/src/admin/ArticleWritePage.tsx | 6 +- frontend/src/admin/Editable.tsx | 12 ++-- 10 files changed, 167 insertions(+), 33 deletions(-) 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 && ( + )} + + )} + )} دیدن در سایت + {role === 'admin' && ( + )} )} diff --git a/frontend/src/admin/Editable.tsx b/frontend/src/admin/Editable.tsx index 9372f98..73abc97 100644 --- a/frontend/src/admin/Editable.tsx +++ b/frontend/src/admin/Editable.tsx @@ -70,14 +70,14 @@ export function EditableItem({ /** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */ editHref?: string }) { - const { isAuthenticated, isEditMode, runSave } = useAdmin() + const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin() const { reload } = useSiteData() const config = ENTITIES[resource] const list = useEntityList(resource) const [drawerOpen, setDrawerOpen] = useState(false) if (!config) return <>{children} - if (!isAuthenticated || !isEditMode) return <>{children} + if (!isAuthenticated || !isEditMode || !canManageSite) return <>{children} const index = list.findIndex((x) => x.id === item.id) @@ -172,13 +172,13 @@ export function AddItemButton({ variant?: 'cell' | 'button' editHrefBase?: string }) { - const { isAuthenticated, isEditMode, runSave } = useAdmin() + const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin() const navigate = useNavigate() const { updateSite } = useSiteData() const config = ENTITIES[resource] const [newItem, setNewItem] = useState(null) - if (!config || !isAuthenticated || !isEditMode) return null + if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null const create = () => { void runSave(async () => { @@ -249,13 +249,13 @@ export function SingletonPill({ /** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */ createDefaults?: Record | (() => Record) }) { - const { isAuthenticated, isEditMode, runSave } = useAdmin() + const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin() const config = ENTITIES[resource] const { data, updateSite } = useSiteData() const [drawerOpen, setDrawerOpen] = useState(false) const [created, setCreated] = useState(null) - if (!config || !isAuthenticated || !isEditMode) return null + if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null const item = created ?? (select