305 lines
14 KiB
Vue
305 lines
14 KiB
Vue
<template>
|
|
<div>
|
|
<div class="mb-6 flex items-center justify-between">
|
|
<h1 class="text-2xl font-bold text-gray-900">Администраторы</h1>
|
|
<button @click="openCreate" class="btn-primary flex items-center gap-1.5 text-sm">
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
|
</svg>
|
|
Добавить админа
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Change own password -->
|
|
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-5">
|
|
<h2 class="mb-3 text-sm font-bold uppercase text-gray-500">Сменить свой пароль</h2>
|
|
<form @submit.prevent="changeOwnPassword" class="flex items-end gap-3">
|
|
<div class="flex-1">
|
|
<label class="mb-1 block text-xs text-gray-500">Текущий пароль</label>
|
|
<input v-model="pwd.current" type="password" required class="input-field" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<label class="mb-1 block text-xs text-gray-500">Новый пароль</label>
|
|
<input v-model="pwd.new_password" type="password" required minlength="6" class="input-field" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<label class="mb-1 block text-xs text-gray-500">Повтор нового пароля</label>
|
|
<input v-model="pwd.confirm" type="password" required minlength="6" class="input-field" />
|
|
</div>
|
|
<button type="submit" :disabled="pwdSaving" class="btn-primary whitespace-nowrap text-sm">
|
|
{{ pwdSaving ? '...' : 'Сменить' }}
|
|
</button>
|
|
</form>
|
|
<p v-if="pwdError" class="mt-2 text-sm text-red-600">{{ pwdError }}</p>
|
|
<p v-if="pwdSuccess" class="mt-2 text-sm text-green-600">{{ pwdSuccess }}</p>
|
|
</div>
|
|
|
|
<!-- Users list -->
|
|
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
|
|
|
<div v-else class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
|
<table class="w-full text-sm">
|
|
<thead class="border-b border-gray-200 bg-gray-50">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left font-semibold text-gray-600">ID</th>
|
|
<th class="px-4 py-3 text-left font-semibold text-gray-600">Имя</th>
|
|
<th class="px-4 py-3 text-left font-semibold text-gray-600">Email</th>
|
|
<th class="px-4 py-3 text-center font-semibold text-gray-600">Активен</th>
|
|
<th class="px-4 py-3 text-left font-semibold text-gray-600">Создан</th>
|
|
<th class="px-4 py-3 text-right font-semibold text-gray-600">Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
<tr v-for="u in users" :key="u.id" class="hover:bg-gray-50 transition-colors">
|
|
<td class="px-4 py-3 text-gray-400">{{ u.id }}</td>
|
|
<td class="px-4 py-3 font-medium text-gray-900">{{ u.name }}</td>
|
|
<td class="px-4 py-3 text-gray-700">{{ u.email }}</td>
|
|
<td class="px-4 py-3 text-center">
|
|
<span v-if="u.is_active" class="text-green-600">✓</span>
|
|
<span v-else class="text-red-400">✕</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-500 text-xs">{{ formatDate(u.created_at) }}</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<button @click="openEdit(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700" title="Редактировать">
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
|
</svg>
|
|
</button>
|
|
<button @click="openResetPassword(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-amber-50 hover:text-amber-600" title="Сбросить пароль">
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
|
</svg>
|
|
</button>
|
|
<button @click="confirmDelete(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600" title="Удалить">
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Create/Edit modal -->
|
|
<Teleport to="body">
|
|
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="showModal = false">
|
|
<div class="w-full max-w-md rounded-xl bg-white shadow-2xl">
|
|
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
|
<h2 class="text-lg font-bold">{{ editingUser ? 'Редактировать админа' : 'Новый администратор' }}</h2>
|
|
<button @click="showModal = false" class="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<form @submit.prevent="saveUser" class="p-5 space-y-4">
|
|
<div>
|
|
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Имя</label>
|
|
<input v-model="form.name" required class="input-field" placeholder="Иван Иванов" />
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Email</label>
|
|
<input v-model="form.email" type="email" required class="input-field" placeholder="admin@bamburussia.ru" />
|
|
</div>
|
|
<div v-if="!editingUser">
|
|
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Пароль</label>
|
|
<input v-model="form.password" type="password" required minlength="6" class="input-field" />
|
|
</div>
|
|
<div v-if="editingUser">
|
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300" />
|
|
Активен
|
|
</label>
|
|
</div>
|
|
<p v-if="formError" class="text-sm text-red-600">{{ formError }}</p>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button type="button" @click="showModal = false" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Отмена
|
|
</button>
|
|
<button type="submit" :disabled="saving" class="btn-primary text-sm">
|
|
{{ saving ? 'Сохранение...' : (editingUser ? 'Сохранить' : 'Создать') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Reset password modal -->
|
|
<Teleport to="body">
|
|
<div v-if="resetUser" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="resetUser = null">
|
|
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
|
<h3 class="text-lg font-bold text-gray-900 mb-1">Сбросить пароль</h3>
|
|
<p class="text-sm text-gray-500 mb-4">{{ resetUser.name }} ({{ resetUser.email }})</p>
|
|
<form @submit.prevent="doResetPassword" class="space-y-3">
|
|
<div>
|
|
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Новый пароль</label>
|
|
<input v-model="resetNewPassword" type="password" required minlength="6" class="input-field" />
|
|
</div>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button type="button" @click="resetUser = null" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Отмена
|
|
</button>
|
|
<button type="submit" class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">
|
|
Сбросить
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Delete confirm -->
|
|
<Teleport to="body">
|
|
<div v-if="deletingUser" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="deletingUser = null">
|
|
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
|
<h3 class="text-lg font-bold text-gray-900 mb-2">Удалить администратора?</h3>
|
|
<p class="text-sm text-gray-600 mb-5">
|
|
<strong>{{ deletingUser.name }}</strong> ({{ deletingUser.email }}) будет удалён.
|
|
</p>
|
|
<div class="flex justify-end gap-2">
|
|
<button @click="deletingUser = null" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Отмена
|
|
</button>
|
|
<button @click="doDelete" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
|
Удалить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import api from '../../api/client'
|
|
|
|
const users = ref([])
|
|
const loading = ref(true)
|
|
const showModal = ref(false)
|
|
const saving = ref(false)
|
|
const editingUser = ref(null)
|
|
const formError = ref('')
|
|
const deletingUser = ref(null)
|
|
const resetUser = ref(null)
|
|
const resetNewPassword = ref('')
|
|
|
|
const form = ref({ name: '', email: '', password: '', is_active: true })
|
|
|
|
// Change own password
|
|
const pwd = ref({ current: '', new_password: '', confirm: '' })
|
|
const pwdSaving = ref(false)
|
|
const pwdError = ref('')
|
|
const pwdSuccess = ref('')
|
|
|
|
onMounted(() => loadUsers())
|
|
|
|
async function loadUsers() {
|
|
loading.value = true
|
|
try {
|
|
const { data } = await api.get('/admin/users')
|
|
users.value = data
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
editingUser.value = null
|
|
form.value = { name: '', email: '', password: '', is_active: true }
|
|
formError.value = ''
|
|
showModal.value = true
|
|
}
|
|
|
|
function openEdit(u) {
|
|
editingUser.value = u
|
|
form.value = { name: u.name, email: u.email, password: '', is_active: u.is_active }
|
|
formError.value = ''
|
|
showModal.value = true
|
|
}
|
|
|
|
async function saveUser() {
|
|
saving.value = true
|
|
formError.value = ''
|
|
try {
|
|
if (editingUser.value) {
|
|
await api.put(`/admin/users/${editingUser.value.id}`, {
|
|
name: form.value.name,
|
|
email: form.value.email,
|
|
is_active: form.value.is_active,
|
|
})
|
|
} else {
|
|
await api.post('/admin/users', {
|
|
name: form.value.name,
|
|
email: form.value.email,
|
|
password: form.value.password,
|
|
})
|
|
}
|
|
showModal.value = false
|
|
await loadUsers()
|
|
} catch (e) {
|
|
formError.value = e.response?.data?.detail || 'Ошибка сохранения'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function openResetPassword(u) {
|
|
resetUser.value = u
|
|
resetNewPassword.value = ''
|
|
}
|
|
|
|
async function doResetPassword() {
|
|
await api.post(`/admin/users/${resetUser.value.id}/reset-password`, {
|
|
new_password: resetNewPassword.value,
|
|
})
|
|
resetUser.value = null
|
|
}
|
|
|
|
function confirmDelete(u) {
|
|
deletingUser.value = u
|
|
}
|
|
|
|
async function doDelete() {
|
|
try {
|
|
await api.delete(`/admin/users/${deletingUser.value.id}`)
|
|
deletingUser.value = null
|
|
await loadUsers()
|
|
} catch (e) {
|
|
alert(e.response?.data?.detail || 'Ошибка удаления')
|
|
deletingUser.value = null
|
|
}
|
|
}
|
|
|
|
async function changeOwnPassword() {
|
|
pwdError.value = ''
|
|
pwdSuccess.value = ''
|
|
if (pwd.value.new_password !== pwd.value.confirm) {
|
|
pwdError.value = 'Пароли не совпадают'
|
|
return
|
|
}
|
|
pwdSaving.value = true
|
|
try {
|
|
await api.post('/admin/change-password', {
|
|
current_password: pwd.value.current,
|
|
new_password: pwd.value.new_password,
|
|
})
|
|
pwdSuccess.value = 'Пароль успешно изменён'
|
|
pwd.value = { current: '', new_password: '', confirm: '' }
|
|
} catch (e) {
|
|
pwdError.value = e.response?.data?.detail || 'Ошибка смены пароля'
|
|
} finally {
|
|
pwdSaving.value = false
|
|
}
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return ''
|
|
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
</script>
|