add user privileges & add translations

This commit is contained in:
2026-04-20 19:12:27 +03:00
parent f16a830eb2
commit fc96a95335
17 changed files with 1073 additions and 426 deletions

View File

@@ -13,6 +13,7 @@
"axios": "^1.15.0", "axios": "^1.15.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.31", "vue": "^3.5.31",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,11 +1,14 @@
<template> <template>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="fixed inset-y-0 left-0 w-64 bg-white border-r border-gray-200"> <aside
class="fixed inset-y-0 left-0 bg-white border-r border-gray-200 transition-all duration-300 z-20"
:class="sidebarCollapsed ? 'w-16' : 'w-64'"
>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<!-- Logo --> <!-- Logo / Toggle Button -->
<div class="flex items-center h-16 px-6 border-b border-gray-200"> <div class="flex items-center h-16 px-4 border-b border-gray-200" :class="sidebarCollapsed ? 'justify-center' : 'justify-between'">
<div class="flex items-center space-x-2"> <div v-if="!sidebarCollapsed" class="flex items-center space-x-2">
<div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
@@ -13,82 +16,109 @@
</div> </div>
<span class="text-xl font-bold text-gray-900">{{ settings.siteName }}</span> <span class="text-xl font-bold text-gray-900">{{ settings.siteName }}</span>
</div> </div>
<button
@click="toggleSidebar"
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 transition-colors"
:class="sidebarCollapsed ? 'mx-auto' : ''"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="!sidebarCollapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto"> <nav class="flex-1 px-2 py-6 space-y-1 overflow-y-auto">
<router-link <router-link
to="/dashboard" to="/dashboard"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors group" class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/dashboard' }" :class="[
$route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.dashboard') : ''"
> >
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg> </svg>
Dashboard <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.dashboard') }}</span>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.role === 'admin'"
to="/users" to="/users"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors" class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/users' }" :class="[
$route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.users') : ''"
> >
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg> </svg>
Users <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.users') }}</span>
</router-link> </router-link>
<router-link <router-link
to="/restaurants" to="/restaurants"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors" class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/restaurants' }" :class="[
$route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.restaurants') : ''"
> >
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg> </svg>
Restaurants <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.role === 'admin'"
to="/settings" to="/settings"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors" class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/settings' }" :class="[
$route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.settings') : ''"
> >
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
Settings <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
</router-link> </router-link>
</nav> </nav>
<!-- User Profile --> <!-- User Info (collapsed aware) -->
<div class="p-4 border-t border-gray-200"> <div v-if="!sidebarCollapsed" class="p-4 border-t border-gray-200">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold"> <div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold">
{{ userInitials }} {{ userInitials }}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p> <p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p>
<p class="text-xs text-gray-500 truncate">Administrator</p> <p class="text-xs text-gray-500 truncate">{{ userStore.role === 'admin' ? 'Administrator' : 'User' }}</p>
</div> </div>
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"> </div>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> <div v-else class="p-2 border-t border-gray-200 flex justify-center">
</svg> <div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold text-sm">
</button> {{ userInitials }}
</div> </div>
</div> </div>
</div> </div>
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
<main class="ml-64"> <main class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-16' : 'ml-64'">
<!-- Header --> <!-- Header (без заголовка) -->
<header class="bg-white border-b border-gray-200"> <header class="bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="flex items-center justify-between h-16 px-8"> <div class="flex items-center justify-end h-16 px-8">
<h1 class="text-2xl font-semibold text-gray-900">{{ pageTitle }}</h1>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- Search --> <!-- Search -->
<div class="relative"> <div class="relative">
@@ -109,6 +139,25 @@
</svg> </svg>
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span> <span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button> </button>
<!-- User actions -->
<div class="flex items-center space-x-1 border-l pl-4 ml-2">
<button @click="toggleLanguage" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.language')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
</button>
<router-link to="/profile" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.profile')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</router-link>
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.logout')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -125,33 +174,47 @@
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings' import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
import { useI18n } from 'vue-i18n'
const settings = useSettingsStore() const settings = useSettingsStore()
const userStore = useUserStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userName = ref('Loading...') const { t, locale } = useI18n()
const userLogin = ref('')
onMounted(async () => { const userName = computed(() => userStore.login || 'User')
try { const userInitials = computed(() => (userName.value[0] || 'U').toUpperCase())
const res = await fetch('/api/admin/me')
if (res.ok) { // Sidebar collapsed state with localStorage
const data = await res.json() const SIDEBAR_STORAGE_KEY = 'admin_sidebar_collapsed'
userLogin.value = data.login const sidebarCollapsed = ref(false)
userName.value = data.login // или можно сделать красивое отображение
} onMounted(() => {
} catch (e) { const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY)
userName.value = 'User' if (saved !== null) {
sidebarCollapsed.value = saved === 'true'
} }
}) })
const userInitials = computed(() => { function toggleSidebar() {
return (userName.value[0] || 'U').toUpperCase() sidebarCollapsed.value = !sidebarCollapsed.value
}) localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarCollapsed.value))
}
async function logout() { async function logout() {
await fetch('/api/logout', { method: 'POST' }) await fetch('/api/logout', { method: 'POST' })
userStore.clear()
router.push('/login') router.push('/login')
} }
async function toggleLanguage() {
const newLang = locale.value === 'en' ? 'ru' : 'en'
const ok = await userStore.updateProfile({ language: newLang })
if (ok) {
locale.value = newLang
} else {
locale.value = newLang
}
}
</script> </script>

View File

@@ -0,0 +1,35 @@
{
"app": {
"title": "Admin Panel",
"dashboard": "Dashboard",
"users": "Users",
"restaurants": "Restaurants",
"settings": "Settings",
"profile": "Profile",
"logout": "Logout",
"language": "Language"
},
"user": {
"pageName": "Users Management",
"add": " Add User",
"edit": "Edit User"
},
"login": {
"title": "Welcome Back",
"subtitle": "Sign in to your account",
"username": "Username or Email",
"password": "Password",
"remember": "Remember me",
"signin": "Sign In",
"createAccount": "Create account"
},
"profile": {
"title": "My Profile",
"email": "Email",
"password": "Password",
"newPassword": "New Password",
"language": "Language",
"save": "Save Changes",
"role": "Role"
}
}

View File

