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

@@ -1,6 +1,6 @@
import { Component, createEffect, createSignal, For } from 'solid-js'
import type { AdminUserInfo } from '../graphql/generated/schema'
import styles from '../styles/Form.module.css'
import formStyles from '../styles/Form.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
@@ -17,87 +17,146 @@ export interface UserEditModalProps {
}) => Promise<void>
}
// Доступные роли в системе (без роли Администратор - она определяется автоматически)
const AVAILABLE_ROLES = [
{ id: 'admin', name: 'Администратор', description: 'Полный доступ к системе' },
{ id: 'editor', name: 'Редактор', description: 'Редактирование публикаций и управление сообществом' },
{
id: 'expert',
name: 'Эксперт',
description: 'Добавление доказательств и опровержений, управление темами'
id: 'Редактор',
name: 'Редактор',
description: 'Редактирование публикаций и управление сообществом',
emoji: '✒️'
},
{ id: 'author', name: 'Автор', description: 'Создание и редактирование своих публикаций' },
{ id: 'reader', name: 'Читатель', description: 'Чтение и комментирование' }
{
id: 'Эксперт',
name: 'Эксперт',
description: 'Добавление доказательств и опровержений, управление темами',
emoji: '🔬'
},
{
id: 'Автор',
name: 'Автор',
description: 'Создание и редактирование своих публикаций',
emoji: '📝'
},
{
id: 'Читатель',
name: 'Читатель',
description: 'Чтение и комментирование',
emoji: '📖'
}
]
const UserEditModal: Component<UserEditModalProps> = (props) => {
const [formData, setFormData] = createSignal({
id: props.user.id,
email: props.user.email || '',
name: props.user.name || '',
slug: props.user.slug || '',
roles: props.user.roles || []
roles: props.user.roles?.filter((role) => role !== 'Администратор') || [] // Исключаем админскую роль из ручного управления
})
const [loading, setLoading] = createSignal(false)
const [errors, setErrors] = createSignal<Record<string, string>>({})
// Сброс формы при открытии модалки
const [errors, setErrors] = createSignal<Record<string, string>>({})
const [loading, setLoading] = createSignal(false)
// Проверяем, является ли пользователь администратором по ролям, которые приходят с сервера
const isAdmin = () => {
return (props.user.roles || []).includes('Администратор')
}
// Получаем информацию о роли по ID
const getRoleInfo = (roleId: string) => {
return AVAILABLE_ROLES.find((role) => role.id === roleId) || { name: roleId, emoji: '🎭' }
}
// Формируем строку с ролями и эмоджи
const getRolesDisplay = () => {
const roles = formData().roles
if (roles.length === 0) {
return isAdmin() ? '🪄 Администратор' : 'Роли не назначены'
}
const roleTexts = roles.map((roleId) => {
const role = getRoleInfo(roleId)
return `${role.emoji} ${role.name}`
})
if (isAdmin()) {
return `🪄 Администратор, ${roleTexts.join(', ')}`
}
return roleTexts.join(', ')
}
// Обновляем форму при изменении пользователя
createEffect(() => {
if (props.isOpen) {
if (props.user) {
setFormData({
id: props.user.id,
email: props.user.email || '',
name: props.user.name || '',
slug: props.user.slug || '',
roles: props.user.roles || []
roles: props.user.roles?.filter((role) => role !== 'Администратор') || [] // Исключаем админскую роль
})
setErrors({})
}
})
const validateForm = () => {
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку при изменении поля
if (errors()[field]) {
setErrors((prev) => ({ ...prev, [field]: '' }))
}
}
const handleRoleToggle = (roleId: string) => {
setFormData((prev) => {
const currentRoles = prev.roles
const newRoles = currentRoles.includes(roleId)
? currentRoles.filter((r) => r !== roleId)
: [...currentRoles, roleId]
return { ...prev, roles: newRoles }
})
// Очищаем ошибку ролей при изменении
if (errors().roles) {
setErrors((prev) => ({ ...prev, roles: '' }))
}
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
const data = formData()
// Валидация email
// Email
if (!data.email.trim()) {
newErrors.email = 'Email обязателен'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
newErrors.email = 'Некорректный формат email'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email.trim())) {
newErrors.email = 'Неверный формат email'
}
// Валидация имени
// Имя
if (!data.name.trim()) {
newErrors.name = 'Имя обязательно'
} else if (data.name.trim().length < 2) {
newErrors.name = 'Имя должно содержать минимум 2 символа'
}
// Валидация slug
// Slug
if (!data.slug.trim()) {
newErrors.slug = 'Slug обязателен'
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
} else if (!/^[a-z0-9_-]+$/.test(data.slug.trim())) {
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
}
// Валидация ролей
if (data.roles.length === 0) {
newErrors.roles = 'Выберите хотя бы одну роль'
// Роли (админы освобождаются от этого требования)
if (!isAdmin() && data.roles.length === 0) {
newErrors.roles = 'Выберите хотя бы одну роль (или назначьте админский email)'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку для поля при изменении
setErrors((prev) => ({ ...prev, [field]: '' }))
}
const handleRoleToggle = (roleId: string) => {
const current = formData().roles
const newRoles = current.includes(roleId) ? current.filter((r) => r !== roleId) : [...current, roleId]
setFormData((prev) => ({ ...prev, roles: newRoles }))
setErrors((prev) => ({ ...prev, roles: '' }))
}
const handleSave = async () => {
if (!validateForm()) {
return
@@ -105,144 +164,184 @@ const UserEditModal: Component<UserEditModalProps> = (props) => {
setLoading(true)
try {
await props.onSave({
id: props.user.id,
email: formData().email,
name: formData().name,
slug: formData().slug,
roles: formData().roles
})
// Отправляем только обычные роли, админская роль определяется на сервере по email
await props.onSave(formData())
props.onClose()
} catch (error) {
console.error('Error saving user:', error)
setErrors({ general: 'Ошибка при сохранении данных пользователя' })
console.error('Ошибка при сохранении пользователя:', error)
setErrors({ general: 'Ошибка при сохранении пользователя' })
} finally {
setLoading(false)
}
}
const formatDate = (timestamp?: number | null) => {
if (!timestamp) return '—'
return new Date(timestamp * 1000).toLocaleString('ru-RU')
}
const footer = (
<>
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave} loading={loading()} disabled={loading()}>
Сохранить изменения
</Button>
</>
)
return (
<Modal
title={`Редактирование пользователя #${props.user.id}`}
isOpen={props.isOpen}
onClose={props.onClose}
footer={footer}
size="medium"
title={`Редактирование пользователя #${props.user.id}`}
size="large"
>
<div class={styles.form}>
{errors().general && (
<div class={styles.error} style={{ 'margin-bottom': '20px' }}>
{errors().general}
</div>
)}
{/* Информационная секция */}
<div
class={styles.section}
style={{
'margin-bottom': '20px',
padding: '15px',
background: '#f8f9fa',
'border-radius': '8px'
}}
>
<h4 style={{ margin: '0 0 10px 0', color: '#495057' }}>Системная информация</h4>
<div style={{ 'font-size': '14px', color: '#6c757d' }}>
<div class={formStyles.form}>
{/* Компактная системная информация */}
<div class={formStyles.fieldGroup}>
<div
style={{
display: 'grid',
'grid-template-columns': 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem',
padding: '1rem',
background: 'var(--form-bg-light)',
'font-size': '0.875rem',
color: 'var(--form-text-light)'
}}
>
<div>
<strong>ID:</strong> {props.user.id}
</div>
<div>
<strong>Дата регистрации:</strong> {formatDate(props.user.created_at)}
<strong>Регистрация:</strong>{' '}
{props.user.created_at
? new Date(props.user.created_at * 1000).toLocaleDateString('ru-RU')
: '—'}
</div>
<div>
<strong>Последняя активность:</strong> {formatDate(props.user.last_seen)}
<strong>Активность:</strong>{' '}
{props.user.last_seen
? new Date(props.user.last_seen * 1000).toLocaleDateString('ru-RU')
: '—'}
</div>
</div>
</div>
{/* Основные данные */}
<div class={styles.section}>
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>Основные данные</h4>
{/* Текущие роли в строку */}
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🎭</span>
Текущие роли
</span>
</label>
<div
style={{
padding: '0.875rem 1rem',
background: isAdmin() ? 'rgba(245, 158, 11, 0.1)' : 'var(--form-bg-light)',
border: isAdmin() ? '1px solid rgba(245, 158, 11, 0.3)' : '1px solid var(--form-divider)',
'font-size': '0.95rem',
'font-weight': '500',
color: isAdmin() ? '#d97706' : 'var(--form-text)'
}}
>
{getRolesDisplay()}
</div>
</div>
<div class={styles.field}>
<label for="email" class={styles.label}>
Email <span style={{ color: 'red' }}>*</span>
{/* Основные данные в компактной сетке */}
<div
style={{
display: 'grid',
'grid-template-columns': 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '1rem'
}}
>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📧</span>
Email
<span class={formStyles.required}>*</span>
</span>
</label>
<input
id="email"
type="email"
class={`${styles.input} ${errors().email ? styles.inputError : ''}`}
class={`${formStyles.input} ${errors().email ? formStyles.error : ''}`}
value={formData().email}
onInput={(e) => updateField('email', e.currentTarget.value)}
disabled={loading()}
placeholder="user@example.com"
/>
{errors().email && <div class={styles.fieldError}>{errors().email}</div>}
{errors().email && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().email}
</div>
)}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Администраторы определяются автоматически по настройкам сервера
</div>
</div>
<div class={styles.field}>
<label for="name" class={styles.label}>
Имя <span style={{ color: 'red' }}>*</span>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>👤</span>
Имя
<span class={formStyles.required}>*</span>
</span>
</label>
<input
id="name"
type="text"
class={`${styles.input} ${errors().name ? styles.inputError : ''}`}
class={`${formStyles.input} ${errors().name ? formStyles.error : ''}`}
value={formData().name}
onInput={(e) => updateField('name', e.currentTarget.value)}
disabled={loading()}
placeholder="Иван Иванов"
/>
{errors().name && <div class={styles.fieldError}>{errors().name}</div>}
{errors().name && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().name}
</div>
)}
</div>
<div class={styles.field}>
<label for="slug" class={styles.label}>
Slug (URL) <span style={{ color: 'red' }}>*</span>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🔗</span>
Slug (URL)
<span class={formStyles.required}>*</span>
</span>
</label>
<input
id="slug"
type="text"
class={`${styles.input} ${errors().slug ? styles.inputError : ''}`}
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''}`}
value={formData().slug}
onInput={(e) => updateField('slug', e.currentTarget.value.toLowerCase())}
disabled={loading()}
placeholder="ivan-ivanov"
/>
<div class={styles.fieldHint}>
Используется в URL профиля. Только латинские буквы, цифры, дефисы и подчеркивания.
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Только латинские буквы, цифры, дефисы и подчеркивания
</div>
{errors().slug && <div class={styles.fieldError}>{errors().slug}</div>}
{errors().slug && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().slug}
</div>
)}
</div>
</div>
{/* Роли */}
<div class={styles.section}>
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>
Роли <span style={{ color: 'red' }}>*</span>
</h4>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}></span>
Управление ролями
<span class={formStyles.required} style={{ display: isAdmin() ? 'none' : 'inline' }}>
*
</span>
</span>
</label>
<div class={styles.rolesGrid}>
<div class={formStyles.rolesGrid}>
<For each={AVAILABLE_ROLES}>
{(role) => (
<label
class={`${styles.roleCard} ${formData().roles.includes(role.id) ? styles.roleCardSelected : ''}`}
class={`${formStyles.roleCard} ${formData().roles.includes(role.id) ? formStyles.roleCardSelected : ''}`}
>
<input
type="checkbox"
@@ -251,18 +350,61 @@ const UserEditModal: Component<UserEditModalProps> = (props) => {
disabled={loading()}
style={{ display: 'none' }}
/>
<div class={styles.roleHeader}>
<span class={styles.roleName}>{role.name}</span>
<span class={styles.roleCheckmark}>
<div class={formStyles.roleHeader}>
<span class={formStyles.roleName}>
<span style={{ 'margin-right': '0.5rem', 'font-size': '1.1rem' }}>{role.emoji}</span>
{role.name}
</span>
<span class={formStyles.roleCheckmark}>
{formData().roles.includes(role.id) ? '✓' : ''}
</span>
</div>
<div class={styles.roleDescription}>{role.description}</div>
<div class={formStyles.roleDescription}>{role.description}</div>
</label>
)}
</For>
</div>
{errors().roles && <div class={styles.fieldError}>{errors().roles}</div>}
{!isAdmin() && errors().roles && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().roles}
</div>
)}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
{isAdmin()
? 'Администраторы имеют все права автоматически. Дополнительные роли опциональны.'
: 'Выберите роли для пользователя. Минимум одна роль обязательна.'}
</div>
</div>
{/* Общая ошибка */}
{errors().general && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().general}
</div>
)}
{/* Компактные кнопки действий */}
<div
style={{
display: 'flex',
gap: '0.75rem',
'justify-content': 'flex-end',
'margin-top': '1.5rem',
'padding-top': '1rem',
'border-top': '1px solid var(--form-divider)'
}}
>
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave} loading={loading()}>
Сохранить
</Button>
</div>
</div>
</Modal>