mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Контраст-аудит (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
527 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|