@@ -0,0 +1,35 @@
{
"app": {
"title": "Панель администратора",
"dashboard": "Панель управления",
"users": "Пользователи",
"restaurants": "Рестораны",
"settings": "Настройки",
"profile": "Профиль",
"logout": "Выйти",
"language": "Язык"
},
"user": {
"pageName": "Управление пользователями",
"add": "Добавить пользователя",
"edit": "Редактировать пользователя"
},
"login": {
"title": "С возвращением",
"subtitle": "Войдите в свой аккаунт",
"username": "Имя пользователя или Email",
"password": "Пароль",
"remember": "Запомнить меня",
"signin": "Войти",
"createAccount": "Создать аккаунт"
},
"profile": {
"title": "Мой профиль",
"email": "Email",
"password": "Пароль",
"newPassword": "Новый пароль",
"language": "Язык",
"save": "Сохранить",
"role": "Роль"
}
}

View File

@@ -4,14 +4,35 @@ import App from './App.vue'
import router from './router' import router from './router'
import './style.css' import './style.css'
import { useSettingsStore } from './stores/settings' import { useSettingsStore } from './stores/settings'
import { useUserStore } from './stores/user'
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import ru from './locales/ru.json'
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: { en, ru }
})
const app = createApp(App) const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
app.use(router) app.use(router)
app.use(i18n)
// Загружаем настройки до монтирования
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
settingsStore.loadSettings().then(() => { const userStore = useUserStore()
// Загружаем настройки и профиль
Promise.all([
settingsStore.loadSettings(),
userStore.fetchProfile().catch(() => {})
]).then(() => {
// Устанавливаем язык из профиля, если есть
if (userStore.language) {
i18n.global.locale.value = userStore.language
}
app.mount('#app') app.mount('#app')
}) })

View File

@@ -1,4 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useSettingsStore } from '../stores/settings'
import Login from '../views/auth/Login.vue' import Login from '../views/auth/Login.vue'
import Setup from '../views/auth/Setup.vue' import Setup from '../views/auth/Setup.vue'
import Register from '../views/auth/Register.vue' import Register from '../views/auth/Register.vue'
@@ -6,60 +8,29 @@ import Dashboard from '../views/Dashboard.vue'
import Users from '../views/Users.vue' import Users from '../views/Users.vue'
import Restaurants from '../views/Restaurants.vue' import Restaurants from '../views/Restaurants.vue'
import AdminSettings from '../views/AdminSettings.vue' import AdminSettings from '../views/AdminSettings.vue'
import Profile from '../views/Profile.vue'
import NotFound from '../views/NotFound.vue' import NotFound from '../views/NotFound.vue'
import { useSettingsStore } from '../stores/settings'
const routes = [ const routes = [
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } }, { path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } }, { path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } }, { path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
{ { path: '/', redirect: '/dashboard' },
path: '/', { path: '/dashboard', component: Dashboard, meta: { requiresAuth: true, title: 'Dashboard' } },
redirect: '/dashboard' { path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' } },
}, { path: '/restaurants', component: Restaurants, meta: { requiresAuth: true, title: 'Restaurants' } },
{ { path: '/settings', component: AdminSettings, meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' } },
path: '/dashboard', { path: '/profile', component: Profile, meta: { requiresAuth: true, title: 'Profile' } },
component: Dashboard, { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { title: 'Page Not Found', requiresAuth: false } }
meta: { requiresAuth: true, title: 'Dashboard' }
},
{
path: '/users',
component: Users,
meta: { requiresAuth: true, title: 'Users' }
},
{
path: '/restaurants',
component: Restaurants,
meta: { requiresAuth: true, title: 'Restaurants' }
},
{
path: '/settings',
component: AdminSettings,
meta: { requiresAuth: true, title: 'Settings' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: { title: 'Page Not Found', requiresAuth: false }
}
] ]
const router = createRouter({ const router = createRouter({ history: createWebHistory(), routes })
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
// Загружаем настройки приложения, если они ещё не загружены
const settings = useSettingsStore() const settings = useSettingsStore()
if (!settings.siteName) { if (!settings.siteName) await settings.loadSettings()
await settings.loadSettings()
}
// Устанавливаем заголовок страницы с использованием site_name document.title = to.meta.title ? `${to.meta.title} | ${settings.siteName}` : settings.siteName
const pageTitle = to.meta.title ? `${to.meta.title} | ${settings.siteName}` : settings.siteName
document.title = pageTitle
// Проверка необходимости установки (setup) // Проверка необходимости установки (setup)
try { try {
@@ -69,44 +40,33 @@ router.beforeEach(async (to, from, next) => {
next('/setup') next('/setup')
return return
} }
} catch (e) { } catch (e) { console.error('Failed to check status', e) }
console.error('Failed to check status', e)
const userStore = useUserStore()
// Если профиль ещё не загружен загружаем
if (userStore.role === '') {
await userStore.fetchProfile()
} }
// Проверка, что залогиненный пользователь не может зайти на страницу логина // Если уже залогинены и пытаемся зайти на login/register редирект на дашборд
if (to.path === '/login') { if (userStore.id && (to.path === '/login' || to.path === '/register')) {
try { next('/dashboard')
const meRes = await fetch('/api/admin/me') return
if (meRes.ok) {
next('/dashboard')
return
}
} catch (e) {
// игнорируем ошибку, продолжаем
}
} }
// Проверка доступности регистрации // Проверка доступности регистрации
if (to.path === '/register') { if (to.path === '/register' && !settings.enableRegistration) {
if (!settings.enableRegistration) { next('/login')
next('/login') return
return
}
} }
// Проверка аутентификации для защищённых маршрутов
const requiresAuth = to.matched.some(record => record.meta.requiresAuth) const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth) { const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
try {
const res = await fetch('/api/admin/me') if (requiresAuth && !userStore.id) {
if (!res.ok) { next('/login')
next('/login') } else if (requiresAdmin && userStore.role !== 'admin') {
} else { next('/dashboard')
next()
}
} catch {
next('/login')
}
} else { } else {
next() next()
} }

View File

@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const id = ref<number | null>(null)
const login = ref('')
const email = ref('')
const role = ref('')
const language = ref('en')
async function fetchProfile() {
try {
const res = await fetch('/api/admin/profile')
if (res.ok) {
const data = await res.json()
id.value = data.id
login.value = data.login
email.value = data.email
role.value = data.role
language.value = data.language || 'en'
return true
}
} catch (e) {
console.error('Failed to load profile', e)
}
return false
}
async function updateProfile(updates: { email?: string; password?: string; language?: string }) {
const res = await fetch('/api/admin/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
})
if (res.ok) {
if (updates.language) language.value = updates.language
if (updates.email) email.value = updates.email
return true
}
return false
}
function clear() {
id.value = null
login.value = ''
email.value = ''
role.value = ''
language.value = 'en'
}
return { id, login, email, role, language, fetchProfile, updateProfile, clear }
})

