feat(auth): نقش سوم «creator» — نویسندهٔ محتوا با دسترسی محدود

- بک‌اند: ستون role برای admin_users (admin/creator) + author_id برای مقاله‌ها
- نقش‌گارد روی کل روتر مدیریتی: creator فقط لیست/ساخت/خواندن/ویرایش
  پیش‌نویس‌های «خودش» را می‌بیند؛ بقیهٔ مسیرهای مدیریتی ۴۰۳
- ساخت توسط creator همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود
  (حتی تلاش برای publish در payload نادیده گرفته می‌شود)
- حذف/ترتیب‌بندی/پیام‌ها/خبرنامه/سایر بخش‌ها برای creator ممنوع
- لاگین نقش را برمی‌گرداند؛ پنل: AdminBar نویسنده فقط «نوشتن مقاله» +
  زبان ویرایش + رمز + خروج دارد؛ دکمه‌های انتشار/حذف مقاله فقط برای admin
- ورود creator مستقیم به صفحهٔ نوشتن مقاله هدایت می‌شود
This commit is contained in:
aramesh-site deploy
2026-09-29 08:38:16 +00:00
parent 1b571387df
commit 79813ff52c
10 changed files with 167 additions and 33 deletions
+78 -12
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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 ''")
+3
View File
@@ -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")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
+1
View File
@@ -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):
+16 -1
View File
@@ -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"
+36 -4
View File
@@ -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>
+2 -2
View File
@@ -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 {
+5 -1
View File
@@ -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>
+6 -6
View File
@@ -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