Доработана работа с пользователями - загрузка аватара. Добавлены ограничения по правам на админской странице.

This commit is contained in:
anibilag 2025-03-02 23:35:45 +03:00
parent 1d09dbadf3
commit e254ef0dc0
10 changed files with 267 additions and 75 deletions

View File

@ -13,6 +13,8 @@ import { Footer } from './components/Footer';
import { AuthGuard } from './components/AuthGuard';
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
function App() {
const { setUser, setLoading } = useAuthStore();
@ -20,7 +22,7 @@ function App() {
const token = localStorage.getItem('token');
if (token) {
setLoading(true);
axios.get('http://localhost:5000/api/auth/me', {
axios.get(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(response => {

View File

@ -15,29 +15,29 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
featured ? 'col-span-2 row-span-2' : ''
}`}>
<div className="relative">
<div className="relative pt-7">
<img
src={article.coverImage}
alt={article.title}
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
/>
<div className="absolute top-4 left-4 flex gap-2">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
<div className="absolute top-1 left-0 flex gap-2 z-10">
<span className="bg-gray-100 shadow px-3 py-1 rounded-full text-sm font-medium">
{categoryName}
</span>
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
<span className="bg-gray-100 shadow px-3 py-1 rounded-full text-sm font-medium flex items-center">
<MapPin size={14} className="mr-1" />
{CityTitles[article.cityId]}
</span>
</div>
</div>
<div className="p-6">
<div className="flex items-center gap-4 mb-4">
<img
src={article.author.avatarUrl}
alt={article.author.displayName}
className="w-10 h-10 rounded-full"
className="w-12 h-12 rounded-full"
/>
<div>
<p className="text-sm font-medium text-gray-900">{article.author.displayName}</p>

View File

@ -16,7 +16,7 @@ export function FeaturedSection() {
const [totalPages, setTotalPages] = useState(1);
const [error, setError] = useState<string | null>(null);
// Загрузка статей с backend
// Загрузка статей
useEffect(() => {
const fetchArticles = async () => {
try {

View File

@ -4,7 +4,20 @@ import TextAlign from '@tiptap/extension-text-align';
import Blockquote from '@tiptap/extension-blockquote';
import Image from '@tiptap/extension-image';
import StarterKit from '@tiptap/starter-kit';
import { Bold, Italic, List, ListOrdered, Quote, Redo, Undo, AlignLeft, AlignCenter, Plus, Minus, Text } from 'lucide-react';
import {
Bold,
Italic,
List,
ListOrdered,
Quote,
Redo,
Undo,
AlignLeft,
AlignCenter,
Plus,
Minus,
SquareUser
} from 'lucide-react';
import { useEffect, useState } from "react";
@ -334,7 +347,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
onClick={handleClick}
className="p-1 rounded hover:bg-gray-200"
>
<Text size={18} />
<SquareUser size={18} />
</button>
<button
type="button"

View File

@ -1,19 +1,25 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import axios from "axios";
import { TipTapEditor } from '../components/TipTapEditor';
import { Header } from '../components/Header';
import { GalleryManager } from '../components/GalleryManager';
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload.tsx';
import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
import MinutesWord from '../components/MinutesWord';
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article } from '../types';
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article, Author } from '../types';
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
const allCategoryIds: number[] = CategoryIds;
const allCityIds: number[] = CityIds;
// Обложка по умоланию для новых статей
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
export function AdminPage() {
const { user } = useAuthStore();
const isAdmin = user?.permissions.isAdmin || false;
const [articleId, setArticleId] = useState('');
const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState('');
@ -34,6 +40,28 @@ export function AdminPage() {
const [error, setError] = useState<string | null>(null);
const [content, setContent] = useState('');
const [showDraftOnly, setShowDraftOnly] = useState(false);
const [authors, setAuthors] = useState<Author[]>([]);
const [authorId, setAuthorId] = useState<string>('');
// Загрузка списка авторов
useEffect(() => {
const fetchAuthors = async () => {
try {
const response = await axios.get('/api/users/' , {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setAuthors(response.data);
} catch (err) {
console.error('Ошибка загрузки авторов:', err);
setError('Не удалось загрузить список авторов.');
}
};
fetchAuthors();
}, []);
// Загрузка статей
useEffect(() => {
@ -62,6 +90,35 @@ export function AdminPage() {
setCurrentPage(page);
};
// Фильтр для категорий и городов, основанный на разрешениях пользователя
const availableCategoryIds = useMemo(() => {
if (!user) return [];
if (isAdmin) return allCategoryIds;
return allCategoryIds.filter(categoryId =>
user.permissions.categories[categoryId] &&
(user.permissions.categories[categoryId].create || user.permissions.categories[categoryId].edit)
);
}, [user, isAdmin]);
const availableCityIds = useMemo(() => {
if (!user) return [];
if (isAdmin) return allCityIds;
return user.permissions.cities;
}, [user, isAdmin]);
// Установка категории и города по умолчанию на основе доступных параметров
useEffect(() => {
if (availableCategoryIds.length > 0 && !availableCategoryIds.includes(categoryId)) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0 && !availableCityIds.includes(cityId)) {
setCityId(availableCityIds[0]);
}
}, [availableCategoryIds, availableCityIds, categoryId, cityId]);
// Редактирование статьи - загрузка данных
const handleEdit = (id: string) => {
const article = articles.find(a => a.id === id);
@ -73,8 +130,9 @@ export function AdminPage() {
setCityId(article.cityId);
setCoverImage(article.coverImage);
setReadTime(article.readTime);
setAuthorId(article.author.id);
setGallery(article.gallery || []);
setContent(article.content); // Устанавливаем содержимое редактора
setContent(article.content);
setEditingId(id);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
@ -129,6 +187,22 @@ export function AdminPage() {
// Создание новой статьи, сохранение существующей
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
//const selectedAuthor = authors.find(author => author.id === authorId) || authors[0];
let selectedAuthor;
if (editingId) {
if (isAdmin) {
selectedAuthor = authors.find(author => author.id === authorId) || authors[0];
} else {
// Пользователь без административных прав не может менять автора
const originalArticle = articles.find(a => a.id === editingId);
selectedAuthor = originalArticle?.author || authors[0];
}
} else {
// Для новой статьи всегда использовать текущего пользователя
selectedAuthor = user;
}
const articleData = {
title,
@ -141,6 +215,7 @@ export function AdminPage() {
content: content || '',
importId: 0,
isActive: false,
author: selectedAuthor
};
if (editingId) {
@ -182,16 +257,28 @@ export function AdminPage() {
setArticleId('');
setTitle('');
setExcerpt('');
if (availableCategoryIds.length > 0) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
/*
setCategoryId(1);
setCityId(1);
*/
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
setAuthorId(authors[0].id || '');
setGallery([]);
setContent(''); // Очищаем содержимое редактора
setEditingId(null);
};
// Проверка прав пользователя
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
const handleGalleryImageUpload = (imageUrl: string) => {
const newImage: GalleryImage = {
id: Date.now().toString(),
@ -206,6 +293,17 @@ export function AdminPage() {
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{hasNoPermissions ? (
<div className="bg-white rounded-lg shadow-sm p-6 mb-8 text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Недостаточно прав
</h1>
<p className="text-gray-600 mb-4">
У вас нет прав на создание и редактирование статей в любой категории и городе.
Свяжитесь с администраторм чтобы получить права.
</p>
</div>
) : (
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{editingId ? 'Редактировать статью' : 'Создать новую статью'}
@ -246,7 +344,7 @@ export function AdminPage() {
/>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Категория</span>
@ -257,7 +355,7 @@ export function AdminPage() {
onChange={(e) => setCategoryId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{CategoryIds.map((cat) => (
{availableCategoryIds.map((cat) => (
<option key={cat} value={cat}>
{CategoryTitles[cat]}
</option>
@ -275,7 +373,7 @@ export function AdminPage() {
onChange={(e) => setCityId(Number(e.target.value))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{CityIds.map((c) => (
{availableCityIds.map((c) => (
<option key={c} value={c}>
{CityTitles[c]}
</option>
@ -296,6 +394,27 @@ export function AdminPage() {
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
/>
</div>
{editingId && isAdmin && (
<div>
<label htmlFor="author" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Автор</span>
</label>
<select
id="author"
value={authorId}
onChange={(e) => setAuthorId(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{authors.map((author) => (
<option key={author.id} value={author.id}>
{author.displayName}
</option>
))}
</select>
</div>
)}
</div>
<CoverImageUpload
@ -317,14 +436,16 @@ export function AdminPage() {
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Фото галерея</span>
</label>
<button
type="button"
onClick={() => setShowGalleryUploader(true)}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<ImagePlus size={16} className="mr-2" />
Загрузить фото
</button>
{editingId && (
<button
type="button"
onClick={() => setShowGalleryUploader(true)}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<ImagePlus size={16} className="mr-2" />
Загрузить фото
</button>
)}
</div>
<GalleryManager
images={gallery}
@ -340,10 +461,17 @@ export function AdminPage() {
setEditingId(null);
setTitle('');
setExcerpt('');
setCategoryId(1);
setCityId(1);
if (availableCategoryIds.length > 0) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
//setCategoryId(1);
//setCityId(1);
setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5);
setAuthorId(authors[0].id || '');
setGallery([]);
setContent('');
}}
@ -356,11 +484,12 @@ export function AdminPage() {
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{editingId ? 'Изменить' : 'Созранить черновик'}
{editingId ? 'Изменить' : 'Сохранить черновик'}
</button>
</div>
</form>
</div>
)}
{/* Модальная загрузка изображений галереи */}
{showGalleryUploader && (
@ -438,7 +567,7 @@ export function AdminPage() {
{articles.map((article) => (
<li
key={article.id}
className={`px-6 py-4 ${!article.isActive ? 'bg-gray-50' : ''}`}
className={`px-6 py-4 ${!article.isActive ? 'bg-gray-200' : ''}`}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
@ -448,6 +577,15 @@ export function AdminPage() {
<p className="text-sm text-gray-500">
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} <MinutesWord minutes={article.readTime}/> чтения
</p>
<div className="flex items-center text-xs text-gray-500 mt-1">
<img
src={article.author.avatarUrl}
alt={article.author.displayName}
className="h-4 w-4 rounded-full mr-1"
/>
<span className="italic font-bold"> {article.author.displayName}</span>
</div>
</div>
<div className="flex items-center gap-4 ml-4">
<button

View File

@ -4,12 +4,13 @@ import { AuthGuard } from '../components/AuthGuard';
import { UserFormData } from '../types/auth';
import { useUserManagement } from '../hooks/useUserManagement';
import { ImagePlus, X, UserPlus, Pencil } from 'lucide-react';
import { uploadImage } from '../services/imageService';
import { imageResolutions } from '../config/imageResolutions';
import {CategoryIds, CategoryTitles, CityIds} from "../types";
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from "../types";
import axios from "axios";
const initialFormData: UserFormData = {
id: '',
email: '',
password: '',
displayName: '',
@ -27,7 +28,7 @@ export function UserManagementPage() {
handleCityChange,
createUser,
updateUser,
deleteUser
// deleteUser
} = useUserManagement();
const [showCreateModal, setShowCreateModal] = useState(false);
@ -35,7 +36,7 @@ export function UserManagementPage() {
const [formData, setFormData] = useState<UserFormData>(initialFormData);
const [formError, setFormError] = useState<string | null>(null);
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>, userId: string) => {
const file = event.target.files?.[0];
if (!file) return;
@ -43,11 +44,23 @@ export function UserManagementPage() {
const resolution = imageResolutions.find(r => r.id === 'thumbnail');
if (!resolution) throw new Error('Invalid resolution');
const uploadedImage = await uploadImage(file, resolution);
setFormData(prev => ({ ...prev, avatarUrl: uploadedImage.url }));
const formData = new FormData();
formData.append('file', file);
formData.append('resolutionId', resolution.id);
formData.append('folder', 'users/' + userId);
// Отправка запроса на сервер
const response = await axios.post('/api/images/upload-url', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
},
});
setFormData(prev => ({ ...prev, avatarUrl: response.data?.fileUrl }));
} catch (error) {
setFormError('Failed to upload avatar. Please try again.');
console.error('Upload error:', error);
setFormError('Ошибка загрузки аватара. Повторите попытку.');
console.error('Ошибка загрузки:', error);
}
};
@ -129,6 +142,7 @@ export function UserManagementPage() {
onClick={() => {
setSelectedUser(user);
setFormData({
id: user.id,
email: user.email,
password: '',
displayName: user.displayName,
@ -160,9 +174,9 @@ export function UserManagementPage() {
Права по категориям
</h3>
<div className="space-y-4">
{CategoryIds.map((category) => (
<div key={category} className="border rounded-lg p-4">
<h4 className="font-medium mb-2">{CategoryTitles[category]}</h4>
{CategoryIds.map((categoryId) => (
<div key={categoryId} className="border rounded-lg p-4">
<h4 className="font-medium mb-2">{CategoryTitles[categoryId]}</h4>
<div className="space-y-2">
{(['create', 'edit', 'delete'] as const).map((action) => (
<label
@ -172,11 +186,11 @@ export function UserManagementPage() {
<input
type="checkbox"
checked={
selectedUser.permissions.categories[CategoryTitles[category]]?.[action] ?? false
selectedUser.permissions.categories[categoryId]?.[action] ?? false
}
onChange={(e) =>
handlePermissionChange(
category,
categoryId,
action,
e.target.checked
)
@ -213,7 +227,7 @@ export function UserManagementPage() {
}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">{city}</span>
<span className="text-sm text-gray-700">{CityTitles[city]}</span>
</label>
))}
</div>
@ -231,7 +245,7 @@ export function UserManagementPage() {
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{showCreateModal ? 'Create New User' : 'Edit User'}
{showCreateModal ? 'Создание нового' : 'Редактирование'}
</h3>
<button
onClick={() => {
@ -255,22 +269,22 @@ export function UserManagementPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Avatar
Аватар
</label>
<div className="mt-1 flex items-center space-x-4">
<img
src={formData.avatarUrl || '/images/avatar.jpg'}
alt="User avatar"
alt="Аватар пользователя"
className="h-12 w-12 rounded-full object-cover"
/>
<label className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<ImagePlus size={16} className="mr-2" />
Change Avatar
Сменить аватар
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleAvatarUpload}
onChange={(e) => handleAvatarUpload(e, formData.id)} // Передаем userId
/>
</label>
</div>
@ -278,7 +292,7 @@ export function UserManagementPage() {
<div>
<label className="block text-sm font-medium text-gray-700">
Display Name
Имя
</label>
<input
type="text"
@ -291,7 +305,7 @@ export function UserManagementPage() {
<div>
<label className="block text-sm font-medium text-gray-700">
Email
E-mail
</label>
<input
type="email"
@ -304,7 +318,7 @@ export function UserManagementPage() {
<div>
<label className="block text-sm font-medium text-gray-700">
Password {showEditModal && '(leave blank to keep current)'}
Пароль {showEditModal && '(оставить пустым чтобы использовать текущий)'}
</label>
<input
type="password"
@ -326,13 +340,13 @@ export function UserManagementPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
Отмена
</button>
<button
type="submit"
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
{showCreateModal ? 'Create User' : 'Save Changes'}
{showCreateModal ? 'Создать' : 'Сохранить'}
</button>
</div>
</form>

View File

@ -11,12 +11,12 @@ export const galleryService = {
size: number;
format: string
}) => {
const { data } = await axios.post(`/gallery/article/${articleId}`, imageData);
const { data } = await axios.post(`/api/gallery/article/${articleId}`, imageData);
return data;
},
updateImage: async (id: string, updates: Partial<GalleryImage>) => {
const { data } = await axios.put(`/gallery/${id}`, updates);
const { data } = await axios.put(`/api/gallery/${id}`, updates);
return data;
},
@ -25,11 +25,11 @@ export const galleryService = {
},
reorderImages: async (articleId: string, imageIds: string[]) => {
await axios.post(`/gallery/article/${articleId}/reorder`, { imageIds });
await axios.post(`/api/gallery/article/${articleId}/reorder`, { imageIds });
},
getArticleGallery: async (articleId: string) => {
const { data } = await axios.get(`/gallery/article/${articleId}`);
const { data } = await axios.get(`/api/gallery/article/${articleId}`);
return data as GalleryImage[];
}
};

View File

@ -7,8 +7,8 @@ export const userService = {
const response = await axios.get('/users');
return response.data;
} catch (error) {
console.error('Error fetching users:', error);
throw new Error('Failed to fetch users');
console.error('Ошибка получения списка пользователей:', error);
throw new Error('Ошибка получения списка пользователей');
}
},

View File

@ -6,6 +6,8 @@ export interface UserPermissions {
isAdmin: boolean;
}
export type PermissionAction = 'edit' | 'create' | 'delete';
export interface User {
id: string;
email: string;
@ -15,6 +17,7 @@ export interface User {
}
export interface UserFormData {
id: string
email: string;
password: string;
displayName: string;

View File

@ -1,31 +1,53 @@
import { Category, City } from '../types';
import { User } from '../types/auth';
import { PermissionAction, User } from '../types/auth';
export const checkPermission = (
user: User,
category: Category,
action: 'create' | 'edit' | 'delete'
categoryId: string,
action: PermissionAction
): boolean => {
// Если пользователь админ — ему разрешено всё
if (user.permissions.isAdmin) return true;
return !!user.permissions.categories[category.name]?.[action];
// Проверяем, есть ли такая категория в permissions
const categoryPermissions = user.permissions.categories[categoryId];
// Если категория отсутствует или в ней нет действия — запрет
return categoryPermissions ? categoryPermissions[action] : false;
};
export const checkCityAccess = (user: User, city: City): boolean => {
export const checkCityAccess = (user: User, cityId: number): boolean => {
if (user.permissions.isAdmin) return true;
return user.permissions.cities.includes(city);
return user.permissions.cities.includes(cityId);
};
export const getDefaultPermissions = () => ({
categories: {
Film: { create: false, edit: false, delete: false },
Theater: { create: false, edit: false, delete: false },
Music: { create: false, edit: false, delete: false },
Sports: { create: false, edit: false, delete: false },
Art: { create: false, edit: false, delete: false },
Legends: { create: false, edit: false, delete: false },
Anniversaries: { create: false, edit: false, delete: false },
Memory: { create: false, edit: false, delete: false }
1: { create: false, edit: false, delete: false },
2: { create: false, edit: false, delete: false },
3: { create: false, edit: false, delete: false },
4: { create: false, edit: false, delete: false },
5: { create: false, edit: false, delete: false },
6: { create: false, edit: false, delete: false },
7: { create: false, edit: false, delete: false },
8: { create: false, edit: false, delete: false }
},
cities: [],
isAdmin: false
});
});
export const getUserAvailableCategories = (user: User): number[] => {
if (!user) return [];
if (user.permissions.isAdmin) return [1, 2, 3, 4, 5, 6, 7, 8];
return Object.entries(user.permissions.categories)
.filter(([, permissions]) => permissions.create || permissions.edit) // Убрали `_`
.map(([categoryId]) => Number(categoryId))
.filter((categoryId) => !isNaN(categoryId));
};
export const getUserAvailableCities = (user: User): number[] => {
if (!user) return [];
if (user.permissions.isAdmin) return [1, 2];
return user.permissions.cities;
};