Squashed new RBAC
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
2025-07-02 22:30:21 +03:00
parent 7585dae0ab
commit 82111ed0f6
100 changed files with 14785 additions and 5888 deletions

View File

@@ -71,11 +71,12 @@ export const AuthProvider: Component<AuthProviderProps> = (props) => {
const login = async (username: string, password: string) => {
console.log('[AuthProvider] Attempting login...')
try {
const result = await query<{ login: { success: boolean; token?: string } }>(
`${location.origin}/graphql`,
ADMIN_LOGIN_MUTATION,
{ email: username, password }
)
const result = await query<{
login: { success: boolean; token?: string }
}>(`${location.origin}/graphql`, ADMIN_LOGIN_MUTATION, {
email: username,
password
})
if (result?.login?.success) {
console.log('[AuthProvider] Login successful')
@@ -97,22 +98,29 @@ export const AuthProvider: Component<AuthProviderProps> = (props) => {
const logout = async () => {
console.log('[AuthProvider] Attempting logout...')
try {
const result = await query<{ logout: { success: boolean } }>(
// Сначала очищаем токены на клиенте
clearAuthTokens()
setIsAuthenticated(false)
// Затем делаем запрос на сервер
const result = await query<{ logout: { success: boolean; message?: string } }>(
`${location.origin}/graphql`,
ADMIN_LOGOUT_MUTATION
)
console.log('[AuthProvider] Logout response:', result)
if (result?.logout?.success) {
console.log('[AuthProvider] Logout successful')
clearAuthTokens()
setIsAuthenticated(false)
console.log('[AuthProvider] Logout successful:', result.logout.message)
window.location.href = '/login'
} else {
console.warn('[AuthProvider] Logout was not successful:', result?.logout?.message)
// Все равно редиректим на страницу входа
window.location.href = '/login'
}
} catch (error) {
console.error('[AuthProvider] Logout error:', error)
// Даже при ошибке очищаем токены и редиректим
clearAuthTokens()
setIsAuthenticated(false)
// При любой ошибке редиректим на страницу входа
window.location.href = '/login'
}
}

390
panel/context/data.tsx Normal file
View File

@@ -0,0 +1,390 @@
import { createContext, createEffect, createSignal, JSX, onMount, useContext } from 'solid-js'
import {
ADMIN_GET_ROLES_QUERY,
GET_COMMUNITIES_QUERY,
GET_TOPICS_BY_COMMUNITY_QUERY,
GET_TOPICS_QUERY
} from '../graphql/queries'
export interface Community {
id: number
name: string
slug: string
desc?: string
pic?: string
}
export interface Topic {
id: number
slug: string
title: string
body?: string
pic?: string
community: number
parent_ids?: number[]
}
export interface Role {
id: string
name: string
description?: string
}
interface DataContextType {
// Сообщества
communities: () => Community[]
getCommunityById: (id: number) => Community | undefined
getCommunityName: (id: number) => string
selectedCommunity: () => number | null
setSelectedCommunity: (id: number | null) => void
// Топики
topics: () => Topic[]
allTopics: () => Topic[]
getTopicById: (id: number) => Topic | undefined
getTopicTitle: (id: number) => string
loadTopicsByCommunity: (communityId: number) => Promise<Topic[]>
// Роли
roles: () => Role[]
getRoleById: (id: string) => Role | undefined
getRoleName: (id: string) => string
// Общие методы
isLoading: () => boolean
loadData: () => Promise<void>
// biome-ignore lint/suspicious/noExplicitAny: grahphql
queryGraphQL: (query: string, variables?: Record<string, any>) => Promise<any>
}
const DataContext = createContext<DataContextType>({
// Сообщества
communities: () => [],
getCommunityById: () => undefined,
getCommunityName: () => '',
selectedCommunity: () => null,
setSelectedCommunity: () => {},
// Топики
topics: () => [],
allTopics: () => [],
getTopicById: () => undefined,
getTopicTitle: () => '',
loadTopicsByCommunity: async () => [],
// Роли
roles: () => [],
getRoleById: () => undefined,
getRoleName: () => '',
// Общие методы
isLoading: () => false,
loadData: async () => {},
queryGraphQL: async () => {}
})
/**
* Ключ для сохранения выбранного сообщества в localStorage
*/
const COMMUNITY_STORAGE_KEY = 'admin-selected-community'
export function DataProvider(props: { children: JSX.Element }) {
const [communities, setCommunities] = createSignal<Community[]>([])
const [topics, setTopics] = createSignal<Topic[]>([])
const [allTopics, setAllTopics] = createSignal<Topic[]>([])
const [roles, setRoles] = createSignal<Role[]>([])
// Инициализация выбранного сообщества из localStorage
const initialCommunity = (() => {
try {
const stored = localStorage.getItem(COMMUNITY_STORAGE_KEY)
if (stored) {
const communityId = Number.parseInt(stored, 10)
return Number.isNaN(communityId) ? 1 : communityId
}
} catch (e) {
console.warn('[DataProvider] Ошибка при чтении сообщества из localStorage:', e)
}
return 1 // По умолчанию выбираем сообщество с ID 1 (Дискурс)
})()
const [selectedCommunity, setSelectedCommunity] = createSignal<number | null>(initialCommunity)
const [isLoading, setIsLoading] = createSignal(false)
// Сохранение выбранного сообщества в localStorage
const updateSelectedCommunity = (id: number | null) => {
try {
if (id !== null) {
localStorage.setItem(COMMUNITY_STORAGE_KEY, id.toString())
console.log('[DataProvider] Сохранено сообщество в localStorage:', id)
} else {
localStorage.removeItem(COMMUNITY_STORAGE_KEY)
console.log('[DataProvider] Удалено сохраненное сообщество из localStorage')
}
setSelectedCommunity(id)
} catch (e) {
console.error('[DataProvider] Ошибка при сохранении сообщества в localStorage:', e)
setSelectedCommunity(id) // Всё равно обновляем состояние
}
}
// Эффект для загрузки ролей при изменении сообщества
createEffect(() => {
const community = selectedCommunity()
if (community !== null) {
console.log('[DataProvider] Загрузка ролей для сообщества:', community)
loadRoles(community).catch((err) => {
console.warn('Не удалось загрузить роли для сообщества:', err)
})
}
})
// Загрузка данных при монтировании
onMount(() => {
console.log('[DataProvider] Инициализация с сообществом:', initialCommunity)
loadData().catch((err) => {
console.error('Ошибка при начальной загрузке данных:', err)
})
})
// Загрузка сообществ
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 response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const communitiesData = result.data.get_communities_all || []
setCommunities(communitiesData)
return communitiesData
} catch (error) {
console.error('Ошибка загрузки сообществ:', error)
return []
}
}
// Загрузка всех топиков
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 response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const topicsData = result.data.get_topics_all || []
setTopics(topicsData)
return topicsData
} catch (error) {
console.error('Ошибка загрузки топиков:', error)
return []
}
}
// Загрузка всех топиков сообщества
const loadTopicsByCommunity = async (communityId: number) => {
try {
setIsLoading(true)
// Загружаем все топики сообщества сразу с лимитом 800
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: GET_TOPICS_BY_COMMUNITY_QUERY,
variables: {
community_id: communityId,
limit: 800,
offset: 0
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const allTopicsData = result.data.get_topics_by_community || []
// Сохраняем все данные сразу для отображения
setTopics(allTopicsData)
setAllTopics(allTopicsData)
return allTopicsData
} catch (error) {
console.error('Ошибка загрузки топиков по сообществу:', error)
return []
} finally {
setIsLoading(false)
}
}
// Загрузка ролей для конкретного сообщества
const loadRoles = async (communityId?: number) => {
try {
console.log(
'[DataProvider] Загружаем роли...',
communityId ? `для сообщества ${communityId}` : 'все роли'
)
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 response.json()
console.log('[DataProvider] Ответ от сервера для ролей:', result)
if (result.errors) {
console.warn('Не удалось загрузить роли (возможно не авторизован):', result.errors[0].message)
setRoles([])
return []
}
const rolesData = result.data.adminGetRoles || []
console.log('[DataProvider] Роли успешно загружены:', rolesData)
setRoles(rolesData)
return rolesData
} catch (error) {
console.warn('Ошибка загрузки ролей:', error)
setRoles([])
return []
}
}
// Загрузка всех данных
const loadData = async () => {
setIsLoading(true)
try {
// Загружаем все данные сразу (вызывается только для авторизованных пользователей)
// Роли загружаем в фоне - их отсутствие не должно блокировать интерфейс
await Promise.all([
loadCommunities(),
loadTopics(),
loadRoles(selectedCommunity() || undefined).catch((err) => {
console.warn('Роли недоступны (возможно не хватает прав):', err)
return []
})
])
// selectedCommunity теперь всегда инициализировано со значением 1,
// поэтому дополнительная проверка не нужна
} catch (error) {
console.error('Ошибка загрузки данных:', error)
} finally {
setIsLoading(false)
}
}
// Методы для работы с сообществами
const getCommunityById = (id: number): Community | undefined => {
return communities().find((community) => community.id === id)
}
const getCommunityName = (id: number): string => getCommunityById(id)?.name || ''
const getTopicTitle = (id: number): string => getTopicById(id)?.title || ''
// Методы для работы с топиками
const getTopicById = (id: number): Topic | undefined => {
return topics().find((topic) => topic.id === id)
}
// Методы для работы с ролями
const getRoleById = (id: string): Role | undefined => {
return roles().find((role) => role.id === id)
}
const getRoleName = (id: string): string => {
const role = getRoleById(id)
return role ? role.name : id
}
const value = {
// Сообщества
communities,
getCommunityById,
getCommunityName,
selectedCommunity,
setSelectedCommunity: updateSelectedCommunity,
// Топики
topics,
allTopics,
getTopicById,
getTopicTitle,
loadTopicsByCommunity,
// Роли
roles,
getRoleById,
getRoleName,
// Общие методы
isLoading,
loadData,
// biome-ignore lint/suspicious/noExplicitAny: grahphql
queryGraphQL: async (query: string, variables?: Record<string, any>) => {
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
} catch (error) {
console.error('Ошибка выполнения GraphQL запроса:', error)
return null
}
}
}
return <DataContext.Provider value={value}>{props.children}</DataContext.Provider>
}
export const useData = () => useContext(DataContext)

150
panel/context/sort.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { createContext, createSignal, ParentComponent, useContext } from 'solid-js'
/**
* Типы полей сортировки для разных вкладок
*/
export type AuthorsSortField = 'id' | 'email' | 'name' | 'created_at' | 'last_seen'
export type ShoutsSortField = 'id' | 'title' | 'slug' | 'created_at' | 'published_at' | 'updated_at'
export type TopicsSortField =
| 'id'
| 'title'
| 'slug'
| 'created_at'
| 'authors'
| 'shouts'
| 'followers'
| 'authors'
export type CommunitiesSortField =
| 'id'
| 'name'
| 'slug'
| 'created_at'
| 'created_by'
| 'shouts'
| 'followers'
| 'authors'
export type CollectionsSortField = 'id' | 'title' | 'slug' | 'created_at' | 'published_at'
export type InvitesSortField = 'inviter_name' | 'author_name' | 'shout_title' | 'status'
/**
* Общий тип для всех полей сортировки
*/
export type SortField =
| AuthorsSortField
| ShoutsSortField
| TopicsSortField
| CommunitiesSortField
| CollectionsSortField
| InvitesSortField
/**
* Направление сортировки
*/
export type SortDirection = 'asc' | 'desc'
/**
* Состояние сортировки
*/
export interface SortState {
field: SortField
direction: SortDirection
}
/**
* Конфигурация сортировки для разных вкладок
*/
export interface TabSortConfig {
allowedFields: SortField[]
defaultField: SortField
defaultDirection: SortDirection
}
/**
* Контекст для управления сортировкой таблиц
*/
interface TableSortContextType {
sortState: () => SortState
setSortState: (state: SortState) => void
handleSort: (field: SortField, allowedFields?: SortField[]) => void
getSortIcon: (field: SortField) => string
isFieldAllowed: (field: SortField, allowedFields?: SortField[]) => boolean
}
/**
* Создаем контекст
*/
const TableSortContext = createContext<TableSortContextType>()
/**
* Провайдер контекста сортировки
*/
export const TableSortProvider: ParentComponent = (props) => {
// Состояние сортировки - по умолчанию сортировка по ID по возрастанию
const [sortState, setSortState] = createSignal<SortState>({
field: 'id',
direction: 'asc'
})
/**
* Проверяет, разрешено ли поле для сортировки
*/
const isFieldAllowed = (field: SortField, allowedFields?: SortField[]) => {
if (!allowedFields) return true
return allowedFields.includes(field)
}
/**
* Обработчик клика по заголовку колонки для сортировки
*/
const handleSort = (field: SortField, allowedFields?: SortField[]) => {
// Проверяем, разрешено ли поле для сортировки
if (!isFieldAllowed(field, allowedFields)) {
console.warn(`Поле ${field} не разрешено для сортировки`)
return
}
const current = sortState()
let newDirection: SortDirection = 'asc'
if (current.field === field) {
// Если кликнули по той же колонке, меняем направление
newDirection = current.direction === 'asc' ? 'desc' : 'asc'
}
const newState = { field, direction: newDirection }
console.log('Изменение сортировки:', { from: current, to: newState })
setSortState(newState)
}
/**
* Получает иконку сортировки для колонки
*/
const getSortIcon = (field: SortField) => {
const current = sortState()
if (current.field !== field) {
return '⇅' // Неактивная сортировка
}
return current.direction === 'asc' ? '▲' : '▼'
}
const contextValue: TableSortContextType = {
sortState,
setSortState,
handleSort,
getSortIcon,
isFieldAllowed
}
return <TableSortContext.Provider value={contextValue}>{props.children}</TableSortContext.Provider>
}
/**
* Хук для использования контекста сортировки
*/
export const useTableSort = () => {
const context = useContext(TableSortContext)
if (!context) {
throw new Error('useTableSort должен использоваться внутри TableSortProvider')
}
return context
}

142
panel/context/sortConfig.ts Normal file
View File

@@ -0,0 +1,142 @@
import type {
AuthorsSortField,
CollectionsSortField,
CommunitiesSortField,
InvitesSortField,
ShoutsSortField,
TabSortConfig,
TopicsSortField
} from './sort'
/**
* Конфигурации сортировки для разных вкладок админ-панели
* Основаны на том, что реально поддерживают резолверы в бэкенде
*/
/**
* Конфигурация сортировки для вкладки "Авторы"
* Основана на резолвере admin_get_users в resolvers/admin.py
*/
export const AUTHORS_SORT_CONFIG: TabSortConfig = {
allowedFields: ['id', 'email', 'name', 'created_at', 'last_seen'] as AuthorsSortField[],
defaultField: 'id' as AuthorsSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Публикации"
* Основана на резолвере admin_get_shouts в resolvers/admin.py
*/
export const SHOUTS_SORT_CONFIG: TabSortConfig = {
allowedFields: ['id', 'title', 'slug', 'created_at', 'published_at', 'updated_at'] as ShoutsSortField[],
defaultField: 'id' as ShoutsSortField,
defaultDirection: 'desc' // Новые публикации сначала
}
/**
* Конфигурация сортировки для вкладки "Темы"
* Основана на резолвере get_topics_with_stats в resolvers/topic.py
*/
export const TOPICS_SORT_CONFIG: TabSortConfig = {
allowedFields: [
'id',
'title',
'slug',
'created_at',
'authors',
'shouts',
'followers'
] as TopicsSortField[],
defaultField: 'id' as TopicsSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Сообщества"
* Основана на резолвере get_communities_all в resolvers/community.py
*/
export const COMMUNITIES_SORT_CONFIG: TabSortConfig = {
allowedFields: [
'id',
'name',
'slug',
'created_at',
'created_by',
'shouts',
'followers',
'authors'
] as CommunitiesSortField[],
defaultField: 'id' as CommunitiesSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Коллекции"
* Основана на резолвере get_collections_all в resolvers/collection.py
*/
export const COLLECTIONS_SORT_CONFIG: TabSortConfig = {
allowedFields: ['id', 'title', 'slug', 'created_at', 'published_at'] as CollectionsSortField[],
defaultField: 'id' as CollectionsSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Приглашения"
* Основана на резолвере admin_get_invites в resolvers/admin.py
*/
export const INVITES_SORT_CONFIG: TabSortConfig = {
allowedFields: ['inviter_name', 'author_name', 'shout_title', 'status'] as InvitesSortField[],
defaultField: 'inviter_name' as InvitesSortField,
defaultDirection: 'asc'
}
/**
* Получает конфигурацию сортировки для указанной вкладки
*/
export const getSortConfigForTab = (tab: string): TabSortConfig => {
switch (tab) {
case 'authors':
return AUTHORS_SORT_CONFIG
case 'shouts':
return SHOUTS_SORT_CONFIG
case 'topics':
return TOPICS_SORT_CONFIG
case 'communities':
return COMMUNITIES_SORT_CONFIG
case 'collections':
return COLLECTIONS_SORT_CONFIG
case 'invites':
return INVITES_SORT_CONFIG
default:
// По умолчанию возвращаем конфигурацию авторов
return AUTHORS_SORT_CONFIG
}
}
/**
* Переводы названий полей для отображения пользователю
*/
export const FIELD_LABELS: Record<string, string> = {
// Общие поля
id: 'ID',
title: 'Название',
name: 'Имя',
slug: 'Slug',
created_at: 'Создано',
updated_at: 'Обновлено',
published_at: 'Опубликовано',
created_by: 'Создатель',
shouts: 'Публикации',
followers: 'Подписчики',
authors: 'Авторы',
// Поля авторов
email: 'Email',
last_seen: 'Последний вход',
// Поля приглашений
inviter_name: 'Приглашающий',
author_name: 'Приглашаемый',
shout_title: 'Публикация',
status: 'Статус'
}