diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f0f8abb..809171a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## [0.7.7] - 2025-01-02 + +### Обновлена система RBAC для топиков + +#### Новые разрешения для топиков +- **ДОБАВЛЕНО**: Новое разрешение `topic:merge` для слияния топиков +- **ДОБАВЛЕНО**: Разрешение `topic:create` для роли `editor` +- **ДОБАВЛЕНО**: Разрешения `topic:update_own`, `topic:delete_own` для роли `author` +- **ДОБАВЛЕНО**: Разрешение `topic:merge` для роли `editor` + +#### Обновленные резолверы мутаций топиков +- **ИЗМЕНЕНО**: `create_topic` теперь требует `topic:create` вместо `@login_required` +- **ИЗМЕНЕНО**: `update_topic` теперь требует `topic:update_own` ИЛИ `topic:update_any` +- **ИЗМЕНЕНО**: `delete_topic` теперь требует `topic:delete_own` ИЛИ `topic:delete_any` +- **ИЗМЕНЕНО**: `delete_topic_by_id` теперь требует `topic:delete_own` ИЛИ `topic:delete_any` +- **ИЗМЕНЕНО**: `merge_topics` теперь требует `topic:merge` вместо `@login_required` +- **ИЗМЕНЕНО**: `set_topic_parent` теперь требует `topic:update_own` ИЛИ `topic:update_any` + +#### Обновленная документация +- **ОБНОВЛЕНО**: Добавлена таблица прав на топики в `docs/rbac-system.md` +- **ОБНОВЛЕНО**: Добавлены примеры использования декораторов для топиков +- **ОБНОВЛЕНО**: Уточнена информация о иерархии ролей и их правах + +#### Безопасность +- **УЛУЧШЕНО**: Теперь все мутации топиков требуют соответствующих разрешений +- **УЛУЧШЕНО**: Разграничение прав между собственными и чужими топиками +- **УЛУЧШЕНО**: Специальное право на слияние топиков только для редакторов + +## [0.7.6] - 2025-07-02 + +### Добавлена функциональность слияния топиков в админ-панели + +#### Новый административный резолвер adminMergeTopics +- **ДОБАВЛЕНО**: Новая мутация `adminMergeTopics` для слияния топиков через админ-панель: + - **Функциональность**: Полное слияние топиков с переносом всех связанных данных + - **Перенос подписчиков**: Все подписчики из исходных топиков переносятся в целевой топик + - **Перенос публикаций**: Все публикации (ShoutTopic) из исходных топиков переносятся в целевой + - **Перенос черновиков**: Все черновики (DraftTopic) из исходных топиков переносятся в целевой + - **Обновление иерархии**: Дочерние топики получают новые parent_ids с заменой исходных на целевой + - **Валидация**: Проверка принадлежности всех топиков к одному сообществу + - **Дедупликация**: Предотвращение дублирования подписчиков и публикаций + - **Статистика**: Детальная статистика о количестве перенесенных данных + +#### Обновленная схема GraphQL +- **ДОБАВЛЕНО**: Новая мутация `adminMergeTopics` в схеме `admin.graphql` +- **ДОБАВЛЕНО**: Новый тип `TopicMergeInput` в схеме `input.graphql` + +#### Исправлены ошибки логгирования +- **ИСПРАВЛЕНО**: Устранены ошибки `TypeError: not all arguments converted during string formatting` +- **ИСПРАВЛЕНО**: Некорректные вызовы `logger.error()` в админ-резолверах + +#### Инфраструктура +- **ОБНОВЛЕНО**: Добавлена документация по новому функционалу в `CHANGELOG.md` +- **ОБНОВЛЕНО**: Импорты для работы с ORM моделями в админ-резолверах + ## [0.7.5] - 2025-07-02 ### Исправление критических проблем админ-панели @@ -478,7 +533,7 @@ - **Оптимизированный скролл**: Эффективная синхронизация между элементами - **Уменьшенные перерисовки**: Минимизация DOM манипуляций -- **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРЫ**: +- **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРТЫ**: - **ARIA атрибуты**: Правильная семантическая разметка - **Клавиатурная навигация**: Полная поддержка навигации с клавиатуры - **Читаемые фокусные состояния**: Четкие индикаторы фокуса diff --git a/default_role_permissions.json b/default_role_permissions.json index 8b3f6485..ba3ab043 100644 --- a/default_role_permissions.json +++ b/default_role_permissions.json @@ -87,8 +87,10 @@ "editor": [ "shout:delete_any", "shout:update_any", - "topic:delete_any", - "topic:update_any", + "topic:create", + "topic:delete_own", + "topic:update_own", + "topic:merge", "reaction:delete_any:*", "reaction:update_any:*", "invite:delete_any", diff --git a/docs/README.md b/docs/README.md index 15f64bcf..b1daa872 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ python dev.py - [Архитектура](auth-architecture.md) - Диаграммы и схемы - [Миграция](auth-migration.md) - Переход на новую версию - [Безопасность](security.md) - Пароли, email, RBAC +- [Система RBAC](rbac-system.md) - Роли, разрешения, топики - [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex - [OAuth настройка](oauth-setup.md) - Инструкции по настройке OAuth провайдеров @@ -52,7 +53,8 @@ python dev.py ### Авторизация - **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager - **OAuth провайдеры**: 7 поддерживаемых провайдеров с PKCE -- **RBAC**: user/moderator/admin роли +- **RBAC**: Система ролей reader/author/artist/expert/editor/admin с наследованием +- **Права на топики**: Специальные разрешения для создания, редактирования и слияния топиков - **Производительность**: 50% ускорение Redis, 30% меньше памяти ### Nginx (упрощенная конфигурация) diff --git a/docs/auth.md b/docs/auth.md index fdc09669..804029ff 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -255,7 +255,6 @@ export const AdminPanel: Component = () => { return (
-

