mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
feat: add granular user permissions (balance, subscription, promo_group, referral, send_offer)
Split users:edit into fine-grained permissions. Guard UI sections with hasPermission checks so moderators only see what they can do.
This commit is contained in:
@@ -2618,7 +2618,12 @@
|
|||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"ban": "Ban",
|
"ban": "Ban",
|
||||||
"unban": "Unban",
|
"unban": "Unban",
|
||||||
"assign": "Assign"
|
"assign": "Assign",
|
||||||
|
"promo_group": "Promo group",
|
||||||
|
"balance": "Balance",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"send_offer": "Send offer",
|
||||||
|
"referral": "Referral %"
|
||||||
},
|
},
|
||||||
"toggleSection": "Toggle all {{section}} permissions",
|
"toggleSection": "Toggle all {{section}} permissions",
|
||||||
"selectedPermissions_one": "{{count}} permission selected",
|
"selectedPermissions_one": "{{count}} permission selected",
|
||||||
|
|||||||
@@ -2407,7 +2407,12 @@
|
|||||||
"manage": "مدیریت",
|
"manage": "مدیریت",
|
||||||
"ban": "مسدود کردن",
|
"ban": "مسدود کردن",
|
||||||
"unban": "رفع مسدودیت",
|
"unban": "رفع مسدودیت",
|
||||||
"assign": "اختصاص"
|
"assign": "اختصاص",
|
||||||
|
"promo_group": "گروه تبلیغاتی",
|
||||||
|
"balance": "موجودی",
|
||||||
|
"subscription": "اشتراک",
|
||||||
|
"send_offer": "ارسال پیشنهاد",
|
||||||
|
"referral": "درصد ارجاع"
|
||||||
},
|
},
|
||||||
"toggleSection": "تغییر وضعیت تمام مجوزهای {{section}}",
|
"toggleSection": "تغییر وضعیت تمام مجوزهای {{section}}",
|
||||||
"selectedPermissions_other": "{{count}} مجوز انتخاب شده",
|
"selectedPermissions_other": "{{count}} مجوز انتخاب شده",
|
||||||
|
|||||||
@@ -3166,7 +3166,12 @@
|
|||||||
"manage": "Управление",
|
"manage": "Управление",
|
||||||
"ban": "Бан",
|
"ban": "Бан",
|
||||||
"unban": "Разбан",
|
"unban": "Разбан",
|
||||||
"assign": "Назначение"
|
"assign": "Назначение",
|
||||||
|
"promo_group": "Промо-группа",
|
||||||
|
"balance": "Баланс",
|
||||||
|
"subscription": "Подписка",
|
||||||
|
"send_offer": "Промо-предложение",
|
||||||
|
"referral": "Реф. процент"
|
||||||
},
|
},
|
||||||
"toggleSection": "Переключить все права {{section}}",
|
"toggleSection": "Переключить все права {{section}}",
|
||||||
"selectedPermissions_one": "{{count}} разрешение выбрано",
|
"selectedPermissions_one": "{{count}} разрешение выбрано",
|
||||||
|
|||||||
@@ -2406,7 +2406,12 @@
|
|||||||
"manage": "管理",
|
"manage": "管理",
|
||||||
"ban": "封禁",
|
"ban": "封禁",
|
||||||
"unban": "解封",
|
"unban": "解封",
|
||||||
"assign": "分配"
|
"assign": "分配",
|
||||||
|
"promo_group": "促销组",
|
||||||
|
"balance": "余额",
|
||||||
|
"subscription": "订阅",
|
||||||
|
"send_offer": "发送优惠",
|
||||||
|
"referral": "推荐佣金"
|
||||||
},
|
},
|
||||||
"toggleSection": "切换所有 {{section}} 权限",
|
"toggleSection": "切换所有 {{section}} 权限",
|
||||||
"selectedPermissions_other": "已选择 {{count}} 个权限",
|
"selectedPermissions_other": "已选择 {{count}} 个权限",
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export default function AdminUserDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'info') {
|
if (activeTab === 'info') {
|
||||||
loadReferrals();
|
loadReferrals();
|
||||||
if (hasPermission('promo_groups:read')) loadPromoGroups();
|
if (hasPermission('users:promo_group')) loadPromoGroups();
|
||||||
}
|
}
|
||||||
if (activeTab === 'sync' && hasPermission('users:sync')) loadSyncStatus();
|
if (activeTab === 'sync' && hasPermission('users:sync')) loadSyncStatus();
|
||||||
if (activeTab === 'subscription') {
|
if (activeTab === 'subscription') {
|
||||||
@@ -935,7 +935,7 @@ 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>
|
||||||
{hasPermission('promo_groups:read') && (
|
{hasPermission('users:promo_group') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingPromoGroup(!editingPromoGroup)}
|
onClick={() => setEditingPromoGroup(!editingPromoGroup)}
|
||||||
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
|
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
|
||||||
@@ -989,17 +989,19 @@ export default function AdminUserDetail() {
|
|||||||
<span className="text-sm font-medium text-dark-200">
|
<span className="text-sm font-medium text-dark-200">
|
||||||
{t('admin.users.detail.referral.title')}
|
{t('admin.users.detail.referral.title')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{hasPermission('users:referral') && (
|
||||||
onClick={() => {
|
<button
|
||||||
if (!editingReferralCommission) {
|
onClick={() => {
|
||||||
setReferralCommissionValue(user.referral.commission_percent ?? '');
|
if (!editingReferralCommission) {
|
||||||
}
|
setReferralCommissionValue(user.referral.commission_percent ?? '');
|
||||||
setEditingReferralCommission(!editingReferralCommission);
|
}
|
||||||
}}
|
setEditingReferralCommission(!editingReferralCommission);
|
||||||
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
|
}}
|
||||||
>
|
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
|
||||||
{editingReferralCommission ? t('common.cancel') : t('common.edit')}
|
>
|
||||||
</button>
|
{editingReferralCommission ? t('common.cancel') : t('common.edit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -1358,72 +1360,78 @@ export default function AdminUserDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
{hasPermission('users:subscription') && (
|
||||||
<div className="mb-3 font-medium text-dark-200">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
{t('admin.users.detail.subscription.actions')}
|
<div className="mb-3 font-medium text-dark-200">
|
||||||
</div>
|
{t('admin.users.detail.subscription.actions')}
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
<select
|
<div className="space-y-3">
|
||||||
value={subAction}
|
|
||||||
onChange={(e) => setSubAction(e.target.value)}
|
|
||||||
className="input"
|
|
||||||
>
|
|
||||||
<option value="extend">{t('admin.users.detail.subscription.extend')}</option>
|
|
||||||
<option value="change_tariff">
|
|
||||||
{t('admin.users.detail.subscription.changeTariff')}
|
|
||||||
</option>
|
|
||||||
<option value="cancel">{t('admin.users.detail.subscription.cancel')}</option>
|
|
||||||
<option value="activate">
|
|
||||||
{t('admin.users.detail.subscription.activate')}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{subAction === 'extend' && (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={subDays}
|
|
||||||
onChange={createNumberInputHandler(setSubDays, 1)}
|
|
||||||
placeholder={t('admin.users.detail.subscription.days')}
|
|
||||||
className="input"
|
|
||||||
min={1}
|
|
||||||
max={3650}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{subAction === 'change_tariff' && (
|
|
||||||
<select
|
<select
|
||||||
value={selectedTariffId || ''}
|
value={subAction}
|
||||||
onChange={(e) =>
|
onChange={(e) => setSubAction(e.target.value)}
|
||||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
|
||||||
}
|
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="extend">
|
||||||
{t('admin.users.detail.subscription.selectTariff')}
|
{t('admin.users.detail.subscription.extend')}
|
||||||
|
</option>
|
||||||
|
<option value="change_tariff">
|
||||||
|
{t('admin.users.detail.subscription.changeTariff')}
|
||||||
|
</option>
|
||||||
|
<option value="cancel">
|
||||||
|
{t('admin.users.detail.subscription.cancel')}
|
||||||
|
</option>
|
||||||
|
<option value="activate">
|
||||||
|
{t('admin.users.detail.subscription.activate')}
|
||||||
</option>
|
</option>
|
||||||
{tariffs.map((tariffItem) => (
|
|
||||||
<option key={tariffItem.id} value={tariffItem.id}>
|
|
||||||
{tariffItem.name}{' '}
|
|
||||||
{!tariffItem.is_available &&
|
|
||||||
t('admin.users.detail.subscription.unavailable')}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
{subAction === 'extend' && (
|
||||||
onClick={() => handleUpdateSubscription()}
|
<input
|
||||||
disabled={actionLoading}
|
type="number"
|
||||||
className="btn-primary w-full"
|
value={subDays}
|
||||||
>
|
onChange={createNumberInputHandler(setSubDays, 1)}
|
||||||
{actionLoading
|
placeholder={t('admin.users.detail.subscription.days')}
|
||||||
? t('admin.users.actions.applying')
|
className="input"
|
||||||
: t('admin.users.actions.apply')}
|
min={1}
|
||||||
</button>
|
max={3650}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subAction === 'change_tariff' && (
|
||||||
|
<select
|
||||||
|
value={selectedTariffId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||||
|
}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t('admin.users.detail.subscription.selectTariff')}
|
||||||
|
</option>
|
||||||
|
{tariffs.map((tariffItem) => (
|
||||||
|
<option key={tariffItem.id} value={tariffItem.id}>
|
||||||
|
{tariffItem.name}{' '}
|
||||||
|
{!tariffItem.is_available &&
|
||||||
|
t('admin.users.detail.subscription.unavailable')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateSubscription()}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
|
{actionLoading
|
||||||
|
? t('admin.users.actions.applying')
|
||||||
|
: t('admin.users.actions.apply')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : hasPermission('users:subscription') ? (
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
<div className="mb-4 text-center text-dark-400">
|
<div className="mb-4 text-center text-dark-400">
|
||||||
{t('admin.users.detail.subscription.noActive')}
|
{t('admin.users.detail.subscription.noActive')}
|
||||||
@@ -1463,6 +1471,12 @@ export default function AdminUserDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="text-center text-dark-400">
|
||||||
|
{t('admin.users.detail.subscription.noActive')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Panel Info */}
|
{/* Panel Info */}
|
||||||
@@ -1788,39 +1802,41 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add/subtract form */}
|
{/* Add/subtract form */}
|
||||||
<div className="space-y-3 rounded-xl bg-dark-800/50 p-4">
|
{hasPermission('users:balance') && (
|
||||||
<input
|
<div className="space-y-3 rounded-xl bg-dark-800/50 p-4">
|
||||||
type="number"
|
<input
|
||||||
value={balanceAmount}
|
type="number"
|
||||||
onChange={createNumberInputHandler(setBalanceAmount)}
|
value={balanceAmount}
|
||||||
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
onChange={createNumberInputHandler(setBalanceAmount)}
|
||||||
className="input"
|
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
||||||
/>
|
className="input"
|
||||||
<input
|
/>
|
||||||
type="text"
|
<input
|
||||||
value={balanceDescription}
|
type="text"
|
||||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
value={balanceDescription}
|
||||||
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||||
className="input"
|
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
||||||
maxLength={500}
|
className="input"
|
||||||
/>
|
maxLength={500}
|
||||||
<div className="flex gap-2">
|
/>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => handleUpdateBalance(true)}
|
<button
|
||||||
disabled={actionLoading || balanceAmount === ''}
|
onClick={() => handleUpdateBalance(true)}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
disabled={actionLoading || balanceAmount === ''}
|
||||||
>
|
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||||
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
>
|
||||||
</button>
|
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
||||||
<button
|
</button>
|
||||||
onClick={() => handleUpdateBalance(false)}
|
<button
|
||||||
disabled={actionLoading || balanceAmount === ''}
|
onClick={() => handleUpdateBalance(false)}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
disabled={actionLoading || balanceAmount === ''}
|
||||||
>
|
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||||
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
>
|
||||||
</button>
|
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Active promo offer */}
|
{/* Active promo offer */}
|
||||||
{user.promo_offer_discount_percent > 0 && (
|
{user.promo_offer_discount_percent > 0 && (
|
||||||
@@ -1869,38 +1885,40 @@ export default function AdminUserDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Send promo offer */}
|
{/* Send promo offer */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
{hasPermission('users:send_offer') && (
|
||||||
<div className="mb-3 text-sm font-medium text-dark-200">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
{t('admin.users.detail.sendOffer')}
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.sendOffer')}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={offerDiscountPercent}
|
||||||
|
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
|
||||||
|
placeholder={t('admin.users.detail.discountPercent')}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={offerValidHours}
|
||||||
|
onChange={createNumberInputHandler(setOfferValidHours, 1)}
|
||||||
|
placeholder={t('admin.users.detail.validHours')}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
max={8760}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSendOffer}
|
||||||
|
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
|
||||||
|
className="btn-primary w-full disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
)}
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={offerDiscountPercent}
|
|
||||||
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
|
|
||||||
placeholder={t('admin.users.detail.discountPercent')}
|
|
||||||
className="input"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={offerValidHours}
|
|
||||||
onChange={createNumberInputHandler(setOfferValidHours, 1)}
|
|
||||||
placeholder={t('admin.users.detail.validHours')}
|
|
||||||
className="input"
|
|
||||||
min={1}
|
|
||||||
max={8760}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSendOffer}
|
|
||||||
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
|
|
||||||
className="btn-primary w-full disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent transactions */}
|
{/* Recent transactions */}
|
||||||
{user.recent_transactions.length > 0 && (
|
{user.recent_transactions.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user