fix: guard user detail API calls with RBAC permission checks

Hide sync tab and skip sync/promo-groups API calls when moderator
lacks users:sync and promo_groups:read permissions.
This commit is contained in:
Fringg
2026-02-25 04:29:35 +03:00
parent 85c93158fb
commit bc5d832e0d

View File

@@ -20,6 +20,7 @@ import { promoOffersApi } from '../api/promoOffers';
import { ticketsApi } from '../api/tickets'; import { ticketsApi } from '../api/tickets';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { usePermissionStore } from '../store/permissions';
// ============ Helpers ============ // ============ Helpers ============
@@ -135,6 +136,7 @@ export default function AdminUserDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const notify = useNotify(); const notify = useNotify();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const hasPermission = usePermissionStore((s) => s.hasPermission);
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' }; const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
const locale = localeMap[i18n.language] || 'ru-RU'; const locale = localeMap[i18n.language] || 'ru-RU';
@@ -384,9 +386,9 @@ export default function AdminUserDetail() {
useEffect(() => { useEffect(() => {
if (activeTab === 'info') { if (activeTab === 'info') {
loadReferrals(); loadReferrals();
loadPromoGroups(); if (hasPermission('promo_groups:read')) loadPromoGroups();
} }
if (activeTab === 'sync') loadSyncStatus(); if (activeTab === 'sync' && hasPermission('users:sync')) loadSyncStatus();
if (activeTab === 'subscription') { if (activeTab === 'subscription') {
loadTariffs(); loadTariffs();
loadSubscriptionData(); loadSubscriptionData();
@@ -400,6 +402,7 @@ export default function AdminUserDetail() {
loadReferrals, loadReferrals,
loadSubscriptionData, loadSubscriptionData,
loadPromoGroups, loadPromoGroups,
hasPermission,
]); ]);
const handleUpdateBalance = async (isAdd: boolean) => { const handleUpdateBalance = async (isAdd: boolean) => {
@@ -831,23 +834,25 @@ export default function AdminUserDetail() {
className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1" className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1"
style={{ WebkitOverflowScrolling: 'touch' }} style={{ WebkitOverflowScrolling: 'touch' }}
> >
{(['info', 'subscription', 'balance', 'sync', 'tickets'] as const).map((tab) => ( {(['info', 'subscription', 'balance', 'sync', 'tickets'] as const)
<button .filter((tab) => tab !== 'sync' || hasPermission('users:sync'))
key={tab} .map((tab) => (
onClick={() => setActiveTab(tab)} <button
className={`shrink-0 whitespace-nowrap rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${ key={tab}
activeTab === tab onClick={() => setActiveTab(tab)}
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30' className={`shrink-0 whitespace-nowrap rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700' activeTab === tab
}`} ? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
> : 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
{tab === 'info' && t('admin.users.detail.tabs.info')} }`}
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')} >
{tab === 'balance' && t('admin.users.detail.tabs.balance')} {tab === 'info' && t('admin.users.detail.tabs.info')}
{tab === 'sync' && t('admin.users.detail.tabs.sync')} {tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')} {tab === 'balance' && t('admin.users.detail.tabs.balance')}
</button> {tab === 'sync' && t('admin.users.detail.tabs.sync')}
))} {tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
</button>
))}
</div> </div>
{/* Content */} {/* Content */}
@@ -930,14 +935,16 @@ export default function AdminUserDetail() {
<div className="rounded-xl bg-dark-800/50 p-3"> <div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<span className="text-xs text-dark-500">{t('admin.users.detail.promoGroup')}</span> <span className="text-xs text-dark-500">{t('admin.users.detail.promoGroup')}</span>
<button {hasPermission('promo_groups:read') && (
onClick={() => setEditingPromoGroup(!editingPromoGroup)} <button
className="text-xs text-accent-400 transition-colors hover:text-accent-300" onClick={() => setEditingPromoGroup(!editingPromoGroup)}
> className="text-xs text-accent-400 transition-colors hover:text-accent-300"
{editingPromoGroup >
? t('common.cancel') {editingPromoGroup
: t('admin.users.detail.changePromoGroup')} ? t('common.cancel')
</button> : t('admin.users.detail.changePromoGroup')}
</button>
)}
</div> </div>
{editingPromoGroup ? ( {editingPromoGroup ? (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">