2025-07-25 11:12:19 +03:00
|
|
|
|
import { Component, createEffect, createSignal, For, Show } from 'solid-js'
|
2025-06-30 21:25:26 +03:00
|
|
|
|
import type { AdminUserInfo } from '../graphql/generated/schema'
|
2025-07-02 22:30:21 +03:00
|
|
|
|
import formStyles from '../styles/Form.module.css'
|
2025-06-30 21:25:26 +03:00
|
|
|
|
import Button from '../ui/Button'
|
|
|
|
|
import Modal from '../ui/Modal'
|
|
|
|
|
|
2025-07-25 10:50:03 +03:00
|
|
|
|
// Список администраторских email
|
|
|
|
|
const ADMIN_EMAILS = ['welcome@discours.io']
|
|
|
|
|
|
2025-06-30 21:25:26 +03:00
|
|
|
|
export interface UserEditModalProps {
|
|
|
|
|
user: AdminUserInfo
|
|
|
|
|
isOpen: boolean
|
|
|
|
|
onClose: () => void
|
|
|
|
|
onSave: (userData: {
|
|
|
|
|
id: number
|
|
|
|
|
email?: string
|
|
|
|
|
name?: string
|
|
|
|
|
slug?: string
|
2025-07-25 10:50:03 +03:00
|
|
|
|
roles: string
|
2025-06-30 21:25:26 +03:00
|
|
|
|
}) => Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-25 09:58:34 +03:00
|
|
|
|
// Доступные роли в системе
|
2025-06-30 21:25:26 +03:00
|
|
|
|
const AVAILABLE_ROLES = [
|
2025-07-25 09:58:34 +03:00
|
|
|
|
{
|
2025-07-25 10:09:01 +03:00
|
|
|
|
id: 'admin',
|
2025-07-25 09:58:34 +03:00
|
|
|
|
name: 'Системный администратор',
|
|
|
|
|
description: 'Администраторы определяются автоматически по настройкам сервера',
|
|
|
|
|
emoji: '🪄'
|
|
|
|
|
},
|
2025-06-30 21:25:26 +03:00
|
|
|
|
{
|
2025-07-25 10:09:01 +03:00
|
|
|
|
id: 'editor',
|
2025-07-02 22:30:21 +03:00
|
|
|
|
name: 'Редактор',
|
|
|
|
|
description: 'Редактирование публикаций и управление сообществом',
|
|
|
|
|
emoji: '✒️'
|
|
|
|
|
},
|
|
|
|
|
{
|
2025-07-25 10:09:01 +03:00
|
|
|
|
id: 'expert',
|
2025-06-30 21:25:26 +03:00
|
|
|
|
name: 'Эксперт',
|
2025-07-02 22:30:21 +03:00
|
|
|
|
description: 'Добавление доказательств и опровержений, управление темами',
|
|
|
|
|
emoji: '🔬'
|
2025-06-30 21:25:26 +03:00
|
|
|
|
},
|
2025-07-02 22:30:21 +03:00
|
|
|
|
{
|
2025-07-25 10:09:01 +03:00
|
|
|
|
id: 'author',
|
2025-07-02 22:30:21 +03:00
|
|
|
|
name: 'Автор',
|
|
|
|
|
description: 'Создание и редактирование своих публикаций',
|
|
|
|
|
emoji: '📝'
|
|
|
|
|
},
|
|
|
|
|
{
|
2025-07-25 10:09:01 +03:00
|
|
|
|
id: 'reader',
|
2025-07-02 22:30:21 +03:00
|
|
|
|
name: 'Читатель',
|
|
|
|
|
description: 'Чтение и комментирование',
|
|
|
|
|
emoji: '📖'
|
|
|
|
|
}
|
2025-06-30 21:25:26 +03:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const UserEditModal: Component<UserEditModalProps> = (props) => {
|
|
|
|
|
const [formData, setFormData] = createSignal({
|
2025-07-02 22:30:21 +03:00
|
|
|
|
id: props.user.id,
|
2025-06-30 21:25:26 +03:00
|
|
|
|
email: props.user.email || '',
|
|
|
|
|
name: props.user.name || '',
|
|
|
|
|
slug: props.user.slug || '',
|
2025-07-25 10:09:01 +03:00
|
|
|
|
roles: props.user.roles || []
|
2025-06-30 21:25:26 +03:00
|
|
|
|
})
|
2025-07-02 22:30:21 +03:00
|
|
|
|
|
2025-06-30 21:25:26 +03:00
|
|
|
|
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
2025-07-02 22:30:21 +03:00
|
|
|
|
const [loading, setLoading] = createSignal(false)
|
|
|
|
|
|
|
|
|
|
// Проверяем, является ли пользователь администратором по ролям, которые приходят с сервера
|
|
|
|
|
const isAdmin = () => {
|
2025-07-25 10:50:03 +03:00
|
|
|
|
const roles = formData().roles
|
|
|
|
|
return roles.includes('admin') || (props.user.email ? ADMIN_EMAILS.includes(props.user.email) : false)
|
2025-07-02 22:30:21 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Получаем информацию о роли по ID
|
|
|
|
|
const getRoleInfo = (roleId: string) => {
|
2025-07-03 00:20:10 +03:00
|
|
|
|
return AVAILABLE_ROLES.find((role) => role.id === roleId) || { name: roleId, emoji: '👤' }
|
2025-07-02 22:30:21 +03:00
|
|
|
|
}
|
|
|
|
|
|
2025-07-25 10:50:03 +03:00
|
|
|
|
// Обновляем поле формы
|
|
|
|
|
const updateField = (field: keyof ReturnType<typeof formData>, value: string) => {
|
|
|
|
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
|
|
|
|
if (errors()[field]) {
|
|
|
|
|
setErrors((prev) => {
|
|
|
|
|
const newErrors = { ...prev }
|
|
|
|
|
delete newErrors[field]
|
|
|
|
|
return newErrors
|
|
|
|
|
})
|
2025-07-02 22:30:21 +03:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-30 21:25:26 +03:00
|
|
|
|
|
2025-07-02 22:30:21 +03:00
|
|
|
|
// Обновляем форму при изменении пользователя
|
2025-06-30 21:25:26 +03:00
|
|
|
|
createEffect(() => {
|
2025-07-02 22:30:21 +03:00
|
|
|
|
if (props.user) {
|
2025-06-30 21:25:26 +03:00
|
|
|
|
setFormData({
|
2025-07-02 22:30:21 +03:00
|
|
|
|
id: props.user.id,
|
2025-06-30 21:25:26 +03:00
|
|
|
|
email: props.user.email || '',
|
|
|
|
|
name: props.user.name || '',
|
|
|
|
|
slug: props.user.slug || '',
|
2025-07-25 10:09:01 +03:00
|
|
|
|
roles: props.user.roles || []
|
2025-06-30 21:25:26 +03:00
|
|
|
|
})
|
|
|
|
|
setErrors({})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2025-07-02 22:30:21 +03:00
|
|
|
|
const handleRoleToggle = (roleId: string) => {
|
2025-07-25 10:09:01 +03:00
|
|
|
|
if (roleId === 'admin') {
|
2025-07-25 09:58:34 +03:00
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-02 22:30:21 +03:00
|
|
|
|
setFormData((prev) => {
|
2025-07-25 10:50:03 +03:00
|
|
|
|
const currentRoles = prev.roles || []
|
2025-07-02 22:30:21 +03:00
|
|
|
|
const newRoles = currentRoles.includes(roleId)
|
2025-07-25 10:50:03 +03:00
|
|
|
|
? currentRoles.filter((r: string) => r !== roleId)
|
2025-07-02 22:30:21 +03:00
|
|
|
|
: [...currentRoles, roleId]
|
|
|
|
|
return { ...prev, roles: newRoles }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (errors().roles) {
|
2025-07-25 10:50:03 +03:00
|
|
|
|
setErrors((prev) => {
|
|
|
|
|
const newErrors = { ...prev }
|
|
|
|
|
delete newErrors.roles
|
|
|
|
|
return newErrors
|
|
|
|
|
})
|
2025-07-02 22:30:21 +03:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validateForm = (): boolean => {
|
2025-06-30 21:25:26 +03:00
|
|
|
|
const newErrors: Record<string, string> = {}
|
|
|
|
|
const data = formData()
|
|
|
|
|
|
2025-07-25 10:09:01 +03:00
|
|
|
|
if (!data.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email.trim())) {
|
2025-07-02 22:30:21 +03:00
|
|
|
|
newErrors.email = 'Неверный формат email'
|
2025-06-30 21:25:26 +03:00
|
|
|
|
}
|
|
|
|
|
|
2025-07-25 10:09:01 +03:00
|
|
|
|
if (!data.name.trim() || data.name.trim().length < 2) {
|
2025-07-02 22:30:21 +03:00
|
|
|
|
newErrors.name = 'Имя должно содержать минимум 2 символа'
|
2025-06-30 21:25:26 +03:00
|
|
|
|
}
|
|
|
|
|
|
2025-07-25 10:09:01 +03:00
|
|
|
|
if (!data.slug.trim() || !/^[a-z0-9_-]+$/.test(data.slug.trim())) {
|
2025-06-30 21:25:26 +03:00
|
|
|
|
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-25 10:50:03 +03:00
|
|
|
|
if (!isAdmin() && (data.roles || []).filter((role: string) => role !== 'admin').length === 0) {
|
2025-07-25 10:09:01 +03:00
|
|
|
|
newErrors.roles = 'Выберите хотя бы одну роль'
|
2025-06-30 21:25:26 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setErrors(newErrors)
|
|
|
|
|
return Object.keys(newErrors).length === 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
if (!validateForm()) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
try {
|
2025-07-25 10:50:03 +03:00
|
|
|
|
await props.onSave({
|
|
|
|
|
...formData(),
|
|
|
|
|
roles: (formData().roles || []).join(',')
|
|
|
|
|
})
|
2025-06-30 21:25:26 +03:00
|
|
|
|
props.onClose()
|
|
|
|
|
} catch (error) {
|
2025-07-02 22:30:21 +03:00
|
|
|
|
console.error('Ошибка при сохранении пользователя:', error)
|
|
|
|
|
setErrors({ general: 'Ошибка при сохранении пользователя' })
|
2025-06-30 21:25:26 +03:00
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
isOpen={props.isOpen}
|
|
|
|
|
onClose={props.onClose}
|
2025-07-02 22:30:21 +03:00
|
|
|
|
title={`Редактирование пользователя #${props.user.id}`}
|
2025-06-30 21:25:26 +03:00
|
|
|
|
>
|
2025-07-02 22:30:21 +03:00
|
|
|
|
<div class={formStyles.form}>
|
2025-07-25 10:50:03 +03:00
|
|
|
|
{/* Основные данные */}
|
2025-07-02 22:30:21 +03:00
|
|
|
|
<div class={formStyles.fieldGroup}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'grid',
|
2025-07-25 10:50:03 +03:00
|
|
|
|
'grid-template-columns': 'repeat(auto-fit, minmax(250px, 1fr))',
|
|
|
|
|
gap: '1rem'
|
2025-07-02 22:30:21 +03:00
|
|
|
|
}}
|
|
|
|
|
>
|
2025-07-25 10:50:03 +03:00
|
|
|
|
<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
|
|
|
|
|
type="email"
|
|
|
|
|
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={formStyles.fieldError}>
|
|
|
|
|
<span class={formStyles.errorIcon}>⚠️</span>
|
|
|
|
|
{errors().email}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-06-30 21:25:26 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
2025-07-25 10:50:03 +03:00
|
|
|
|
<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
|
|
|
|
|
type="text"
|
|
|
|
|
class={`${formStyles.input} ${errors().name ? formStyles.error : ''}`}
|
|
|
|
|
value={formData().name}
|
|
|
|
|
onInput={(e) => updateField('name', e.currentTarget.value)}
|
|
|
|
|
disabled={loading()}
|
|
|
|
|
placeholder="Иван Иванов"
|
|
|
|
|
/>
|
|
|
|
|
{errors().name && (
|
|
|
|
|
<div class={formStyles.fieldError}>
|
|
|
|
|
<span class={formStyles.errorIcon}>⚠️</span>
|
|
|
|
|
{errors().name}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-07-02 22:30:21 +03:00
|
|
|
|
</div>
|
2025-06-30 21:25:26 +03:00
|
|
|
|
|
2025-07-25 10:50:03 +03:00
|
|
|
|
<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
|
|
|
|
|
type="text"
|
|
|
|
|
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''}`}
|
|
|
|
|
value={formData().slug}
|
|
|
|
|
onInput={(e) => updateField('slug', e.currentTarget.value.toLowerCase())}
|
|
|
|
|
disabled={loading()}
|
|
|
|
|
placeholder="ivan-ivanov"
|
|
|
|
|
/>
|
|
|
|
|
{errors().slug && (
|
|
|
|
|
<div class={formStyles.fieldError}>
|
|
|
|
|
<span class={formStyles.errorIcon}>⚠️</span>
|
|
|
|
|
{errors().slug}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-06-30 21:25:26 +03:00
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Роли */}
|
2025-07-02 22:30:21 +03:00
|
|
|
|
<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>
|
2025-06-30 21:25:26 +03:00
|
|
|
|
|
2025-07-02 22:30:21 +03:00
|
|
|
|
<div class={formStyles.rolesGrid}>
|
2025-06-30 21:25:26 +03:00
|
|
|
|
<For each={AVAILABLE_ROLES}>
|
2025-07-25 09:58:34 +03:00
|
|
|
|
{(role) => {
|
2025-07-25 10:09:01 +03:00
|
|
|
|
const isAdminRole = role.id === 'admin'
|
2025-07-25 10:50:03 +03:00
|
|
|
|
const isSelected = (formData().roles || []).includes(role.id)
|
2025-07-25 10:09:01 +03:00
|
|
|
|
const isDisabled = isAdminRole
|
2025-07-25 10:50:03 +03:00
|
|
|
|
const roleInfo = getRoleInfo(role.id)
|
2025-07-25 09:58:34 +03:00
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<label
|
2025-07-25 10:09:01 +03:00
|
|
|
|
class={`${formStyles.roleCard} ${isSelected ? formStyles.roleCardSelected : ''} ${isDisabled ? formStyles.roleCardDisabled : ''}`}
|
2025-07-25 09:58:34 +03:00
|
|
|
|
style={{
|
|
|
|
|
opacity: isDisabled ? 0.7 : 1,
|
|
|
|
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
|
|
|
background: isAdminRole && isSelected ? 'rgba(245, 158, 11, 0.1)' : undefined,
|
|
|
|
|
border: isAdminRole && isSelected ? '1px solid rgba(245, 158, 11, 0.3)' : undefined
|
|
|
|
|
}}
|
2025-07-25 11:18:12 +03:00
|
|
|
|
onClick={() => !isDisabled && handleRoleToggle(role.id)}
|
2025-07-25 09:58:34 +03:00
|
|
|
|
>
|
|
|
|
|
<div class={formStyles.roleHeader}>
|
|
|
|
|
<span class={formStyles.roleName}>
|
|
|
|
|
<span style={{ 'margin-right': '0.5rem', 'font-size': '1.1rem' }}>
|
2025-07-25 10:50:03 +03:00
|
|
|
|
{roleInfo.emoji}
|
2025-07-25 09:58:34 +03:00
|
|
|
|
</span>
|
|
|
|
|
{role.name}
|
|
|
|
|
{isAdminRole && (
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
'margin-left': '0.5rem',
|
|
|
|
|
'font-size': '0.75rem',
|
|
|
|
|
color: '#d97706',
|
|
|
|
|
'font-weight': 'normal'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
(системная)
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
2025-07-25 11:12:19 +03:00
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
width: '20px',
|
|
|
|
|
height: '20px',
|
|
|
|
|
'border-radius': '50%',
|
|
|
|
|
border: `2px solid ${isSelected ? '#3b82f6' : '#a1a1aa'}`,
|
|
|
|
|
'background-color': isSelected ? '#3b82f6' : 'transparent',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
'align-items': 'center',
|
|
|
|
|
'justify-content': 'center',
|
|
|
|
|
cursor: isDisabled ? 'not-allowed' : 'pointer'
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-07-25 11:18:12 +03:00
|
|
|
|
<Show when={isSelected}>
|
2025-07-25 11:12:19 +03:00
|
|
|
|
<svg
|
|
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
|
|
width="12"
|
|
|
|
|
height="12"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="white"
|
|
|
|
|
stroke-width="3"
|
|
|
|
|
stroke-linecap="round"
|
|
|
|
|
stroke-linejoin="round"
|
|
|
|
|
>
|
|
|
|
|
<polyline points="20 6 9 17 4 12"></polyline>
|
|
|
|
|
</svg>
|
2025-07-25 11:18:12 +03:00
|
|
|
|
</Show>
|
2025-07-25 11:12:19 +03:00
|
|
|
|
</div>
|
2025-07-25 09:58:34 +03:00
|
|
|
|
</div>
|
|
|
|
|
<div class={formStyles.roleDescription}>{role.description}</div>
|
|
|
|
|
</label>
|
|
|
|
|
)
|
|
|
|
|
}}
|
2025-06-30 21:25:26 +03:00
|
|
|
|
</For>
|
|
|
|
|
</div>
|
2025-07-02 22:30:21 +03:00
|
|
|
|
|
|
|
|
|
{!isAdmin() && errors().roles && (
|
|
|
|
|
<div class={formStyles.fieldError}>
|
|
|
|
|
<span class={formStyles.errorIcon}>⚠️</span>
|
|
|
|
|
{errors().roles}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div class={formStyles.hint}>
|
|
|
|
|
<span class={formStyles.hintIcon}>💡</span>
|
2025-07-25 09:58:34 +03:00
|
|
|
|
Системные роли (администратор) назначаются автоматически и не могут быть изменены вручную.
|
|
|
|
|
{!isAdmin() &&
|
|
|
|
|
' Выберите дополнительные роли для пользователя - минимум одна роль обязательна.'}
|
2025-07-02 22:30:21 +03:00
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Общая ошибка */}
|
|
|
|
|
{errors().general && (
|
|
|
|
|
<div class={formStyles.fieldError}>
|
|
|
|
|
<span class={formStyles.errorIcon}>⚠️</span>
|
|
|
|
|
{errors().general}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Компактные кнопки действий */}
|
2025-07-25 10:50:03 +03:00
|
|
|
|
<div class={formStyles.actions}>
|
|
|
|
|
<Button type="button" onClick={props.onClose} disabled={loading()}>
|
2025-07-02 22:30:21 +03:00
|
|
|
|
Отмена
|
|
|
|
|
</Button>
|
2025-07-25 10:50:03 +03:00
|
|
|
|
<Button type="button" onClick={handleSave} disabled={loading()}>
|
2025-07-02 22:30:21 +03:00
|
|
|
|
Сохранить
|
|
|
|
|
</Button>
|
2025-06-30 21:25:26 +03:00
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default UserEditModal
|