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",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
},
"devDependencies": {

View File

@@ -1,11 +1,14 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- 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">
<!-- Logo -->
<div class="flex items-center h-16 px-6 border-b border-gray-200">
<div class="flex items-center space-x-2">
<!-- Logo / Toggle Button -->
<div class="flex items-center h-16 px-4 border-b border-gray-200" :class="sidebarCollapsed ? 'justify-center' : 'justify-between'">
<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">
<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" />
@@ -13,82 +16,109 @@
</div>
<span class="text-xl font-bold text-gray-900">{{ settings.siteName }}</span>
</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>
<!-- 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
to="/dashboard"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors group"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/dashboard' }"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
: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" />
</svg>
Dashboard
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.dashboard') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/users"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/users' }"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
: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" />
</svg>
Users
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.users') }}</span>
</router-link>
<router-link
to="/restaurants"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/restaurants' }"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
: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" />
</svg>
Restaurants
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/settings"
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
:class="{ 'bg-primary-50 text-primary-700': $route.path === '/settings' }"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
: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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
</router-link>
</nav>
<!-- User Profile -->
<div class="p-4 border-t border-gray-200">
<!-- User Info (collapsed aware) -->
<div v-if="!sidebarCollapsed" class="p-4 border-t border-gray-200">
<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">
{{ userInitials }}
</div>
<div class="flex-1 min-w-0">
<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>
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors">
<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 v-else class="p-2 border-t border-gray-200 flex justify-center">
<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">
{{ userInitials }}
</div>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="ml-64">
<!-- Header -->
<header class="bg-white border-b border-gray-200">
<div class="flex items-center justify-between h-16 px-8">
<h1 class="text-2xl font-semibold text-gray-900">{{ pageTitle }}</h1>
<main class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-16' : 'ml-64'">
<!-- Header (без заголовка) -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="flex items-center justify-end h-16 px-8">
<div class="flex items-center space-x-4">
<!-- Search -->
<div class="relative">
@@ -109,6 +139,25 @@
</svg>
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</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>
</header>
@@ -125,33 +174,47 @@
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
import { useI18n } from 'vue-i18n'
const settings = useSettingsStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const userName = ref('Loading...')
const userLogin = ref('')
const { t, locale } = useI18n()
onMounted(async () => {
try {
const res = await fetch('/api/admin/me')
if (res.ok) {
const data = await res.json()
userLogin.value = data.login
userName.value = data.login // или можно сделать красивое отображение
}
} catch (e) {
userName.value = 'User'
const userName = computed(() => userStore.login || 'User')
const userInitials = computed(() => (userName.value[0] || 'U').toUpperCase())
// Sidebar collapsed state with localStorage
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_collapsed'
const sidebarCollapsed = ref(false)
onMounted(() => {
const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY)
if (saved !== null) {
sidebarCollapsed.value = saved === 'true'
}
})
const userInitials = computed(() => {
return (userName.value[0] || 'U').toUpperCase()
})
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarCollapsed.value))
}
async function logout() {
await fetch('/api/logout', { method: 'POST' })
userStore.clear()
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>

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 './style.css'
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 pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(i18n)
// Загружаем настройки до монтирования
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')
})

View File

