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 .. 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="رکورد یافت نشد")
|
||||
|
||||
+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"))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 ''")
|
||||
|
||||
@@ -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")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
|
||||
@@ -421,6 +421,7 @@ class LoginIn(BaseModel):
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
|
||||
@@ -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() {
|
||||
حالت مدیریت
|
||||
</span>
|
||||
|
||||
{canManageSite && (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
@@ -160,6 +162,7 @@ export function AdminBar() {
|
||||
</span>
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -188,6 +191,16 @@ export function AdminBar() {
|
||||
رمز
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||
@@ -209,6 +222,8 @@ export function AdminBar() {
|
||||
>
|
||||
خبرنامه (CSV)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,11 +10,17 @@ export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
export type AdminRole = 'admin' | 'creator'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
/** نقش کاربر واردشده — 'creator' فقط نویسندهٔ محتواست */
|
||||
role: AdminRole | null
|
||||
/** فقط نقش admin به کل بخشهای سایت دسترسی دارد */
|
||||
canManageSite: boolean
|
||||
isEditMode: boolean
|
||||
toggleEditMode: () => void
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
login: (username: string, password: string) => Promise<AdminRole>
|
||||
logout: () => void
|
||||
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 }) {
|
||||
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>(
|
||||
() => 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)
|
||||
const nextRole = roleFromToken(getToken()) ?? 'admin'
|
||||
sessionStorage.setItem('aramesh_role', nextRole)
|
||||
setRole(nextRole)
|
||||
setIsAuthenticated(true)
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||
reload()
|
||||
return nextRole
|
||||
}, [reload])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null)
|
||||
setIsAuthenticated(false)
|
||||
setIsEditMode(false)
|
||||
setRole(null)
|
||||
sessionStorage.removeItem('aramesh_role')
|
||||
sessionStorage.removeItem('aramesh_edit_mode')
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||
reload()
|
||||
@@ -132,7 +158,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||
useEffect(() => {
|
||||
const syncAuth = () => setIsAuthenticated(!!getToken())
|
||||
const syncAuth = () => {
|
||||
setIsAuthenticated(!!getToken())
|
||||
setRole(roleFromToken(getToken()))
|
||||
}
|
||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.addEventListener('storage', syncAuth)
|
||||
return () => {
|
||||
@@ -151,9 +180,12 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('beforeunload', guard)
|
||||
}, [saveStatus])
|
||||
|
||||
const canManageSite = role === 'admin'
|
||||
const value = useMemo<AdminContextValue>(
|
||||
() => ({
|
||||
isAuthenticated,
|
||||
role,
|
||||
canManageSite,
|
||||
isEditMode,
|
||||
toggleEditMode,
|
||||
login,
|
||||
@@ -163,7 +195,7 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
editLang,
|
||||
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>
|
||||
|
||||
@@ -20,8 +20,8 @@ export function AdminLoginPage() {
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
navigate('/')
|
||||
const nextRole = await login(username.trim(), password)
|
||||
navigate(nextRole === 'creator' ? '/admin/write' : '/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||
} finally {
|
||||
|
||||
@@ -134,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, role } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -396,6 +396,7 @@ export function ArticleWritePage() {
|
||||
</span>
|
||||
{createdId && (
|
||||
<>
|
||||
{role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||
@@ -407,6 +408,7 @@ export function ArticleWritePage() {
|
||||
>
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
@@ -415,6 +417,7 @@ export function ArticleWritePage() {
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
دیدن در سایت
|
||||
</Link>
|
||||
{role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
@@ -424,6 +427,7 @@ export function ArticleWritePage() {
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<ItemWithId | null>(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<string, unknown> | (() => Record<string, unknown>)
|
||||
}) {
|
||||
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<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||
const item =
|
||||
created ??
|
||||
(select
|
||||
|
||||
Reference in New Issue
Block a user