View File

@@ -2,68 +2,71 @@
<AppLayout> <AppLayout>
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="card animate-fade-in"> <div class="card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-600">Total Users</p> <p class="text-sm font-medium text-gray-600">Total Users</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalUsers }}</p> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalUsers }}</p>
</div> </div>
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-primary-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg> </svg>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center text-sm"> <div class="mt-4 flex items-center text-sm">
<span class="text-green-600 font-medium"> 12%</span> <span class="text-green-600 font-medium"> {{ userGrowth }}%</span>
<span class="text-gray-500 ml-2">from last month</span> <span class="text-gray-500 ml-2">vs last month</span>
</div> </div>
</div> </div>
<div class="card animate-fade-in" style="animation-delay: 0.1s"> <div class="card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-600">Active Sessions</p> <p class="text-sm font-medium text-gray-600">Active Sessions</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.activeSessions }}</p> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.activeSessions }}</p>
</div> </div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg> </svg>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center text-sm"> <div class="mt-4 flex items-center text-sm">
<span class="text-green-600 font-medium"> 5%</span> <span class="text-green-600 font-medium"> {{ sessionGrowth }}%</span>
<span class="text-gray-500 ml-2">from last hour</span> <span class="text-gray-500 ml-2">from last hour</span>
</div> </div>
</div> </div>
<div class="card animate-fade-in" style="animation-delay: 0.2s"> <div class="card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-600">System Health</p> <p class="text-sm font-medium text-gray-600">System Health</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.systemHealth }}%</p> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.systemHealth }}%</p>
</div> </div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg> </svg>
</div> </div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<div class="h-2 bg-gray-200 rounded-full"> <div class="h-2 bg-gray-200 rounded-full overflow-hidden">
<div class="h-2 bg-blue-600 rounded-full" :style="{ width: `${stats.systemHealth}%` }"></div> <div
class="h-full bg-blue-600 rounded-full transition-all duration-500"
:style="{ width: `${stats.systemHealth}%` }"
></div>
</div> </div>
</div> </div>
</div> </div>
<div class="card animate-fade-in" style="animation-delay: 0.3s"> <div class="card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-600">Uptime</p> <p class="text-sm font-medium text-gray-600">Uptime</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.uptime }}</p> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.uptime }}</p>
</div> </div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
@@ -78,10 +81,52 @@
</div> </div>
</div> </div>
<!-- Recent Activity --> <!-- Charts & Activity -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- User Activity Chart -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">User Activity (Last 7 days)</h3>
<div class="flex items-center space-x-2">
<button @click="activityPeriod = 'week'" class="text-xs px-2 py-1 rounded" :class="activityPeriod === 'week' ? 'bg-primary-100 text-primary-700' : 'text-gray-500'">Week</button>
<button @click="activityPeriod = 'month'" class="text-xs px-2 py-1 rounded" :class="activityPeriod === 'month' ? 'bg-primary-100 text-primary-700' : 'text-gray-500'">Month</button>
</div>
</div>
<div class="flex items-end space-x-2 h-48">
<div v-for="(value, index) in activityData" :key="index" class="flex-1 flex flex-col items-center">
<div class="w-full bg-primary-100 rounded-t-lg transition-all duration-500" :style="{ height: `${value}%`, minHeight: '4px' }"></div>
<span class="text-xs text-gray-500 mt-2">{{ activityLabels[index] }}</span>
</div>
</div>
</div>
<!-- System Services Status -->
<div class="card">
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Services</h3>
<div class="space-y-4">
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<div :class="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div>
<span class="text-gray-700 font-medium">{{ service.name }}</span>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
<span class="text-xs px-2 py-1 rounded-full" :class="service.status === 'up' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'">
{{ service.status === 'up' ? 'Operational' : 'Down' }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Users & Restaurants -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card"> <div class="card">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Recent Users</h3> <div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">Recent Users</h3>
<router-link to="/users" class="text-sm text-primary-600 hover:text-primary-700">View all </router-link>
</div>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="user in recentUsers" :key="user.id" class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors"> <div v-for="user in recentUsers" :key="user.id" class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
@@ -95,19 +140,33 @@
</div> </div>
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">New</span> <span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">New</span>
</div> </div>
<div v-if="recentUsers.length === 0" class="text-center text-gray-500 py-8">No users yet</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Status</h3> <div class="flex justify-between items-center mb-4">
<div class="space-y-4"> <h3 class="text-lg font-semibold text-gray-900">Recent Restaurants</h3>
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between"> <router-link to="/restaurants" class="text-sm text-primary-600 hover:text-primary-700">View all </router-link>
</div>
<div class="space-y-3">
<div v-for="rest in recentRestaurants" :key="rest.id" class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div :class="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div> <div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<span class="text-gray-700">{{ service.name }}</span> <svg class="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p class="font-medium text-gray-900">{{ rest.name }}</p>
<p class="text-sm text-gray-500">{{ rest.host }}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500">{{ formatDate(rest.created) }}</span>
</div> </div>
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
</div> </div>
<div v-if="recentRestaurants.length === 0" class="text-center text-gray-500 py-8">No restaurants yet</div>
</div> </div>
</div> </div>
</div> </div>
@@ -115,88 +174,73 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue' import AppLayout from '../components/Layout/AppLayout.vue';
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' }) const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' });
const userGrowth = ref(12);
const sessionGrowth = ref(5);
const recentUsers = ref([]);
const recentRestaurants = ref([]);
const systemServices = ref([]);
const activityPeriod = ref('week');
const activityData = ref([65, 78, 82, 71, 88, 94, 72]);
const activityLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
async function loadStats() { let interval: number;
async function loadDashboardData() {
try { try {
const [usersRes, sessionsRes, healthRes] = await Promise.all([ const [usersRes, sessionsRes, healthRes, restaurantsRes] = await Promise.all([
fetch('/api/admin/users'), fetch('/api/admin/users'),
fetch('/api/admin/active-sessions'), fetch('/api/admin/active-sessions'),
fetch('/api/health') fetch('/api/health'),
]) fetch('/api/admin/restaurants')
const users = await usersRes.json() ]);
const sessions = await sessionsRes.json()
const health = await healthRes.json()
stats.value.totalUsers = users.length const users = await usersRes.json();
stats.value.activeSessions = sessions.count || 0 const sessions = await sessionsRes.json();
const health = await healthRes.json();
const restaurants = await restaurantsRes.json();
const upCount = health.checks?.filter(c => c.status === 'UP').length || 0 stats.value.totalUsers = users.length;
const total = health.checks?.length || 1 stats.value.activeSessions = sessions.count || 0;
stats.value.systemHealth = Math.round((upCount / total) * 100) recentUsers.value = users.slice(-5).reverse();
} catch (e) { console.error(e) } recentRestaurants.value = restaurants.slice(-5).reverse();
}
onMounted(() => { const upCount = health.checks?.filter((c: any) => c.status === 'UP').length || 0;
loadStats() const total = health.checks?.length || 1;
const interval = setInterval(loadStats, 5000) stats.value.systemHealth = Math.round((upCount / total) * 100);
onUnmounted(() => clearInterval(interval))
})
const recentUsers = ref([]) if (health.checks) {
const systemServices = ref([]) systemServices.value = health.checks.map((check: any) => ({
async function loadHealth() {
try {
const res = await fetch('/api/health')
const data = await res.json()
if (data.checks) {
systemServices.value = data.checks.map(check => ({
name: check.data?.name || check.id, name: check.data?.name || check.id,
status: check.status.toLowerCase(), status: check.status.toLowerCase(),
latency: check.data?.latency_ms || 0 latency: check.data?.latency_ms || 0
})) }));
} }
} catch (e) { } catch (e) {
console.error('Health check failed', e) console.error('Failed to load dashboard data', e);
}
}
let interval: number
onMounted(async () => {
await loadData()
await loadHealth()
interval = window.setInterval(loadHealth, 5000)
})
onUnmounted(() => {
if (interval) clearInterval(interval)
})
onMounted(async () => {
await loadData()
})
async function loadData() {
try {
const res = await fetch('/api/admin/users')
const users = await res.json()
stats.value.totalUsers = users.length
recentUsers.value = users.slice(-5).reverse()
} catch (e) {
console.error('Failed to load data', e)
} }
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', { if (!dateStr) return '-';
month: 'short', const date = new Date(dateStr);
day: 'numeric', const now = new Date();
hour: '2-digit', const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
minute: '2-digit' if (diffDays === 0) return 'Today';
}) if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
} }
onMounted(() => {
loadDashboardData();
interval = window.setInterval(loadDashboardData, 30000);
});
onUnmounted(() => {
if (interval) clearInterval(interval);
});
</script> </script>

