feat(auth): نقش سوم «creator» — نویسندهٔ محتوا با دسترسی محدود
- بکاند: ستون role برای admin_users (admin/creator) + author_id برای مقالهها - نقشگارد روی کل روتر مدیریتی: creator فقط لیست/ساخت/خواندن/ویرایش پیشنویسهای «خودش» را میبیند؛ بقیهٔ مسیرهای مدیریتی ۴۰۳ - ساخت توسط creator همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود (حتی تلاش برای publish در payload نادیده گرفته میشود) - حذف/ترتیببندی/پیامها/خبرنامه/سایر بخشها برای creator ممنوع - لاگین نقش را برمیگرداند؛ پنل: AdminBar نویسنده فقط «نوشتن مقاله» + زبان ویرایش + رمز + خروج دارد؛ دکمههای انتشار/حذف مقاله فقط برای admin - ورود creator مستقیم به صفحهٔ نوشتن مقاله هدایت میشود
This commit is contained in:
@@ -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