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

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

View File

@ -15,17 +15,17 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${ <article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
featured ? 'col-span-2 row-span-2' : '' featured ? 'col-span-2 row-span-2' : ''
}`}> }`}>
<div className="relative"> <div className="relative pt-7">
<img <img
src={article.coverImage} src={article.coverImage}
alt={article.title} alt={article.title}
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`} className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
/> />
<div className="absolute top-4 left-4 flex gap-2"> <div className="absolute top-1 left-0 flex gap-2 z-10">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium"> <span className="bg-gray-100 shadow px-3 py-1 rounded-full text-sm font-medium">
{categoryName} {categoryName}
</span> </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" /> <MapPin size={14} className="mr-1" />
{CityTitles[article.cityId]} {CityTitles[article.cityId]}
</span> </span>
@ -37,7 +37,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
<img <img
src={article.author.avatarUrl} src={article.author.avatarUrl}
alt={article.author.displayName} alt={article.author.displayName}
className="w-10 h-10 rounded-full" className="w-12 h-12 rounded-full"
/> />
<div> <div>
<p className="text-sm font-medium text-gray-900">{article.author.displayName}</p> <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 [totalPages, setTotalPages] = useState(1);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Загрузка статей с backend // Загрузка статей
useEffect(() => { useEffect(() => {
const fetchArticles = async () => { const fetchArticles = async () => {
try { try {

View File

@ -4,7 +4,20 @@ import TextAlign from '@tiptap/extension-text-align';
import Blockquote from '@tiptap/extension-blockquote'; import Blockquote from '@tiptap/extension-blockquote';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import StarterKit from '@tiptap/starter-kit'; 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"; import { useEffect, useState } from "react";
@ -334,7 +347,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
onClick={handleClick} onClick={handleClick}
className="p-1 rounded hover:bg-gray-200" className="p-1 rounded hover:bg-gray-200"
> >
<Text size={18} /> <SquareUser size={18} />
</button> </button>
<button <button
type="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 axios from "axios";
import { TipTapEditor } from '../components/TipTapEditor'; import { TipTapEditor } from '../components/TipTapEditor';
import { Header } from '../components/Header'; import { Header } from '../components/Header';
import { GalleryManager } from '../components/GalleryManager'; 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 { ImageUploader } from '../components/ImageUpload/ImageUploader';
import MinutesWord from '../components/MinutesWord'; 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 { 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'; const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
export function AdminPage() { export function AdminPage() {
const { user } = useAuthStore();
const isAdmin = user?.permissions.isAdmin || false;
const [articleId, setArticleId] = useState(''); const [articleId, setArticleId] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState(''); const [excerpt, setExcerpt] = useState('');
@ -34,6 +40,28 @@ export function AdminPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [showDraftOnly, setShowDraftOnly] = useState(false); 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(() => { useEffect(() => {
@ -62,6 +90,35 @@ export function AdminPage() {
setCurrentPage(page); 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 handleEdit = (id: string) => {
const article = articles.find(a => a.id === id); const article = articles.find(a => a.id === id);
@ -73,8 +130,9 @@ export function AdminPage() {
setCityId(article.cityId); setCityId(article.cityId);
setCoverImage(article.coverImage); setCoverImage(article.coverImage);
setReadTime(article.readTime); setReadTime(article.readTime);
setAuthorId(article.author.id);
setGallery(article.gallery || []); setGallery(article.gallery || []);
setContent(article.content); // Устанавливаем содержимое редактора setContent(article.content);
setEditingId(id); setEditingId(id);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} }
@ -129,6 +187,22 @@ export function AdminPage() {
// Создание новой статьи, сохранение существующей // Создание новой статьи, сохранение существующей
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); 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 = { const articleData = {
title, title,
@ -141,6 +215,7 @@ export function AdminPage() {
content: content || '', content: content || '',
importId: 0, importId: 0,
isActive: false, isActive: false,
author: selectedAuthor
}; };
if (editingId) { if (editingId) {
@ -182,16 +257,28 @@ export function AdminPage() {
setArticleId(''); setArticleId('');
setTitle(''); setTitle('');
setExcerpt(''); setExcerpt('');
if (availableCategoryIds.length > 0) {
setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
/*
setCategoryId(1); setCategoryId(1);
setCityId(1); setCityId(1);
*/
setCoverImage(DEFAULT_COVER_IMAGE); setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5); setReadTime(5);
setAuthorId(authors[0].id || '');
setGallery([]); setGallery([]);
setContent(''); // Очищаем содержимое редактора setContent(''); // Очищаем содержимое редактора
setEditingId(null); setEditingId(null);
}; };
// Проверка прав пользователя
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
const handleGalleryImageUpload = (imageUrl: string) => { const handleGalleryImageUpload = (imageUrl: string) => {
const newImage: GalleryImage = { const newImage: GalleryImage = {
id: Date.now().toString(), id: Date.now().toString(),
@ -206,6 +293,17 @@ export function AdminPage() {
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Header /> <Header />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <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"> <div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6"> <h1 className="text-2xl font-bold text-gray-900 mb-6">
{editingId ? 'Редактировать статью' : 'Создать новую статью'} {editingId ? 'Редактировать статью' : 'Создать новую статью'}
@ -246,7 +344,7 @@ export function AdminPage() {
/> />
</div> </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> <div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700"> <label htmlFor="category" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Категория</span> <span className="italic font-bold">Категория</span>
@ -257,7 +355,7 @@ export function AdminPage() {
onChange={(e) => setCategoryId(Number(e.target.value))} 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" 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}> <option key={cat} value={cat}>
{CategoryTitles[cat]} {CategoryTitles[cat]}
</option> </option>
@ -275,7 +373,7 @@ export function AdminPage() {
onChange={(e) => setCityId(Number(e.target.value))} 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" 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}> <option key={c} value={c}>
{CityTitles[c]} {CityTitles[c]}
</option> </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" 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> </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> </div>
<CoverImageUpload <CoverImageUpload
@ -317,14 +436,16 @@ export function AdminPage() {
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Фото галерея</span> <span className="italic font-bold">Фото галерея</span>
</label> </label>
<button {editingId && (
type="button" <button
onClick={() => setShowGalleryUploader(true)} type="button"
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" 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" /> >
Загрузить фото <ImagePlus size={16} className="mr-2" />
</button> Загрузить фото
</button>
)}
</div> </div>
<GalleryManager <GalleryManager
images={gallery} images={gallery}
@ -340,10 +461,17 @@ export function AdminPage() {
setEditingId(null); setEditingId(null);
setTitle(''); setTitle('');
setExcerpt(''); setExcerpt('');
setCategoryId(1); if (availableCategoryIds.length > 0) {
setCityId(1); setCategoryId(availableCategoryIds[0]);
}
if (availableCityIds.length > 0) {
setCityId(availableCityIds[0]);
}
//setCategoryId(1);
//setCityId(1);
setCoverImage(DEFAULT_COVER_IMAGE); setCoverImage(DEFAULT_COVER_IMAGE);
setReadTime(5); setReadTime(5);
setAuthorId(authors[0].id || '');
setGallery([]); setGallery([]);
setContent(''); setContent('');
}} }}
@ -356,11 +484,12 @@ export function AdminPage() {
type="submit" 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" 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> </button>
</div> </div>
</form> </form>
</div> </div>
)}
{/* Модальная загрузка изображений галереи */} {/* Модальная загрузка изображений галереи */}
{showGalleryUploader && ( {showGalleryUploader && (
@ -438,7 +567,7 @@ export function AdminPage() {
{articles.map((article) => ( {articles.map((article) => (
<li <li
key={article.id} 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 items-center justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -448,6 +577,15 @@ export function AdminPage() {
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} <MinutesWord minutes={article.readTime}/> чтения · {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} <MinutesWord minutes={article.readTime}/> чтения
</p> </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>
<div className="flex items-center gap-4 ml-4"> <div className="flex items-center gap-4 ml-4">
<button <button

View File

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

View File

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

View File

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

View File

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

View File

@ -1,31 +1,53 @@
import { Category, City } from '../types'; import { PermissionAction, User } from '../types/auth';
import { User } from '../types/auth';
export const checkPermission = ( export const checkPermission = (
user: User, user: User,
category: Category, categoryId: string,
action: 'create' | 'edit' | 'delete' action: PermissionAction
): boolean => { ): boolean => {
// Если пользователь админ — ему разрешено всё
if (user.permissions.isAdmin) return true; 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; if (user.permissions.isAdmin) return true;
return user.permissions.cities.includes(city); return user.permissions.cities.includes(cityId);
}; };
export const getDefaultPermissions = () => ({ export const getDefaultPermissions = () => ({
categories: { categories: {
Film: { create: false, edit: false, delete: false }, 1: { create: false, edit: false, delete: false },
Theater: { create: false, edit: false, delete: false }, 2: { create: false, edit: false, delete: false },
Music: { create: false, edit: false, delete: false }, 3: { create: false, edit: false, delete: false },
Sports: { create: false, edit: false, delete: false }, 4: { create: false, edit: false, delete: false },
Art: { create: false, edit: false, delete: false }, 5: { create: false, edit: false, delete: false },
Legends: { create: false, edit: false, delete: false }, 6: { create: false, edit: false, delete: false },
Anniversaries: { create: false, edit: false, delete: false }, 7: { create: false, edit: false, delete: false },
Memory: { create: false, edit: false, delete: false } 8: { create: false, edit: false, delete: false }
}, },
cities: [], cities: [],
isAdmin: false 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;
};