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
+16 -1
View File
@@ -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"
+36 -4
View File
@@ -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>
+2 -2
View File
@@ -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 {
+5 -1
View File
@@ -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>
+6 -6
View File
@@ -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