feat(auth): نقش سوم «creator» — نویسندهٔ محتوا با دسترسی محدود
- بکاند: ستون role برای admin_users (admin/creator) + author_id برای مقالهها - نقشگارد روی کل روتر مدیریتی: creator فقط لیست/ساخت/خواندن/ویرایش پیشنویسهای «خودش» را میبیند؛ بقیهٔ مسیرهای مدیریتی ۴۰۳ - ساخت توسط creator همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود (حتی تلاش برای publish در payload نادیده گرفته میشود) - حذف/ترتیببندی/پیامها/خبرنامه/سایر بخشها برای creator ممنوع - لاگین نقش را برمیگرداند؛ پنل: AdminBar نویسنده فقط «نوشتن مقاله» + زبان ویرایش + رمز + خروج دارد؛ دکمههای انتشار/حذف مقاله فقط برای admin - ورود creator مستقیم به صفحهٔ نوشتن مقاله هدایت میشود
This commit is contained in:
+78
-12
@@ -19,7 +19,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import models, schemas
|
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 ..config import settings
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..rate_limit import clear as clear_rate_limit
|
from ..rate_limit import clear as clear_rate_limit
|
||||||
@@ -113,7 +113,45 @@ from ..schemas import (
|
|||||||
auth_router = APIRouter()
|
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)
|
@auth_router.post("/auth/login", response_model=TokenOut)
|
||||||
@@ -127,7 +165,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
|||||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||||
)
|
)
|
||||||
clear_rate_limit(request)
|
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")
|
@auth_router.post("/auth/change-password")
|
||||||
@@ -144,13 +182,21 @@ def change_password(
|
|||||||
return {"ok": True}
|
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 (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
حذف و ترتیببندی) روی روتر مدیریت.
|
||||||
|
|
||||||
|
creator_scoped=True (فقط مقالهها): نقش creator فقط رکوردهای خودش را میبیند،
|
||||||
|
ساختش همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود."""
|
||||||
|
|
||||||
@router.get(f"/{path}", response_model=list[out_schema])
|
@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)
|
query = db.query(model)
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
query = query.filter(model.author_id == admin.id)
|
||||||
if hasattr(model, "position"):
|
if hasattr(model, "position"):
|
||||||
query = query.order_by(model.position, model.id)
|
query = query.order_by(model.position, model.id)
|
||||||
else:
|
else:
|
||||||
@@ -158,8 +204,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
|||||||
return query.all()
|
return query.all()
|
||||||
|
|
||||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
@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())
|
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"):
|
if hasattr(model, "position"):
|
||||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||||
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
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
|
return item
|
||||||
|
|
||||||
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
|
@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)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
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
|
return item
|
||||||
|
|
||||||
@router.put(f"/{path}/{{item_id}}", response_model=out_schema)
|
@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)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
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():
|
for key, value in payload.model_dump().items():
|
||||||
setattr(item, key, value)
|
setattr(item, key, value)
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
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:
|
if patch_schema is not None:
|
||||||
|
|
||||||
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
@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)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
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():
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||||
setattr(item, key, value)
|
setattr(item, key, value)
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@@ -213,7 +275,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
@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های ارسالی."""
|
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||||
if not hasattr(model, "position"):
|
if not hasattr(model, "position"):
|
||||||
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
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()
|
return db.query(model).order_by(model.position, model.id).all()
|
||||||
|
|
||||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
@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)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||||
|
|||||||
+12
-7
@@ -21,17 +21,14 @@ def verify_password(password: str, hashed: str) -> bool:
|
|||||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
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)
|
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)
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
|
||||||
def get_current_admin(
|
def get_user_from_token(token: str, db: Session) -> AdminUser:
|
||||||
token: str = Depends(oauth2_scheme),
|
"""decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقشگارد"""
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> AdminUser:
|
|
||||||
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
|
||||||
credentials_error = HTTPException(
|
credentials_error = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="احراز هویت نامعتبر است",
|
detail="احراز هویت نامعتبر است",
|
||||||
@@ -50,3 +47,11 @@ def get_current_admin(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
return user
|
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)
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
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(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class AdminUser(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255))
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
# 'admin' همهکاره — 'creator' فقط نویسندهٔ محتوا (پیشنویس مقالههای خودش)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), default='admin')
|
||||||
|
|
||||||
|
|
||||||
class Brand(Base):
|
class Brand(Base):
|
||||||
@@ -116,6 +118,7 @@ class Article(Base):
|
|||||||
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||||
|
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
|
||||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ class LoginIn(BaseModel):
|
|||||||
class TokenOut(BaseModel):
|
class TokenOut(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
role: str = "admin"
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordIn(BaseModel):
|
class ChangePasswordIn(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||||
import { useAdmin } from './AdminContext'
|
import { useAdmin } from './AdminContext'
|
||||||
import { adminApi } from './adminClient'
|
import { adminApi } from './adminClient'
|
||||||
@@ -126,7 +127,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||||
export function AdminBar() {
|
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)
|
const [pwOpen, setPwOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,6 +141,7 @@ export function AdminBar() {
|
|||||||
حالت مدیریت
|
حالت مدیریت
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{canManageSite && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
@@ -160,6 +162,7 @@ export function AdminBar() {
|
|||||||
</span>
|
</span>
|
||||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -188,6 +191,16 @@ export function AdminBar() {
|
|||||||
رمز
|
رمز
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{canManageSite && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/admin/write"
|
||||||
|
data-admin-link
|
||||||
|
className="border border-copper/50 px-3 py-1.5 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
نوشتن مقاله
|
||||||
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||||
@@ -209,6 +222,8 @@ export function AdminBar() {
|
|||||||
>
|
>
|
||||||
خبرنامه (CSV)
|
خبرنامه (CSV)
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ export type SaveStatus = 'idle' | 'saving' | 'error'
|
|||||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||||
export type EditLang = 'fa' | 'en'
|
export type EditLang = 'fa' | 'en'
|
||||||
|
|
||||||
|
export type AdminRole = 'admin' | 'creator'
|
||||||
|
|
||||||
interface AdminContextValue {
|
interface AdminContextValue {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
|
/** نقش کاربر واردشده — 'creator' فقط نویسندهٔ محتواست */
|
||||||
|
role: AdminRole | null
|
||||||
|
/** فقط نقش admin به کل بخشهای سایت دسترسی دارد */
|
||||||
|
canManageSite: boolean
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
toggleEditMode: () => void
|
toggleEditMode: () => void
|
||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<AdminRole>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||||
@@ -55,8 +61,22 @@ function showSaveError(error: unknown): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نقش را از توکن JWT درمیآورد — فال بک sessionStorage */
|
||||||
|
function roleFromToken(token: string | null): AdminRole | null {
|
||||||
|
if (!token) return null
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
|
||||||
|
return payload?.role === 'creator' ? 'creator' : 'admin'
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminProvider({ children }: { children: ReactNode }) {
|
export function AdminProvider({ children }: { children: ReactNode }) {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
||||||
|
const [role, setRole] = useState<AdminRole | null>(() =>
|
||||||
|
sessionStorage.getItem('aramesh_role') as AdminRole | null ?? roleFromToken(getToken()),
|
||||||
|
)
|
||||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||||
)
|
)
|
||||||
@@ -81,17 +101,23 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string) => {
|
const login = useCallback(async (username: string, password: string): Promise<AdminRole> => {
|
||||||
await adminApi.login(username, password)
|
await adminApi.login(username, password)
|
||||||
|
const nextRole = roleFromToken(getToken()) ?? 'admin'
|
||||||
|
sessionStorage.setItem('aramesh_role', nextRole)
|
||||||
|
setRole(nextRole)
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||||
reload()
|
reload()
|
||||||
|
return nextRole
|
||||||
}, [reload])
|
}, [reload])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setIsEditMode(false)
|
setIsEditMode(false)
|
||||||
|
setRole(null)
|
||||||
|
sessionStorage.removeItem('aramesh_role')
|
||||||
sessionStorage.removeItem('aramesh_edit_mode')
|
sessionStorage.removeItem('aramesh_edit_mode')
|
||||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||||
reload()
|
reload()
|
||||||
@@ -132,7 +158,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncAuth = () => setIsAuthenticated(!!getToken())
|
const syncAuth = () => {
|
||||||
|
setIsAuthenticated(!!getToken())
|
||||||
|
setRole(roleFromToken(getToken()))
|
||||||
|
}
|
||||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||||
window.addEventListener('storage', syncAuth)
|
window.addEventListener('storage', syncAuth)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -151,9 +180,12 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('beforeunload', guard)
|
return () => window.removeEventListener('beforeunload', guard)
|
||||||
}, [saveStatus])
|
}, [saveStatus])
|
||||||
|
|
||||||
|
const canManageSite = role === 'admin'
|
||||||
const value = useMemo<AdminContextValue>(
|
const value = useMemo<AdminContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
role,
|
||||||
|
canManageSite,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
login,
|
login,
|
||||||
@@ -163,7 +195,7 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
editLang,
|
editLang,
|
||||||
toggleEditLang,
|
toggleEditLang,
|
||||||
}),
|
}),
|
||||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
[isAuthenticated, role, canManageSite, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function AdminLoginPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await login(username.trim(), password)
|
const nextRole = await login(username.trim(), password)
|
||||||
navigate('/')
|
navigate(nextRole === 'creator' ? '/admin/write' : '/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
|||||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||||
*/
|
*/
|
||||||
export function ArticleWritePage() {
|
export function ArticleWritePage() {
|
||||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, role } = useAdmin()
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data, updateSite } = useSiteData()
|
const { data, updateSite } = useSiteData()
|
||||||
@@ -396,6 +396,7 @@ export function ArticleWritePage() {
|
|||||||
</span>
|
</span>
|
||||||
{createdId && (
|
{createdId && (
|
||||||
<>
|
<>
|
||||||
|
{role === 'admin' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||||
@@ -407,6 +408,7 @@ export function ArticleWritePage() {
|
|||||||
>
|
>
|
||||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||||
data-admin-link
|
data-admin-link
|
||||||
@@ -415,6 +417,7 @@ export function ArticleWritePage() {
|
|||||||
<EyeIcon className="h-3.5 w-3.5" />
|
<EyeIcon className="h-3.5 w-3.5" />
|
||||||
دیدن در سایت
|
دیدن در سایت
|
||||||
</Link>
|
</Link>
|
||||||
|
{role === 'admin' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
@@ -424,6 +427,7 @@ export function ArticleWritePage() {
|
|||||||
>
|
>
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ export function EditableItem({
|
|||||||
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||||
editHref?: string
|
editHref?: string
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||||
const { reload } = useSiteData()
|
const { reload } = useSiteData()
|
||||||
const config = ENTITIES[resource]
|
const config = ENTITIES[resource]
|
||||||
const list = useEntityList(resource)
|
const list = useEntityList(resource)
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
if (!config) return <>{children}</>
|
if (!config) return <>{children}</>
|
||||||
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
if (!isAuthenticated || !isEditMode || !canManageSite) return <>{children}</>
|
||||||
|
|
||||||
const index = list.findIndex((x) => x.id === item.id)
|
const index = list.findIndex((x) => x.id === item.id)
|
||||||
|
|
||||||
@@ -172,13 +172,13 @@ export function AddItemButton({
|
|||||||
variant?: 'cell' | 'button'
|
variant?: 'cell' | 'button'
|
||||||
editHrefBase?: string
|
editHrefBase?: string
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { updateSite } = useSiteData()
|
const { updateSite } = useSiteData()
|
||||||
const config = ENTITIES[resource]
|
const config = ENTITIES[resource]
|
||||||
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||||
|
|
||||||
if (!config || !isAuthenticated || !isEditMode) return null
|
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||||
|
|
||||||
const create = () => {
|
const create = () => {
|
||||||
void runSave(async () => {
|
void runSave(async () => {
|
||||||
@@ -249,13 +249,13 @@ export function SingletonPill({
|
|||||||
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||||
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||||
const config = ENTITIES[resource]
|
const config = ENTITIES[resource]
|
||||||
const { data, updateSite } = useSiteData()
|
const { data, updateSite } = useSiteData()
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const [created, setCreated] = useState<ItemWithId | null>(null)
|
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||||
|
|
||||||
if (!config || !isAuthenticated || !isEditMode) return null
|
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||||
const item =
|
const item =
|
||||||
created ??
|
created ??
|
||||||
(select
|
(select
|
||||||
|
|||||||
Reference in New Issue
Block a user