View File

@@ -0,0 +1,175 @@
<template>
<AppLayout>
<div class="max-w-4xl mx-auto">
<div class="card">
<div class="flex items-center space-x-4 mb-6">
<div class="relative">
<div class="w-20 h-20 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white text-2xl font-bold">
{{ userInitials }}
</div>
<button class="absolute bottom-0 right-0 p-1 bg-white rounded-full shadow-md hover:shadow-lg transition-shadow">
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">My Profile</h1>
<p class="text-gray-500">Manage your account settings</p>
</div>
</div>
<form @submit.prevent="saveProfile" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input v-model="userStore.login" type="text" disabled class="input-field bg-gray-100" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Role</label>
<div class="relative">
<input :value="userStore.role === 'admin' ? 'Administrator' : 'User'" type="text" disabled class="input-field bg-gray-100" />
<div class="absolute right-3 top-2.5">
<span class="px-2 py-0.5 text-xs rounded-full" :class="userStore.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'">
{{ userStore.role === 'admin' ? 'Admin' : 'User' }}
</span>
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
<input v-model="form.email" type="email" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
<input v-model="form.password" type="password" class="input-field" autocomplete="new-password" />
<p class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
<input v-model="form.confirmPassword" type="password" class="input-field" :class="{ 'border-red-300': passwordMismatch }" />
<p v-if="passwordMismatch" class="text-xs text-red-600 mt-1">Passwords do not match</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Language</label>
<select v-model="form.language" class="input-field">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<div class="pt-4 border-t">
<div class="flex justify-end space-x-3">
<button type="button" @click="resetForm" class="btn-secondary">Reset</button>
<button type="submit" :disabled="loading" class="btn-primary flex items-center gap-2">
<svg v-if="loading" class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Save Changes
</button>
</div>
</div>
</form>
<!-- Notification Toast -->
<Transition name="slide">
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<svg v-if="notification.type === 'success'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ notification.message }}</span>
</div>
</Transition>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useUserStore } from '../stores/user';
import { useI18n } from 'vue-i18n';
import AppLayout from '../components/Layout/AppLayout.vue';
const userStore = useUserStore();
const { locale } = useI18n();
const form = reactive({
email: '',
password: '',
confirmPassword: '',
language: 'en'
});
const loading = ref(false);
const notification = ref({ show: false, type: 'success', message: '' });
const userInitials = computed(() => (userStore.login[0] || 'U').toUpperCase());
const passwordMismatch = computed(() => !!form.password && form.password !== form.confirmPassword);
function showNotification(message: string, type: 'success' | 'error') {
notification.value = { show: true, type, message };
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
function resetForm() {
form.email = userStore.email;
form.password = '';
form.confirmPassword = '';
form.language = userStore.language;
}
async function saveProfile() {
if (form.password && form.password !== form.confirmPassword) {
showNotification('Passwords do not match', 'error');
return;
}
loading.value = true;
const updates: any = {
email: form.email,
language: form.language
};
if (form.password) updates.password = form.password;
const ok = await userStore.updateProfile(updates);
loading.value = false;
if (ok) {
locale.value = form.language;
showNotification('Profile updated successfully', 'success');
resetForm(); // очищаем поля пароля
} else {
showNotification('Failed to update profile', 'error');
}
}
onMounted(() => {
form.email = userStore.email;
form.language = userStore.language;
});
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -2,89 +2,143 @@
<AppLayout> <AppLayout>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Restaurants</h1> <h1 class="text-2xl font-bold text-gray-900">Restaurants</h1>
<button @click="openModal('create')" class="btn-primary">+ Add Restaurant</button> <button @click="openModal('create')" class="btn-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Restaurant
</button>
</div> </div>
<div class="card overflow-x-auto"> <div class="card overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <div class="overflow-x-auto">
<thead class="bg-gray-50"> <table class="min-w-full divide-y divide-gray-200">
<tr> <thead class="bg-gray-50">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Host</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">HTTPS</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Host</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">HTTPS</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Login</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
</tr> <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</thead> </tr>
<tbody class="divide-y divide-gray-200"> </thead>
<tr v-for="rest in restaurants" :key="rest.id"> <tbody class="divide-y divide-gray-200 bg-white">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.id }}</td> <tr v-for="rest in restaurants" :key="rest.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ rest.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
<input <td class="px-6 py-4 whitespace-nowrap text-sm">
type="checkbox" <label class="relative inline-flex items-center cursor-pointer">
:checked="rest.https" <input
@change="toggleHttps(rest)" type="checkbox"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4" :checked="rest.https"
/> @change="toggleHttps(rest)"
</td> class="sr-only peer"
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td> />
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td> <div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2"> <div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800">Edit</button> </label>
<button @click="deleteRestaurant(rest.id)" class="text-red-600 hover:text-red-800">Delete</button> </td>
</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td>
</tr> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
</tbody> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-3">
</table> <button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800 transition-colors">
</div> <svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<!-- Modal --> </svg>
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> </button>
<div class="bg-white rounded-lg p-6 w-full max-w-md"> <button @click="confirmDelete(rest.id)" class="text-red-600 hover:text-red-800 transition-colors">
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2> <svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<form @submit.prevent="submitRestaurant"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<!-- 1. Имя ресторана --> </svg>
<div class="mb-4"> </button>
<label class="block text-sm font-medium text-gray-700">Name</label> </td>
<input v-model="form.name" type="text" required class="input-field mt-1" /> </tr>
</div> <tr v-if="restaurants.length === 0">
<!-- 2. Хост --> <td colspan="7" class="px-6 py-12 text-center text-gray-500">No restaurants found. Click "Add Restaurant" to create one.</td>
<div class="mb-4"> </tr>
<label class="block text-sm font-medium text-gray-700">Host</label> </tbody>
<input v-model="form.host" type="text" required class="input-field mt-1" /> </table>
</div>
<!-- 3. HTTPS чекбокс -->
<div class="mb-4 flex items-center">
<input type="checkbox" v-model="form.https" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">HTTPS</label>
</div>
<!-- 4. Логин -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Login</label>
<input v-model="form.login" type="text" required class="input-field mt-1" />
</div>
<!-- 5. Пароль (отключаем автозаполнение) -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Password</label>
<input
v-model="form.password"
:required="modalMode === 'create'"
type="password"
class="input-field mt-1"
/>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
</div>
<div class="flex justify-end space-x-2">
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div> </div>
</div> </div>
<!-- Модалка создания/редактирования ресторана -->
<Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitRestaurant" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input v-model="form.name" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Host *</label>
<input v-model="form.host" type="text" required class="input-field" placeholder="e.g., api.example.com" />
</div>
<div class="flex items-center">
<input type="checkbox" v-model="form.https" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">Use HTTPS</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Login *</label>
<input v-model="form.login" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
v-model="form.password"
:required="modalMode === 'create'"
type="password"
class="input-field"
autocomplete="new-password"
/>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модалка подтверждения удаления ресторана -->
<Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Delete Restaurant</h3>
<p class="text-sm text-gray-500 mb-6">Are you sure you want to delete this restaurant? This action cannot be undone.</p>
<div class="flex justify-center space-x-3">
<button @click="deleteConfirm.show = false" class="btn-secondary">Cancel</button>
<button @click="deleteRestaurant(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">Delete</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</AppLayout> </AppLayout>
</template> </template>
@@ -97,6 +151,7 @@ const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create'); const modalMode = ref<'create' | 'edit'>('create');
const form = ref({ id: null, name: '', login: '', password: '', host: '', https: false }); const form = ref({ id: null, name: '', login: '', password: '', host: '', https: false });
const modalTitle = ref(''); const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
async function loadRestaurants() { async function loadRestaurants() {
const res = await fetch('/api/admin/restaurants'); const res = await fetch('/api/admin/restaurants');
@@ -138,7 +193,6 @@ async function toggleHttps(rest: any) {
host: rest.host, host: rest.host,
login: rest.login, login: rest.login,
https: newHttps https: newHttps
// пароль не передаём, он останется прежним
}; };
try { try {
const res = await fetch(`/api/admin/restaurants/${rest.id}`, { const res = await fetch(`/api/admin/restaurants/${rest.id}`, {
@@ -147,9 +201,7 @@ async function toggleHttps(rest: any) {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (res.ok) { if (res.ok) {
// Обновляем локальное состояние или перезагружаем список
rest.https = newHttps; rest.https = newHttps;
// Альтернатива: await loadRestaurants();
} else { } else {
alert('Failed to update HTTPS status'); alert('Failed to update HTTPS status');
} }
@@ -193,12 +245,26 @@ async function submitRestaurant() {
} }
} }
function confirmDelete(id: number) {
deleteConfirm.value = { show: true, id };
}
async function deleteRestaurant(id: number) { async function deleteRestaurant(id: number) {
if (confirm('Are you sure?')) { await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' }); await loadRestaurants();
await loadRestaurants(); deleteConfirm.value.show = false;
}
} }
onMounted(loadRestaurants); onMounted(loadRestaurants);
</script> </script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,87 +1,169 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Users Management</h1> <h1 class="text-2xl font-bold text-gray-900">{{ t('user.pageName') }}</h1>
<button @click="openModal('create')" class="btn-primary">+ Add User</button> <button @click="openModal('create')" class="btn-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('user.add') }}
</button>
</div> </div>
<div class="card overflow-x-auto"> <div class="card overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <div class="overflow-x-auto">
<thead class="bg-gray-50"> <table class="min-w-full divide-y divide-gray-200">
<tr> <thead class="bg-gray-50">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Login</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">IP</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
</tr> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
</thead> <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<tbody> </tr>
<tr v-for="user in users" :key="user.id"> </thead>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.id }}</td> <tbody class="divide-y divide-gray-200 bg-white">
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.login }}</td> <tr v-for="user in users" :key="user.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.email }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ user.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ user.login }}</td>
<input <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.email }}</td>
v-if="user.id !== currentUserId" <td class="px-6 py-4 whitespace-nowrap text-sm">
type="checkbox" <span class="px-2 py-1 text-xs rounded-full" :class="user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'">
:checked="user.active" {{ user.role === 'admin' ? 'Administrator' : 'User' }}
@change="toggleActive(user)" </span>
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4" </td>
/> <td class="px-6 py-4 whitespace-nowrap text-sm">
<span v-else class="text-gray-400 text-sm">(You)</span> <div v-if="user.id === currentUserId" class="text-xs text-gray-500">(You)</div>
</td> <label v-else class="relative inline-flex items-center cursor-pointer">
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.ip || '-' }}</td> <input
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ formatDate(user.created) }}</td> type="checkbox"
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2"> :checked="user.active"
<button @click="openModal('edit', user)" class="text-blue-600">Edit</button> @change="toggleActive(user)"
<button class="sr-only peer"
v-if="user.id !== currentUserId" />
@click="deleteUser(user.id)" <div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
class="text-red-600" <div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
> </label>
Delete </td>
</button> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.ip || '-' }}</td>
</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(user.created) }}</td>
</tr> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-3">
</tbody> <button @click="openModal('edit', user)" class="text-blue-600 hover:text-blue-800 transition-colors">
</table> <svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<!-- Modal --> </button>
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <button
<div class="bg-white rounded-lg p-6 w-full max-w-md"> v-if="user.id !== currentUserId"
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2> @click="confirmDelete(user.id)"
<form @submit.prevent="submitUser"> class="text-red-600 hover:text-red-800 transition-colors"
<div class="mb-4"> >
<label class="block text-sm font-medium text-gray-700">Email</label> <svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<input v-model="form.email" type="text" required class="input-field mt-1" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</div> </svg>
<div class="mb-4"> </button>
<label class="block text-sm font-medium text-gray-700">Login</label> </td>
<input v-model="form.login" type="text" required class="input-field mt-1" /> </tr>
</div> <tr v-if="users.length === 0">
<div class="mb-4"> <td colspan="8" class="px-6 py-12 text-center text-gray-500">No users found. Click "Add User" to create one.</td>
<label class="block text-sm font-medium text-gray-700">Password</label> </tr>
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" /> </tbody>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p> </table>
</div>
<div class="flex justify-end space-x-2">
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div> </div>
</div> </div>
<!-- Модалка создания/редактирования пользователя -->
<Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<!-- Затемнение с размытием -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitUser" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input v-model="form.email" type="email" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Login *</label>
<input v-model="form.login" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select v-model="form.role" class="input-field" :disabled="isEditingSelf">
<option value="user">User</option>
<option value="admin">Administrator</option>
</select>
<p v-if="isEditingSelf" class="text-xs text-amber-600 mt-1">You cannot change your own role</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
v-model="form.password"
:required="modalMode === 'create'"
type="password"
class="input-field"
autocomplete="new-password"
/>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модалка подтверждения удаления -->
<Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Delete User</h3>
<p class="text-sm text-gray-500 mb-6">Are you sure you want to delete this user? This action cannot be undone.</p>
<div class="flex justify-center space-x-3">
<button @click="deleteConfirm.show = false" class="btn-secondary">Cancel</button>
<button @click="deleteUser(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">Delete</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue'; import AppLayout from '../components/Layout/AppLayout.vue';
import { useUserStore } from '../stores/user';
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const userStore = useUserStore();
const currentUserId = ref<number | null>(null); const currentUserId = ref<number | null>(null);
async function loadCurrentUser() { async function loadCurrentUser() {
@@ -99,8 +181,13 @@ async function loadCurrentUser() {
const users = ref([]); const users = ref([]);
const modalOpen = ref(false); const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create'); const modalMode = ref<'create' | 'edit'>('create');
const form = ref({ id: null, login: '', email: '', password: '' }); const form = ref({ id: null, login: '', email: '', password: '', role: 'user' });
const modalTitle = ref(''); const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
const isEditingSelf = computed(() => {
return modalMode.value === 'edit' && form.value.id === currentUserId.value;
});
async function loadUsers() { async function loadUsers() {
const res = await fetch('/api/admin/users'); const res = await fetch('/api/admin/users');
@@ -113,18 +200,18 @@ function formatDate(dateStr: string) {
} }
async function toggleActive(user: any) { async function toggleActive(user: any) {
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' }) await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' });
await loadUsers() await loadUsers();
} }
function openModal(mode: 'create' | 'edit', user: any = null) { function openModal(mode: 'create' | 'edit', user: any = null) {
modalMode.value = mode; modalMode.value = mode;
if (mode === 'create') { if (mode === 'create') {
form.value = { id: null, login: '', email: '', password: '' }; form.value = { id: null, login: '', email: '', password: '', role: 'user' };
modalTitle.value = 'Create User'; modalTitle.value = t('user.add');
} else { } else {
form.value = { id: user.id, login: user.login, email: user.email, password: '' }; // добавлен email form.value = { id: user.id, login: user.login, email: user.email, password: '', role: user.role || 'user' };
modalTitle.value = 'Edit User'; modalTitle.value = t('user.edit');
} }
modalOpen.value = true; modalOpen.value = true;
} }
@@ -134,10 +221,16 @@ function closeModal() {
} }
async function submitUser() { async function submitUser() {
if (isEditingSelf.value && form.value.role !== userStore.role) {
alert('You cannot change your own role');
return;
}
try { try {
const payload: any = { const payload: any = {
login: form.value.login, login: form.value.login,
email: form.value.email, email: form.value.email,
role: form.value.role,
}; };
if (form.value.password) { if (form.value.password) {
payload.password = form.value.password; payload.password = form.value.password;
@@ -168,11 +261,14 @@ async function submitUser() {
} }
} }
function confirmDelete(id: number) {
deleteConfirm.value = { show: true, id };
}
async function deleteUser(id: number) { async function deleteUser(id: number) {
if (confirm('Are you sure?')) { await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); await loadUsers();
await loadUsers(); deleteConfirm.value.show = false;
}
} }
onMounted(async () => { onMounted(async () => {
@@ -180,3 +276,14 @@ onMounted(async () => {
await loadUsers(); await loadUsers();
}); });
</script> </script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -104,9 +104,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings' import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
const settings = useSettingsStore() const settings = useSettingsStore()
const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const form = ref({ login: '', password: '' }) const form = ref({ login: '', password: '' })
const loading = ref(false) const loading = ref(false)
@@ -116,24 +117,19 @@ const showPassword = ref(false)
async function handleLogin() { async function handleLogin() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value) body: JSON.stringify(form.value)
}) })
if (res.ok) { if (res.ok) {
// Загружаем профиль (роль, язык, email)
await userStore.fetchProfile()
router.push('/dashboard') router.push('/dashboard')
} else { } else {
// Пытаемся получить текст ошибки от сервера
const text = await res.text() const text = await res.text()
if (text && text.trim()) { error.value = text || 'Invalid username or password'
error.value = text
} else {
error.value = 'Invalid username or password'
}
} }
} catch (e) { } catch (e) {
error.value = 'Network error. Please try again.' error.value = 'Network error. Please try again.'

View File

@@ -18,6 +18,7 @@ import io.vertx.ext.web.sstore.SessionStore;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.AppConfig; import su.xserver.iikocon.config.AppConfig;
import su.xserver.iikocon.handler.AdminHandler;
import su.xserver.iikocon.handler.AuthHandler; import su.xserver.iikocon.handler.AuthHandler;
import su.xserver.iikocon.handler.SecurityHandler; import su.xserver.iikocon.handler.SecurityHandler;
import su.xserver.iikocon.handler.SetupHandler; import su.xserver.iikocon.handler.SetupHandler;
@@ -194,7 +195,50 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
})); }));
// В initRouter после настройки authHandler, до объявления /api/admin/*:
router.route("/api/admin/profile").handler(authHandler::requireAuth);
router.get("/api/admin/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
userService.getProfile(userId)
.onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
JsonObject body = rc.body().asJsonObject();
String email = body.getString("email");
String password = body.getString("password");
String language = body.getString("language");
userService.updateProfile(userId, email, password, language)
.onSuccess(v -> {
if (language != null) rc.session().put("language", language);
rc.response().end(new JsonObject().put("success", true).encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/language").handler(rc -> {
Integer userId = rc.session().get("userId");
JsonObject body = rc.body().asJsonObject();
String language = body.getString("language");
if (language == null || (!"en".equals(language) && !"ru".equals(language))) {
rc.response().setStatusCode(400).end("Invalid language");
return;
}
userService.updateLanguage(userId, language)
.onSuccess(v -> {
rc.session().put("language", language);
rc.response().end(new JsonObject().put("success", true).encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Затем существующий блок router.route("/api/admin/*").handler(authHandler::requireAuth);
router.route("/api/admin/*").handler(authHandler::requireAuth); router.route("/api/admin/*").handler(authHandler::requireAuth);
// Добавить проверку роли для чувствительных эндпоинтов:
// router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/restaurants*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/active-sessions").handler(AdminHandler::requireAdmin);
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> { router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
if (ar.succeeded()) { if (ar.succeeded()) {
@@ -211,13 +255,14 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login"); String login = body.getString("login");
String email = body.getString("email"); String email = body.getString("email");
String password = body.getString("password"); String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host(); String ip = rc.request().remoteAddress().host();
if (login == null || email == null || password == null) { if (login == null || email == null || password == null) {
rc.response().setStatusCode(400).end("Missing login, email or password"); rc.response().setStatusCode(400).end("Missing login, email or password");
return; return;
} }
// Создаём активного пользователя (active = true) if (role == null || role.isEmpty()) role = "user";
userService.createUser(login, email, password, ip, true) userService.createUser(login, email, password, ip, true, role)
.onSuccess(v -> rc.response().setStatusCode(201).end()) .onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
@@ -228,12 +273,13 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login"); String login = body.getString("login");
String email = body.getString("email"); String email = body.getString("email");
String password = body.getString("password"); String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host(); String ip = rc.request().remoteAddress().host();
if (login == null || email == null) { if (login == null || email == null) {
rc.response().setStatusCode(400).end("Missing login or email"); rc.response().setStatusCode(400).end("Missing login or email");
return; return;
} }
userService.updateUser(id, login, email, password, ip) userService.updateUser(id, login, email, password, ip, role)
.onSuccess(v -> rc.response().end()) .onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });

View File

@@ -0,0 +1,15 @@
package su.xserver.iikocon.handler;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
public class AdminHandler {
public static void requireAdmin(RoutingContext ctx) {
String role = ctx.session().get("role");
if (!"admin".equals(role)) {
ctx.response().setStatusCode(403).end(new JsonObject().put("error", "Admin access required").encode());
return;
}
ctx.next();
}
}

View File

@@ -53,6 +53,8 @@ public class AuthHandler {
Session session = ctx.session(); Session session = ctx.session();
session.put("userId", user.getInteger("id")); session.put("userId", user.getInteger("id"));
session.put("login", user.getString("login")); session.put("login", user.getString("login"));
session.put("role", user.getString("role"));
session.put("language", user.getString("language"));
ctx.response().end(new JsonObject().put("success", true).put("login", user.getString("login")).encode()); ctx.response().end(new JsonObject().put("success", true).put("login", user.getString("login")).encode());
} else { } else {
ctx.response().setStatusCode(401).end("Invalid credentials"); ctx.response().setStatusCode(401).end("Invalid credentials");

View File

@@ -56,7 +56,7 @@ public class SetupHandler {
if (clientIp == null) { if (clientIp == null) {
clientIp = ctx.request().remoteAddress().host(); clientIp = ctx.request().remoteAddress().host();
} }
userService.createUser(login, email, password, clientIp, true).onComplete(cr -> { userService.createUser(login, email, password, clientIp, true, "admin").onComplete(cr -> {
if (cr.succeeded()) { if (cr.succeeded()) {
ctx.response().setStatusCode(201) ctx.response().setStatusCode(201)
.end(new JsonObject().put("success", true).encode()); .end(new JsonObject().put("success", true).encode());

View File

@@ -28,6 +28,8 @@ public class UserService {
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
active BOOLEAN DEFAULT FALSE, active BOOLEAN DEFAULT FALSE,
role VARCHAR(50) DEFAULT 'user',
language VARCHAR(5) DEFAULT 'en',
ip VARCHAR(45), ip VARCHAR(45),
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@@ -42,24 +44,28 @@ public class UserService {
.map(rows -> rows.iterator().next().getLong("cnt")); .map(rows -> rows.iterator().next().getLong("cnt"));
} }
public Future<Void> createUser(String login, String email, String password, String ip, boolean active) { public Future<Void> createUser(String login, String email, String password, String ip, boolean active, String role) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt()); String hash = BCrypt.hashpw(password, BCrypt.gensalt());
Map<String, Object> params = Map.of( Map<String, Object> params = Map.of(
"login", login, "login", login,
"email", email, "email", email,
"password", hash, "password", hash,
"ip", ip, "ip", ip,
"active", active "active", active,
"role", role
); );
return SqlTemplate.forUpdate(pool, return SqlTemplate.forUpdate(pool,
"INSERT INTO users (login, email, password, ip, active) VALUES (#{login}, #{email}, #{password}, #{ip}, #{active})") "INSERT INTO users (login, email, password, ip, active, role) VALUES (#{login}, #{email}, #{password}, #{ip}, #{active}, #{role})")
.execute(params) .execute(params)
.mapEmpty(); .mapEmpty();
} }
// Существующий метод оставляем, но он будет создавать неактивного пользователя (active = false) public Future<Void> createUser(String login, String email, String password, String ip, boolean active) {
return createUser(login, email, password, ip, active, "user");
}
public Future<Void> createUser(String login, String email, String password, String ip) { public Future<Void> createUser(String login, String email, String password, String ip) {
return createUser(login, email, password, ip, false); return createUser(login, email, password, ip, false, "user");
} }
public Future<Void> setActive(int id, boolean active) { public Future<Void> setActive(int id, boolean active) {
@@ -68,7 +74,7 @@ public class UserService {
} }
public Future<JsonObject> findByLoginOrEmail(String loginOrEmail) { public Future<JsonObject> findByLoginOrEmail(String loginOrEmail) {
String sql = "SELECT id, login, email, password, active, ip, created, updated FROM users WHERE login = ? OR email = ?"; String sql = "SELECT id, login, email, password, active, role, language, ip, created, updated FROM users WHERE login = ? OR email = ?";
return pool.preparedQuery(sql) return pool.preparedQuery(sql)
.execute(Tuple.of(loginOrEmail, loginOrEmail)) .execute(Tuple.of(loginOrEmail, loginOrEmail))
.map(rows -> { .map(rows -> {
@@ -80,31 +86,8 @@ public class UserService {
}); });
} }
public Future<JsonObject> findByEmail(String email) {
return SqlTemplate.forQuery(pool, "SELECT id, login, email, password, active, ip, created, updated FROM users WHERE email = #{email}")
.mapTo(this::toJson)
.execute(Map.of("email", email))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<JsonObject> findByLogin(String login) {
return SqlTemplate.forQuery(pool,
"SELECT id, login, password, created, updated, ip FROM users WHERE login = #{login}")
.mapTo(row -> new JsonObject()
.put("id", row.getInteger("id"))
.put("login", row.getString("login"))
.put("password", row.getString("password"))
.put("created", row.getLocalDateTime("created") != null ?
row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ?
row.getLocalDateTime("updated").toString() : null)
.put("ip", row.getString("ip")))
.execute(Collections.singletonMap("login", login))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<JsonArray> getAllUsers() { public Future<JsonArray> getAllUsers() {
return pool.query("SELECT id, login, email, active, ip, created, updated FROM users ORDER BY id") return pool.query("SELECT id, login, email, active, role, language, ip, created, updated FROM users ORDER BY id")
.execute() .execute()
.map(rows -> { .map(rows -> {
JsonArray array = new JsonArray(); JsonArray array = new JsonArray();
@@ -114,6 +97,8 @@ public class UserService {
.put("login", row.getString("login")) .put("login", row.getString("login"))
.put("email", row.getString("email")) .put("email", row.getString("email"))
.put("active", row.getBoolean("active")) .put("active", row.getBoolean("active"))
.put("role", row.getString("role"))
.put("language", row.getString("language"))
.put("ip", row.getString("ip")) .put("ip", row.getString("ip"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null)); .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null));
@@ -122,19 +107,23 @@ public class UserService {
}); });
} }
public Future<Void> updateUser(int id, String login, String email, String password, String ip) { public Future<Void> updateUser(int id, String login, String email, String password, String ip, String role) {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("id", id); params.put("id", id);
params.put("login", login); params.put("login", login);
params.put("email", email); params.put("email", email);
params.put("ip", ip); params.put("ip", ip);
if (role != null) params.put("role", role);
String sql; String sql;
if (password != null && !password.isEmpty()) { if (password != null && !password.isEmpty()) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt()); String hash = BCrypt.hashpw(password, BCrypt.gensalt());
params.put("password", hash); params.put("password", hash);
sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip} WHERE id = #{id}"; sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip}"
+ (role != null ? ", role = #{role}" : "") + " WHERE id = #{id}";
} else { } else {
sql = "UPDATE users SET login = #{login}, email = #{email}, ip = #{ip} WHERE id = #{id}"; sql = "UPDATE users SET login = #{login}, email = #{email}, ip = #{ip}"
+ (role != null ? ", role = #{role}" : "") + " WHERE id = #{id}";
} }
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty(); return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
} }
@@ -151,6 +140,44 @@ public class UserService {
.mapEmpty(); .mapEmpty();
} }
public Future<JsonObject> getProfile(int userId) {
return SqlTemplate.forQuery(pool,
"SELECT id, login, email, role, language, ip, created, updated FROM users WHERE id = #{id}")
.mapTo(row -> new JsonObject()
.put("id", row.getInteger("id"))
.put("login", row.getString("login"))
.put("email", row.getString("email"))
.put("role", row.getString("role"))
.put("language", row.getString("language"))
.put("ip", row.getString("ip"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
.execute(Map.of("id", userId))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<Void> updateProfile(int userId, String email, String password, String language) {
Map<String, Object> params = new HashMap<>();
params.put("id", userId);
params.put("email", email);
if (language != null) params.put("language", language);
String sql;
if (password != null && !password.isEmpty()) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt());
params.put("password", hash);
sql = "UPDATE users SET email = #{email}, password = #{password}, language = COALESCE(#{language}, language) WHERE id = #{id}";
} else {
sql = "UPDATE users SET email = #{email}, language = COALESCE(#{language}, language) WHERE id = #{id}";
}
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public Future<Void> updateLanguage(int userId, String language) {
return SqlTemplate.forUpdate(pool, "UPDATE users SET language = #{lang} WHERE id = #{id}")
.execute(Map.of("id", userId, "lang", language))
.mapEmpty();
}
public boolean checkPassword(String plain, String hash) { public boolean checkPassword(String plain, String hash) {
try { try {
return BCrypt.checkpw(plain, hash); return BCrypt.checkpw(plain, hash);
@@ -164,8 +191,10 @@ public class UserService {
.put("id", row.getInteger("id")) .put("id", row.getInteger("id"))
.put("login", row.getString("login")) .put("login", row.getString("login"))
.put("email", row.getString("email")) .put("email", row.getString("email"))
.put("password", row.getString("password")) // ← ДОБАВИТЬ ЭТУ СТРОКУ .put("password", row.getString("password"))
.put("active", row.getBoolean("active")) .put("active", row.getBoolean("active"))
.put("role", row.getString("role"))
.put("language", row.getString("language"))
.put("ip", row.getString("ip")) .put("ip", row.getString("ip"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null); .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null);