@@ -1,4 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useSettingsStore } from '../stores/settings'
import Login from '../views/auth/Login.vue'
import Setup from '../views/auth/Setup.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 Restaurants from '../views/Restaurants.vue'
import AdminSettings from '../views/AdminSettings.vue'
import Profile from '../views/Profile.vue'
import NotFound from '../views/NotFound.vue'
import { useSettingsStore } from '../stores/settings'
const routes = [
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
component: Dashboard,
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 }
}
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true, title: '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: '/profile', component: Profile, meta: { requiresAuth: true, title: 'Profile' } },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { title: 'Page Not Found', requiresAuth: false } }
]
const router = createRouter({
history: createWebHistory(),
routes
})
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach(async (to, from, next) => {
// Загружаем настройки приложения, если они ещё не загружены
const settings = useSettingsStore()
if (!settings.siteName) {
await settings.loadSettings()
}
if (!settings.siteName) await settings.loadSettings()
// Устанавливаем заголовок страницы с использованием site_name
const pageTitle = to.meta.title ? `${to.meta.title} | ${settings.siteName}` : settings.siteName
document.title = pageTitle
document.title = to.meta.title ? `${to.meta.title} | ${settings.siteName}` : settings.siteName
// Проверка необходимости установки (setup)
try {
@@ -69,44 +40,33 @@ router.beforeEach(async (to, from, next) => {
next('/setup')
return
}
} catch (e) {
console.error('Failed to check status', e)
} catch (e) { console.error('Failed to check status', e) }
const userStore = useUserStore()
// Если профиль ещё не загружен загружаем
if (userStore.role === '') {
await userStore.fetchProfile()
}
// Проверка, что залогиненный пользователь не может зайти на страницу логина
if (to.path === '/login') {
try {
const meRes = await fetch('/api/admin/me')
if (meRes.ok) {
next('/dashboard')
return
}
} catch (e) {
// игнорируем ошибку, продолжаем
}
// Если уже залогинены и пытаемся зайти на login/register редирект на дашборд
if (userStore.id && (to.path === '/login' || to.path === '/register')) {
next('/dashboard')
return
}
// Проверка доступности регистрации
if (to.path === '/register') {
if (!settings.enableRegistration) {
next('/login')
return
}
if (to.path === '/register' && !settings.enableRegistration) {
next('/login')
return
}
// Проверка аутентификации для защищённых маршрутов
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth) {
try {
const res = await fetch('/api/admin/me')
if (!res.ok) {
next('/login')
} else {
next()
}
} catch {
next('/login')
}
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
if (requiresAuth && !userStore.id) {
next('/login')
} else if (requiresAdmin && userStore.role !== 'admin') {
next('/dashboard')
} else {
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>
<!-- Stats Grid -->
<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>
<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>
</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">
<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>
</div>
</div>
<div class="mt-4 flex items-center text-sm">
<span class="text-green-600 font-medium"> 12%</span>
<span class="text-gray-500 ml-2">from last month</span>
<span class="text-green-600 font-medium"> {{ userGrowth }}%</span>
<span class="text-gray-500 ml-2">vs last month</span>
</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>
<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>
</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">
<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>
</div>
</div>
<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>
</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>
<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>
</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">
<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>
</div>
</div>
<div class="mt-4">
<div class="h-2 bg-gray-200 rounded-full">
<div class="h-2 bg-blue-600 rounded-full" :style="{ width: `${stats.systemHealth}%` }"></div>
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-blue-600 rounded-full transition-all duration-500"
:style="{ width: `${stats.systemHealth}%` }"
></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>
<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>
</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">
<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>
@@ -78,10 +81,52 @@
</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="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 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">
@@ -95,19 +140,33 @@
</div>
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">New</span>
</div>
<div v-if="recentUsers.length === 0" class="text-center text-gray-500 py-8">No users yet</div>
</div>
</div>
<div class="card">
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Status</h3>
<div class="space-y-4">
<div v-for="service in systemServices" :key="service.name" class="flex items-center justify-between">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">Recent Restaurants</h3>
<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="['w-2 h-2 rounded-full', service.status === 'up' ? 'bg-green-500' : 'bg-red-500']"></div>
<span class="text-gray-700">{{ service.name }}</span>
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<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>
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
</div>
<div v-if="recentRestaurants.length === 0" class="text-center text-gray-500 py-8">No restaurants yet</div>
</div>
</div>
</div>
@@ -115,88 +174,73 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import AppLayout from '../components/Layout/AppLayout.vue'
import { ref, onMounted, onUnmounted } from '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 {
const [usersRes, sessionsRes, healthRes] = await Promise.all([
const [usersRes, sessionsRes, healthRes, restaurantsRes] = await Promise.all([
fetch('/api/admin/users'),
fetch('/api/admin/active-sessions'),
fetch('/api/health')
])
const users = await usersRes.json()
const sessions = await sessionsRes.json()
const health = await healthRes.json()
fetch('/api/health'),
fetch('/api/admin/restaurants')
]);
stats.value.totalUsers = users.length
stats.value.activeSessions = sessions.count || 0
const users = await usersRes.json();
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
const total = health.checks?.length || 1
stats.value.systemHealth = Math.round((upCount / total) * 100)
} catch (e) { console.error(e) }
}
stats.value.totalUsers = users.length;
stats.value.activeSessions = sessions.count || 0;
recentUsers.value = users.slice(-5).reverse();
recentRestaurants.value = restaurants.slice(-5).reverse();
onMounted(() => {
loadStats()
const interval = setInterval(loadStats, 5000)
onUnmounted(() => clearInterval(interval))
})
const upCount = health.checks?.filter((c: any) => c.status === 'UP').length || 0;
const total = health.checks?.length || 1;
stats.value.systemHealth = Math.round((upCount / total) * 100);
const recentUsers = ref([])
const systemServices = ref([])
async function loadHealth() {
try {
const res = await fetch('/api/health')
const data = await res.json()
if (data.checks) {
systemServices.value = data.checks.map(check => ({
if (health.checks) {
systemServices.value = health.checks.map((check: any) => ({
name: check.data?.name || check.id,
status: check.status.toLowerCase(),
latency: check.data?.latency_ms || 0
}))
}));
}
} catch (e) {
console.error('Health check failed', 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)
console.error('Failed to load dashboard data', e);
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
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>

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>
<div class="flex justify-between items-center mb-6">
<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 class="card overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
<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">Host</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">Login</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">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="rest in restaurants" :key="rest.id">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.id }}</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 text-gray-500">{{ rest.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<input
type="checkbox"
:checked="rest.https"
@change="toggleHttps(rest)"
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 text-gray-500">{{ rest.login }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800">Edit</button>
<button @click="deleteRestaurant(rest.id)" class="text-red-600 hover:text-red-800">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Modal -->
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-full max-w-md">
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
<form @submit.prevent="submitRestaurant">
<!-- 1. Имя ресторана -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="form.name" type="text" required class="input-field mt-1" />
</div>
<!-- 2. Хост -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Host</label>
<input v-model="form.host" type="text" required class="input-field mt-1" />
</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 class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<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 tracking-wider">Name</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 tracking-wider">HTTPS</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 tracking-wider">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<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 font-medium text-gray-900">{{ rest.id }}</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 text-gray-500">{{ rest.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
:checked="rest.https"
@change="toggleHttps(rest)"
class="sr-only peer"
/>
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
<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>
</td>
<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>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800 transition-colors">
<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" />
</svg>
</button>
<button @click="confirmDelete(rest.id)" class="text-red-600 hover:text-red-800 transition-colors">
<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="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" />
</svg>
</button>
</td>
</tr>
<tr v-if="restaurants.length === 0">
<td colspan="7" class="px-6 py-12 text-center text-gray-500">No restaurants found. Click "Add Restaurant" to create one.</td>
</tr>
</tbody>
</table>
</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>
</template>
@@ -97,6 +151,7 @@ const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const form = ref({ id: null, name: '', login: '', password: '', host: '', https: false });
const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
async function loadRestaurants() {
const res = await fetch('/api/admin/restaurants');
@@ -138,7 +193,6 @@ async function toggleHttps(rest: any) {
host: rest.host,
login: rest.login,
https: newHttps
// пароль не передаём, он останется прежним
};
try {
const res = await fetch(`/api/admin/restaurants/${rest.id}`, {
@@ -147,9 +201,7 @@ async function toggleHttps(rest: any) {
body: JSON.stringify(payload)
});
if (res.ok) {
// Обновляем локальное состояние или перезагружаем список
rest.https = newHttps;
// Альтернатива: await loadRestaurants();
} else {
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) {
if (confirm('Are you sure?')) {
await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
await loadRestaurants();
}
await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
await loadRestaurants();
deleteConfirm.value.show = false;
}
onMounted(loadRestaurants);
</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>
<AppLayout>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Users Management</h1>
<button @click="openModal('create')" class="btn-primary">+ Add User</button>
<h1 class="text-2xl font-bold text-gray-900">{{ t('user.pageName') }}</h1>
<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 class="card overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</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">Email</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">IP</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">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.login }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.email }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<input
v-if="user.id !== currentUserId"
type="checkbox"
:checked="user.active"
@change="toggleActive(user)"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4"
/>
<span v-else class="text-gray-400 text-sm">(You)</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.ip || '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ formatDate(user.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
<button @click="openModal('edit', user)" class="text-blue-600">Edit</button>
<button
v-if="user.id !== currentUserId"
@click="deleteUser(user.id)"
class="text-red-600"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Modal -->
<div v-if="modalOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-full max-w-md">
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
<form @submit.prevent="submitUser">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Email</label>
<input v-model="form.email" type="text" required class="input-field mt-1" />
</div>
<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>
<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 class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<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 tracking-wider">Login</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 tracking-wider">Role</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 tracking-wider">IP</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<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 font-medium text-gray-900">{{ user.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ user.login }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.email }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<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'">
{{ user.role === 'admin' ? 'Administrator' : 'User' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="user.id === currentUserId" class="text-xs text-gray-500">(You)</div>
<label v-else class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
:checked="user.active"
@change="toggleActive(user)"
class="sr-only peer"
/>
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
<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>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.ip || '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(user.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button @click="openModal('edit', user)" class="text-blue-600 hover:text-blue-800 transition-colors">
<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" />
</svg>
</button>
<button
v-if="user.id !== currentUserId"
@click="confirmDelete(user.id)"
class="text-red-600 hover:text-red-800 transition-colors"
>
<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="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" />
</svg>
</button>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="8" class="px-6 py-12 text-center text-gray-500">No users found. Click "Add User" to create one.</td>
</tr>
</tbody>
</table>
</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>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed } from '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);
async function loadCurrentUser() {
@@ -99,8 +181,13 @@ async function loadCurrentUser() {
const users = ref([]);
const modalOpen = ref(false);
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 deleteConfirm = ref({ show: false, id: null });
const isEditingSelf = computed(() => {
return modalMode.value === 'edit' && form.value.id === currentUserId.value;
});
async function loadUsers() {
const res = await fetch('/api/admin/users');
@@ -113,18 +200,18 @@ function formatDate(dateStr: string) {
}
async function toggleActive(user: any) {
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' })
await loadUsers()
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' });
await loadUsers();
}
function openModal(mode: 'create' | 'edit', user: any = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = { id: null, login: '', email: '', password: '' };
modalTitle.value = 'Create User';
form.value = { id: null, login: '', email: '', password: '', role: 'user' };
modalTitle.value = t('user.add');
} else {
form.value = { id: user.id, login: user.login, email: user.email, password: '' }; // добавлен email
modalTitle.value = 'Edit User';
form.value = { id: user.id, login: user.login, email: user.email, password: '', role: user.role || 'user' };
modalTitle.value = t('user.edit');
}
modalOpen.value = true;
}
@@ -134,10 +221,16 @@ function closeModal() {
}
async function submitUser() {
if (isEditingSelf.value && form.value.role !== userStore.role) {
alert('You cannot change your own role');
return;
}
try {
const payload: any = {
login: form.value.login,
email: form.value.email,
role: form.value.role,
};
if (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) {
if (confirm('Are you sure?')) {
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
await loadUsers();
}
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
await loadUsers();
deleteConfirm.value.show = false;
}
onMounted(async () => {
@@ -180,3 +276,14 @@ onMounted(async () => {
await loadUsers();
});
</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 { useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
const settings = useSettingsStore()
const userStore = useUserStore()
const router = useRouter()
const form = ref({ login: '', password: '' })
const loading = ref(false)
@@ -116,24 +117,19 @@ const showPassword = ref(false)
async function handleLogin() {
loading.value = true
error.value = ''
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value)
})
if (res.ok) {
// Загружаем профиль (роль, язык, email)
await userStore.fetchProfile()
router.push('/dashboard')
} else {
// Пытаемся получить текст ошибки от сервера
const text = await res.text()
if (text && text.trim()) {
error.value = text
} else {
error.value = 'Invalid username or password'
}
error.value = text || 'Invalid username or password'
}
} catch (e) {
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.LoggerFactory;
import su.xserver.iikocon.config.AppConfig;
import su.xserver.iikocon.handler.AdminHandler;
import su.xserver.iikocon.handler.AuthHandler;
import su.xserver.iikocon.handler.SecurityHandler;
import su.xserver.iikocon.handler.SetupHandler;
@@ -194,7 +195,50 @@ public class MainVerticle extends AbstractVerticle {
.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/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 -> {
if (ar.succeeded()) {
@@ -211,13 +255,14 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login");
String email = body.getString("email");
String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host();
if (login == null || email == null || password == null) {
rc.response().setStatusCode(400).end("Missing login, email or password");
return;
}
// Создаём активного пользователя (active = true)
userService.createUser(login, email, password, ip, true)
if (role == null || role.isEmpty()) role = "user";
userService.createUser(login, email, password, ip, true, role)
.onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
@@ -228,12 +273,13 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login");
String email = body.getString("email");
String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host();
if (login == null || email == null) {
rc.response().setStatusCode(400).end("Missing login or email");
return;
}
userService.updateUser(id, login, email, password, ip)
userService.updateUser(id, login, email, password, ip, role)
.onSuccess(v -> rc.response().end())
.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.put("userId", user.getInteger("id"));
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());
} else {
ctx.response().setStatusCode(401).end("Invalid credentials");

View File

@@ -56,7 +56,7 @@ public class SetupHandler {
if (clientIp == null) {
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()) {
ctx.response().setStatusCode(201)
.end(new JsonObject().put("success", true).encode());

View File

@@ -28,6 +28,8 @@ public class UserService {
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
active BOOLEAN DEFAULT FALSE,
role VARCHAR(50) DEFAULT 'user',
language VARCHAR(5) DEFAULT 'en',
ip VARCHAR(45),
created TIMESTAMP DEFAULT 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"));
}
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());
Map<String, Object> params = Map.of(
"login", login,
"email", email,
"password", hash,
"ip", ip,
"active", active
"active", active,
"role", role
);
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)
.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) {
return createUser(login, email, password, ip, false);
return createUser(login, email, password, ip, false, "user");
}
public Future<Void> setActive(int id, boolean active) {
@@ -68,7 +74,7 @@ public class UserService {
}
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)
.execute(Tuple.of(loginOrEmail, loginOrEmail))
.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() {
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()
.map(rows -> {
JsonArray array = new JsonArray();
@@ -114,6 +97,8 @@ public class UserService {
.put("login", row.getString("login"))
.put("email", row.getString("email"))
.put("active", row.getBoolean("active"))
.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));
@@ -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<>();
params.put("id", id);
params.put("login", login);
params.put("email", email);
params.put("ip", ip);
if (role != null) params.put("role", role);
String sql;
if (password != null && !password.isEmpty()) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt());
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 {
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();
}
@@ -151,6 +140,44 @@ public class UserService {
.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) {
try {
return BCrypt.checkpw(plain, hash);
@@ -164,8 +191,10 @@ public class UserService {
.put("id", row.getInteger("id"))
.put("login", row.getString("login"))
.put("email", row.getString("email"))
.put("password", row.getString("password")) // ← ДОБАВИТЬ ЭТУ СТРОКУ
.put("password", row.getString("password"))
.put("active", row.getBoolean("active"))
.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);