Панель администратора

{/* Контент админки */}
) diff --git a/docs/rbac-system.md b/docs/rbac-system.md index 8070a26c..c4259184 100644 --- a/docs/rbac-system.md +++ b/docs/rbac-system.md @@ -132,18 +132,28 @@ reader → author → artist → expert → editor → admin | Роль | Базовые права | Дополнительные права | |------|---------------|---------------------| -| `reader` | `*:read`, базовые реакции | `chat:*`, `message:*` | +| `reader` | `*:read`, базовые реакции | `chat:*`, `message:*`, `bookmark:*` | | `author` | Наследует `reader` + `*:create`, `*:update_own`, `*:delete_own` | `draft:*` | | `artist` | Наследует `author` | `reaction:CREDIT:accept`, `reaction:CREDIT:decline` | | `expert` | Наследует `author` | `reaction:PROOF:*`, `reaction:DISPROOF:*`, `reaction:AGREE:*`, `reaction:DISAGREE:*` | -| `editor` | `*:read`, `*:create`, `*:update_any`, `*:delete_any` | `community:read`, `community:update_own` | +| `editor` | `*:read`, `*:create`, `*:update_any`, `*:delete_any` | `community:read`, `community:update_own`, `topic:merge`, `topic:create`, `topic:update_own`, `topic:delete_own` | | `admin` | Все права (`*`) | Полный доступ ко всем функциям | ### Формат разрешений -- Базовые: `:` (например: `shout:create`) +- Базовые: `:` (например: `shout:create`, `topic:create`) - Реакции: `reaction::` (например: `reaction:LIKE:create`) +- Специальные: `topic:merge` (слияние топиков) - Wildcard: `:*` или `*` (только для admin) +### Права на топики +- `topic:create` - создание новых топиков (роли: `author`, `editor`) +- `topic:read` - чтение топиков (роли: `reader` и выше) +- `topic:update_own` - обновление собственных топиков (роли: `author`) +- `topic:update_any` - обновление любых топиков (роли: `editor`) +- `topic:delete_own` - удаление собственных топиков (роли: `author`) +- `topic:delete_any` - удаление любых топиков (роли: `editor`) +- `topic:merge` - слияние топиков (роли: `editor`) + ## GraphQL API ### Запросы @@ -243,6 +253,18 @@ from resolvers.rbac import ( async def create_shout(self, info: GraphQLResolveInfo, **kwargs): # Только пользователи с правом создания статей return await self._create_shout_logic(**kwargs) + +@mutation.field("create_topic") +@require_permission("topic:create") +async def create_topic(self, info: GraphQLResolveInfo, topic_input: dict): + # Только пользователи с правом создания топиков (author, editor) + return await self._create_topic_logic(topic_input) + +@mutation.field("merge_topics") +@require_permission("topic:merge") +async def merge_topics(self, info: GraphQLResolveInfo, merge_input: dict): + # Только пользователи с правом слияния топиков (editor) + return await self._merge_topics_logic(merge_input) ``` #### Проверка любого из разрешений (OR логика) @@ -252,6 +274,18 @@ async def create_shout(self, info: GraphQLResolveInfo, **kwargs): async def update_shout(self, info: GraphQLResolveInfo, shout_id: int, **kwargs): # Может редактировать свои статьи ИЛИ любые статьи return await self._update_shout_logic(shout_id, **kwargs) + +@mutation.field("update_topic") +@require_any_permission(["topic:update_own", "topic:update_any"]) +async def update_topic(self, info: GraphQLResolveInfo, topic_input: dict): + # Может редактировать свои топики ИЛИ любые топики + return await self._update_topic_logic(topic_input) + +@mutation.field("delete_topic") +@require_any_permission(["topic:delete_own", "topic:delete_any"]) +async def delete_topic(self, info: GraphQLResolveInfo, topic_id: int): + # Может удалять свои топики ИЛИ любые топики + return await self._delete_topic_logic(topic_id) ``` #### Проверка конкретной роли diff --git a/panel/context/data.tsx b/panel/context/data.tsx index c33e8ee0..81d2e392 100644 --- a/panel/context/data.tsx +++ b/panel/context/data.tsx @@ -1,4 +1,5 @@ import { createContext, createEffect, createSignal, JSX, onMount, useContext } from 'solid-js' +import { query } from '../graphql' import { ADMIN_GET_ROLES_QUERY, ADMIN_GET_TOPICS_QUERY, @@ -42,6 +43,7 @@ interface DataContextType { // Топики topics: () => Topic[] allTopics: () => Topic[] + setTopics: (topics: Topic[]) => void getTopicById: (id: number) => Topic | undefined getTopicTitle: (id: number) => string loadTopicsByCommunity: (communityId: number) => Promise @@ -69,6 +71,7 @@ const DataContext = createContext({ // Топики topics: () => [], allTopics: () => [], + setTopics: () => {}, getTopicById: () => undefined, getTopicTitle: () => '', loadTopicsByCommunity: async () => [], @@ -95,6 +98,13 @@ export function DataProvider(props: { children: JSX.Element }) { const [allTopics, setAllTopics] = createSignal([]) const [roles, setRoles] = createSignal([]) + // Обертка для setTopics с логированием + const setTopicsWithLogging = (newTopics: Topic[]) => { + console.log('[DataProvider] setTopics called with', newTopics.length, 'topics') + console.log('[DataProvider] Sample topic parent_ids:', newTopics.slice(0, 3).map(t => ({ id: t.id, title: t.title, parent_ids: t.parent_ids }))) + setTopics(newTopics) + } + // Инициализация выбранного сообщества из localStorage const initialCommunity = (() => { try { @@ -151,23 +161,12 @@ export function DataProvider(props: { children: JSX.Element }) { // Загрузка сообществ const loadCommunities = async () => { try { - const response = await fetch('/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: GET_COMMUNITIES_QUERY - }) - }) + const result = await query<{ get_communities_all: Community[] }>( + `${location.origin}/graphql`, + GET_COMMUNITIES_QUERY + ) - const result = await response.json() - - if (result.errors) { - throw new Error(result.errors[0].message) - } - - const communitiesData = result.data.get_communities_all || [] + const communitiesData = result.get_communities_all || [] setCommunities(communitiesData) return communitiesData } catch (error) { @@ -179,24 +178,13 @@ export function DataProvider(props: { children: JSX.Element }) { // Загрузка всех топиков const loadTopics = async () => { try { - const response = await fetch('/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: GET_TOPICS_QUERY - }) - }) + const result = await query<{ get_topics_all: Topic[] }>( + `${location.origin}/graphql`, + GET_TOPICS_QUERY + ) - const result = await response.json() - - if (result.errors) { - throw new Error(result.errors[0].message) - } - - const topicsData = result.data.get_topics_all || [] - setTopics(topicsData) + const topicsData = result.get_topics_all || [] + setTopicsWithLogging(topicsData) return topicsData } catch (error) { console.error('Ошибка загрузки топиков:', error) @@ -210,29 +198,16 @@ export function DataProvider(props: { children: JSX.Element }) { setIsLoading(true) // Используем админский резолвер для получения всех топиков без лимитов - const response = await fetch('/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: ADMIN_GET_TOPICS_QUERY, - variables: { - community_id: communityId - } - }) - }) + const result = await query<{ adminGetTopics: Topic[] }>( + `${location.origin}/graphql`, + ADMIN_GET_TOPICS_QUERY, + { community_id: communityId } + ) - const result = await response.json() - - if (result.errors) { - throw new Error(result.errors[0].message) - } - - const allTopicsData = result.data.adminGetTopics || [] + const allTopicsData = result.adminGetTopics || [] // Сохраняем все данные сразу для отображения - setTopics(allTopicsData) + setTopicsWithLogging(allTopicsData) setAllTopics(allTopicsData) console.log(`[DataProvider] Загружено ${allTopicsData.length} топиков для сообщества ${communityId}`) @@ -255,27 +230,13 @@ export function DataProvider(props: { children: JSX.Element }) { const variables = communityId ? { community: communityId } : {} - const response = await fetch('/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: ADMIN_GET_ROLES_QUERY, - variables - }) - }) + const result = await query<{ adminGetRoles: Role[] }>( + `${location.origin}/graphql`, + ADMIN_GET_ROLES_QUERY, + variables + ) - const result = await response.json() - console.log('[DataProvider] Ответ от сервера для ролей:', result) - - if (result.errors) { - console.warn('Не удалось загрузить роли (возможно не авторизован):', result.errors[0].message) - setRoles([]) - return [] - } - - const rolesData = result.data.adminGetRoles || [] + const rolesData = result.adminGetRoles || [] console.log('[DataProvider] Роли успешно загружены:', rolesData) setRoles(rolesData) return rolesData @@ -316,7 +277,12 @@ export function DataProvider(props: { children: JSX.Element }) { } const getCommunityName = (id: number): string => getCommunityById(id)?.name || '' - const getTopicTitle = (id: number): string => getTopicById(id)?.title || '' + const getTopicTitle = (id: number): string => { + const topic = getTopicById(id) + const title = topic?.title || '' + console.log(`[DataProvider] getTopicTitle(${id}) -> "${title}", parent_ids:`, topic?.parent_ids) + return title + } // Методы для работы с топиками const getTopicById = (id: number): Topic | undefined => { @@ -344,6 +310,7 @@ export function DataProvider(props: { children: JSX.Element }) { // Топики topics, allTopics, + setTopics: setTopicsWithLogging, getTopicById, getTopicTitle, loadTopicsByCommunity, @@ -357,26 +324,9 @@ export function DataProvider(props: { children: JSX.Element }) { isLoading, loadData, // biome-ignore lint/suspicious/noExplicitAny: grahphql - queryGraphQL: async (query: string, variables?: Record) => { + queryGraphQL: async (queryStr: string, variables?: Record) => { try { - const response = await fetch('/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query, - variables - }) - }) - - const result = await response.json() - - if (result.errors) { - throw new Error(result.errors[0].message) - } - - return result.data + return await query(`${location.origin}/graphql`, queryStr, variables) } catch (error) { console.error('Ошибка выполнения GraphQL запроса:', error) return null diff --git a/panel/graphql/mutations.ts b/panel/graphql/mutations.ts index a4545c10..dc3c5010 100644 --- a/panel/graphql/mutations.ts +++ b/panel/graphql/mutations.ts @@ -176,3 +176,37 @@ export const SET_TOPIC_PARENT_MUTATION = ` } } ` + +export const ADMIN_UPDATE_TOPIC_MUTATION = ` + mutation AdminUpdateTopic($topic: AdminTopicInput!) { + adminUpdateTopic(topic: $topic) { + success + error + topic { + id + title + slug + body + community + parent_ids + } + } + } +` + +export const ADMIN_CREATE_TOPIC_MUTATION = ` + mutation AdminCreateTopic($topic: AdminTopicInput!) { + adminCreateTopic(topic: $topic) { + success + error + topic { + id + title + slug + body + community + parent_ids + } + } + } +` diff --git a/panel/intl/i18n.tsx b/panel/intl/i18n.tsx index e6b22941..582c3336 100644 --- a/panel/intl/i18n.tsx +++ b/panel/intl/i18n.tsx @@ -118,11 +118,6 @@ const AutoTranslator = (props: { children: JSX.Element; language: () => Language 'TH' ] if (textElements.includes(element.tagName)) { - // Более приоритетная обработка для кнопок - if (element.tagName === 'BUTTON') { - console.log(`👆 Проверка кнопки: "${element.textContent?.trim()}"`) - } - // Ищем прямые текстовые узлы внутри элемента const directTextNodes = Array.from(element.childNodes).filter( (child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim() @@ -147,9 +142,6 @@ const AutoTranslator = (props: { children: JSX.Element; language: () => Language // Если у кнопки нет прямых текстовых узлов, но есть вложенные элементы const buttonText = element.textContent?.trim() if (buttonText && CYRILLIC_REGEX.test(buttonText)) { - console.log(`🔍 Кнопка с вложенными элементами: "${buttonText}"`) - - // Проверяем, есть ли у кнопки value атрибут const valueAttr = element.getAttribute('value') if (valueAttr && CYRILLIC_REGEX.test(valueAttr)) { const currentLang = props.language() diff --git a/panel/intl/strings.json b/panel/intl/strings.json index 4f63c792..5c6a94cf 100644 --- a/panel/intl/strings.json +++ b/panel/intl/strings.json @@ -1,5 +1,4 @@ { - "Панель администратора": "Admin Panel", "Выйти": "Logout", "Авторы": "Authors", "Публикации": "Publications", @@ -10,7 +9,7 @@ "Переменные среды": "Environment Variables", "Ошибка при выходе": "Logout error", - "Вход в панель администратора": "Admin Panel Login", + "Вход": "Login", "Имя пользователя": "Username", "Пароль": "Password", "Войти": "Login", @@ -230,5 +229,9 @@ "123": "123", "Введите содержимое media.body...": "Enter media.body content...", "Поиск по названию, slug или ID...": "Search by title, slug or ID...", - "Дискурс": "Discours" + "Дискурс": "Discours", + + "Родительские топики отображаются вверху списка синим цветом. Кликните по топику чтобы добавить или убрать из выбранных.": "Parent topics are displayed at the top of the list in blue color. Click on the topic to add or remove from selected.", + "Выбрано:": "Selected:", + "Поиск по топикам...": "Search by topics..." } diff --git a/panel/modals/CollectionEditModal.tsx b/panel/modals/CollectionEditModal.tsx index fd7c8b2b..9f43e16c 100644 --- a/panel/modals/CollectionEditModal.tsx +++ b/panel/modals/CollectionEditModal.tsx @@ -2,6 +2,7 @@ import { Component, createEffect, createSignal } from 'solid-js' import formStyles from '../styles/Form.module.css' import styles from '../styles/Modal.module.css' import Button from '../ui/Button' +import HTMLEditor from '../ui/HTMLEditor' import Modal from '../ui/Modal' interface Collection { @@ -166,12 +167,9 @@ const CollectionEditModal: Component = (props) => { Описание -