Files
bedolaga-cabinet/src/pages/AdminRoleEdit.tsx
c0mrade 7f68e2cd5e fix(theme): readable text on any operator palette + admin consistency pass
Контраст-аудит (WCAG-обход всех текстовых узлов, 4 палитры × юзер+админ
страницы) показал системные провалы читаемости: вторичный текст 1.5-2.5
на светлой/кастомных темах, статусные цвета 1.3-1.6, белый текст на
светлых акцентах 1.9.

Корневые фиксы:
- useThemeColors: контраст-кламп производных серых токенов — dark-400/
  champagne-600 (вторичный текст) держат >=5.0 к поверхности, dark-500/
  champagne-500 (подсказки) >=3.8; палитры с достаточным контрастом
  рендерятся байт-в-байт как раньше
- --color-on-accent/-success/-warning/-error: чёрный или белый текст
  по фактической яркости цвета оператора; text-white на accent-заливках
  заменён на text-on-accent (135 мест + variant примитива Button +
  .btn-primary)
- .light: статусные текстовые оттенки *-300/400 ремапятся на тёмные
  *-700 (жёлтая «Админка» 1.26 -> 3.8+, зелёные суммы 1.4 -> 3.4+,
  бейджи состояний)

Замер после: операторская тёмная палитра — 0 нарушений (всё >=4.5),
худшие точки остальных подняты с 1.26-1.6 до 3.2+.

Консистентность:
- заголовки админ-страниц приведены к text-xl font-bold text-dark-100
  (были 18/20/24/30px и 600/700 вперемешку)
- «Создать FAQ»: warning-500/80+белый -> warning-500+text-on-warning
- formatTraffic: единицы из i18n (убран микс «GB»/«ГБ» на одном экране)
- categoryCanvas -> categoryCANVAS в локалях (битый ключ на настройках)
- «Управление сквадами» -> «Управление локациями» (юзерский экран)
- мин-макс сумм на выборе метода: dark-600 -> dark-400
- неактивные пункты нижнего нава: dark-500 -> dark-400
2026-06-11 15:49:17 +03:00

527 lines
18 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { rbacApi, PermissionSection, CreateRolePayload, UpdateRolePayload } from '@/api/rbac';
import { AdminBackButton } from '@/components/admin';
import { ChevronDownIcon } from '@/components/icons';
// === Constants ===
const ROLE_COLORS = [
'#6366f1',
'#8b5cf6',
'#a855f7',
'#ec4899',
'#ef4444',
'#f97316',
'#eab308',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#3b82f6',
'#6b7280',
];
const PRESETS: Record<string, string[]> = {
moderator: ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'],
marketer: [
'campaigns:*',
'broadcasts:*',
'promocodes:*',
'promo_offers:*',
'promo_groups:*',
'stats:read',
'sales_stats:read',
'pinned_messages:*',
'wheel:*',
],
support: ['tickets:read', 'tickets:reply', 'users:read'],
};
// === Types ===
interface RoleFormData {
name: string;
description: string;
level: number;
color: string;
permissions: string[];
}
const INITIAL_FORM: RoleFormData = {
name: '',
description: '',
level: 50,
color: ROLE_COLORS[0],
permissions: [],
};
// === Sub-components ===
interface PermissionMatrixProps {
registry: PermissionSection[];
selectedPermissions: string[];
onToggle: (perm: string) => void;
onToggleSection: (section: string, actions: string[]) => void;
}
function PermissionMatrix({
registry,
selectedPermissions,
onToggle,
onToggleSection,
}: PermissionMatrixProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = useCallback((section: string) => {
setExpanded((prev) => ({ ...prev, [section]: !prev[section] }));
}, []);
const isSectionFullySelected = useCallback(
(section: string, actions: string[]) => {
return actions.every((action) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
});
},
[selectedPermissions],
);
const isSectionPartiallySelected = useCallback(
(section: string, actions: string[]) => {
const hasAny = actions.some((action) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
});
return hasAny && !isSectionFullySelected(section, actions);
},
[selectedPermissions, isSectionFullySelected],
);
const isPermSelected = useCallback(
(section: string, action: string) => {
const perm = `${section}:${action}`;
return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`);
},
[selectedPermissions],
);
return (
<div className="space-y-2">
<label className="text-sm font-medium text-dark-200">
{t('admin.roles.form.permissions')}
</label>
<div className="space-y-1 rounded-lg border border-dark-600 bg-dark-900/50 p-2">
{registry.map((section) => {
const isExpanded = expanded[section.section] ?? false;
const allSelected = isSectionFullySelected(section.section, section.actions);
const partialSelected = isSectionPartiallySelected(section.section, section.actions);
return (
<div
key={section.section}
className="rounded-lg border border-dark-700/50 bg-dark-800/30"
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => onToggleSection(section.section, section.actions)}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
allSelected
? 'border-accent-500 bg-accent-500'
: partialSelected
? 'border-accent-500 bg-accent-500/40'
: 'border-dark-500 hover:border-dark-400'
}`}
aria-label={t('admin.roles.form.toggleSection', { section: section.section })}
>
{(allSelected || partialSelected) && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
{allSelected ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
)}
</svg>
)}
</button>
<button
type="button"
onClick={() => toggleExpand(section.section)}
className="flex flex-1 items-center justify-between"
>
<span className="text-sm font-medium text-dark-200">
{t(`admin.roles.form.permissionSections.${section.section}`, section.section)}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">
{section.actions.filter((a) => isPermSelected(section.section, a)).length}/
{section.actions.length}
</span>
<ChevronDownIcon
className={`h-4 w-4 text-dark-400 transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</button>
</div>
{isExpanded && (
<div className="border-t border-dark-700/50 px-3 py-2">
<div className="flex flex-wrap gap-2">
{section.actions.map((action) => {
const perm = `${section.section}:${action}`;
const selected = isPermSelected(section.section, action);
return (
<button
key={perm}
type="button"
onClick={() => onToggle(perm)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
selected
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-700 hover:text-dark-300'
}`}
aria-pressed={selected}
>
{t(`admin.roles.form.permissionActions.${action}`, action)}
</button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// === Main Page ===
export default function AdminRoleEdit() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const isEdit = !!id;
const [formData, setFormData] = useState<RoleFormData>(INITIAL_FORM);
const [formError, setFormError] = useState<string | null>(null);
// Fetch role for editing
const { isLoading: isLoadingRole } = useQuery({
queryKey: ['admin-role', id],
queryFn: () => rbacApi.getRoles(),
enabled: isEdit,
select: useCallback(
(roles: import('@/api/rbac').AdminRole[]) => {
const role = roles.find((r) => r.id === Number(id));
if (role) {
setFormData({
name: role.name,
description: role.description || '',
level: role.level,
color: role.color || ROLE_COLORS[0],
permissions: [...role.permissions],
});
}
return role;
},
[id],
),
});
const { data: permissionRegistry } = useQuery({
queryKey: ['admin-permission-registry'],
queryFn: rbacApi.getPermissionRegistry,
});
// Mutations
const createMutation = useMutation({
mutationFn: (payload: CreateRolePayload) => rbacApi.createRole(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
navigate('/admin/roles');
},
onError: () => {
setFormError(t('admin.roles.errors.createFailed'));
},
});
const updateMutation = useMutation({
mutationFn: ({ roleId, payload }: { roleId: number; payload: UpdateRolePayload }) =>
rbacApi.updateRole(roleId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-roles'] });
navigate('/admin/roles');
},
onError: () => {
setFormError(t('admin.roles.errors.updateFailed'));
},
});
// Handlers
const handleTogglePermission = useCallback((perm: string) => {
setFormData((prev) => {
const has = prev.permissions.includes(perm);
return {
...prev,
permissions: has ? prev.permissions.filter((p) => p !== perm) : [...prev.permissions, perm],
};
});
}, []);
const handleToggleSection = useCallback((section: string, actions: string[]) => {
setFormData((prev) => {
const allPerms = actions.map((a) => `${section}:${a}`);
const allSelected = allPerms.every(
(p) => prev.permissions.includes(p) || prev.permissions.includes(`${section}:*`),
);
if (allSelected) {
const sectionPerms = new Set([...allPerms, `${section}:*`]);
return {
...prev,
permissions: prev.permissions.filter((p) => !sectionPerms.has(p)),
};
}
const withoutSection = prev.permissions.filter((p) => !p.startsWith(`${section}:`));
return {
...prev,
permissions: [...withoutSection, `${section}:*`],
};
});
}, []);
const handleApplyPreset = useCallback((presetKey: string) => {
const presetPerms = PRESETS[presetKey];
if (!presetPerms) return;
setFormData((prev) => ({
...prev,
permissions: [...presetPerms],
}));
}, []);
const handleSubmit = useCallback(
(e: React.SyntheticEvent) => {
e.preventDefault();
setFormError(null);
if (!formData.name.trim()) {
setFormError(t('admin.roles.errors.nameRequired'));
return;
}
const payload = {
name: formData.name.trim(),
description: formData.description.trim() || null,
level: formData.level,
permissions: formData.permissions,
color: formData.color,
};
if (isEdit) {
updateMutation.mutate({ roleId: Number(id), payload });
} else {
createMutation.mutate(payload);
}
},
[formData, isEdit, id, createMutation, updateMutation, t],
);
const isSaving = createMutation.isPending || updateMutation.isPending;
// Loading state
if (isEdit && isLoadingRole) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/roles" />
<div>
<h1 className="text-xl font-semibold text-dark-100">
{isEdit ? t('admin.roles.modal.editTitle') : t('admin.roles.modal.createTitle')}
</h1>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
<div className="space-y-4">
{/* Name */}
<div>
<label htmlFor="role-name" className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.name')}
</label>
<input
id="role-name"
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.roles.form.namePlaceholder')}
autoFocus
/>
</div>
{/* Description */}
<div>
<label
htmlFor="role-description"
className="mb-1 block text-sm font-medium text-dark-200"
>
{t('admin.roles.form.description')}
</label>
<textarea
id="role-description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.roles.form.descriptionPlaceholder')}
rows={2}
/>
</div>
{/* Level */}
<div>
<label htmlFor="role-level" className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.level')}
</label>
<div className="flex items-center gap-3">
<input
id="role-level"
type="range"
min={0}
max={999}
value={formData.level}
onChange={(e) =>
setFormData((prev) => ({ ...prev, level: Number(e.target.value) }))
}
className="flex-1 accent-accent-500"
/>
<input
type="number"
min={0}
max={999}
value={formData.level}
onChange={(e) =>
setFormData((prev) => ({
...prev,
level: Math.min(999, Math.max(0, Number(e.target.value) || 0)),
}))
}
className="w-20 rounded-lg border border-dark-600 bg-dark-900 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
aria-label={t('admin.roles.form.levelValue')}
/>
</div>
<p className="mt-1 text-xs text-dark-500">{t('admin.roles.form.levelHint')}</p>
</div>
{/* Color picker */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.color')}
</label>
<div className="flex flex-wrap gap-2">
{ROLE_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, color }))}
className={`h-7 w-7 rounded-full border-2 transition-transform hover:scale-110 ${
formData.color === color ? 'scale-110 border-white' : 'border-transparent'
}`}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
</div>
</div>
{/* Preset buttons */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-200">
{t('admin.roles.form.presets')}
</label>
<div className="flex flex-wrap gap-2">
{Object.keys(PRESETS).map((key) => (
<button
key={key}
type="button"
onClick={() => handleApplyPreset(key)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-xs font-medium text-dark-300 transition-colors hover:border-accent-500/50 hover:bg-accent-500/10 hover:text-accent-400"
>
{t(`admin.roles.presets.${key}`)}
</button>
))}
</div>
</div>
</div>
</div>
{/* Permission Matrix */}
{permissionRegistry && permissionRegistry.length > 0 && (
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
<PermissionMatrix
registry={permissionRegistry}
selectedPermissions={formData.permissions}
onToggle={handleTogglePermission}
onToggleSection={handleToggleSection}
/>
<p className="mt-2 text-xs text-dark-500">
{t('admin.roles.form.selectedPermissions', { count: formData.permissions.length })}
</p>
</div>
)}
{/* Error & Submit */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
{formError && <p className="mb-4 text-sm text-error-400">{formError}</p>}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => navigate('/admin/roles')}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.roles.form.cancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{isSaving ? t('admin.roles.form.saving') : t('admin.roles.form.save')}
</button>
</div>
</div>
</form>
</div>
);
}