This commit is contained in:
danilbodry-mac
2026-04-10 19:58:29 +03:00
parent 5821006bf2
commit c5287dc81d
18 changed files with 2495 additions and 161 deletions

View File

@@ -30,11 +30,11 @@ services:
- mariadb - mariadb
- redis - redis
environment: environment:
DB_HOST: mariadb DB_HOST: phpmyadmin.xserver.su
DB_PORT: 3306 DB_PORT: 3306
DB_NAME: admin_db DB_NAME: test
DB_USER: admin_user DB_USER: test
DB_PASSWORD: admin_pass DB_PASSWORD: test
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: 6379 REDIS_PORT: 6379
HTTP_PORT: 8080 HTTP_PORT: 8080

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^14.2.1",
"axios": "^1.15.0", "axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.5.31", "vue": "^3.5.31",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.9",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.2",
"vite": "^8.0.3", "vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1" "vite-plugin-vue-devtools": "^8.1.1"
}, },

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,3 +1,21 @@
<template> <template>
<router-view /> <div class="min-h-screen bg-gray-50">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</template> </template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,138 @@
<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">
<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">
<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" />
</svg>
</div>
<span class="text-xl font-bold text-gray-900">AdminPanel</span>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 px-4 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' }"
>
<svg class="w-5 h-5 mr-3" 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
</router-link>
<router-link
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' }"
>
<svg class="w-5 h-5 mr-3" 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
</router-link>
<router-link
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' }"
>
<svg class="w-5 h-5 mr-3" 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
</router-link>
</nav>
<!-- User Profile -->
<div 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>
</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>
</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>
<div class="flex items-center space-x-4">
<!-- Search -->
<div class="relative">
<input
type="text"
placeholder="Search..."
class="w-64 px-4 py-2 pl-10 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<!-- Notifications -->
<button class="relative p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
</div>
</div>
</header>
<!-- Page Content -->
<div class="p-8">
<slot />
</div>
</main>
</div>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const userName = ref('Admin User')
const userInitials = computed(() => {
return userName.value.split(' ').map(n => n[0]).join('').toUpperCase()
})
const pageTitle = computed(() => {
const titles: Record<string, string> = {
'/dashboard': 'Dashboard',
'/users': 'Users Management',
'/settings': 'Settings'
}
return titles[route.path] || 'Admin Panel'
})
async function logout() {
await fetch('/api/logout', { method: 'POST' })
router.push('/login')
}
</script>

View File

@@ -1,5 +1,10 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './style.css'
createApp(App).use(router).mount('#app') const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -1,12 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue' import Login from '../views/auth/Login.vue'
import Setup from '../views/Setup.vue' import Setup from '../views/auth/Setup.vue'
import Dashboard from '../views/Dashboard.vue' import Dashboard from '../views/Dashboard.vue'
const routes = [ const routes = [
{ path: '/login', component: Login }, { path: '/login', component: Login, meta: { title: 'Login' } },
{ path: '/setup', component: Setup }, { path: '/setup', component: Setup, meta: { title: 'Setup' } },
{ path: '/', component: Dashboard, meta: { requiresAuth: true } } {
path: '/dashboard',
component: Dashboard,
meta: { requiresAuth: true, title: 'Dashboard' }
},
{
path: '/',
redirect: '/dashboard'
}
] ]
const router = createRouter({ const router = createRouter({
@@ -15,23 +23,39 @@ const router = createRouter({
}) })
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const requiresAuth = to.matched.some(record => record.meta.requiresAuth) // Update page title
const loggedIn = await checkAuth() // запрос к /api/admin/users или специальному endpoint document.title = `${to.meta.title || 'Admin Panel'} | AdminPanel`
if (requiresAuth && !loggedIn) { // Check if setup is needed
next('/login') try {
const statusRes = await fetch('/api/status')
const status = await statusRes.json()
if (status.needsSetup && to.path !== '/setup') {
next('/setup')
return
}
} catch (e) {
console.error('Failed to check status', e)
}
// Check authentication
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')
}
} else { } else {
next() next()
} }
}) })
async function checkAuth(): Promise<boolean> {
try {
const res = await fetch('/api/admin/users')
return res.ok
} catch {
return false
}
}
export default router export default router

33
frontend/src/style.css Normal file
View File

@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50;
}
}
@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
transition-colors duration-200 font-medium;
}
.btn-secondary {
@apply px-4 py-2 bg-white text-gray-700 rounded-lg border border-gray-300
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500
focus:ring-offset-2 transition-colors duration-200 font-medium;
}
.input-field {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
transition-all duration-200;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
}
}

View File

@@ -1,43 +1,159 @@
<template> <template>
<div> <AppLayout>
<h2>Dashboard</h2> <!-- Stats Grid -->
<button @click="logout">Logout</button> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<h3>Users</h3> <div class="card animate-fade-in">
<table> <div class="flex items-center justify-between">
<thead> <div>
<tr><th>ID</th><th>Login</th><th>Created</th><th>IP</th></tr> <p class="text-sm font-medium text-gray-600">Total Users</p>
</thead> <p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalUsers }}</p>
<tbody> </div>
<tr v-for="user in users" :key="user.id"> <div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<td>{{ user.id }}</td> <svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<td>{{ user.login }}</td> <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" />
<td>{{ user.created }}</td> </svg>
<td>{{ user.ip }}</td> </div>
</tr> </div>
</tbody> <div class="mt-4 flex items-center text-sm">
</table> <span class="text-green-600 font-medium"> 12%</span>
</div> <span class="text-gray-500 ml-2">from last month</span>
</div>
</div>
<div class="card animate-fade-in" style="animation-delay: 0.1s">
<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">
<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-gray-500 ml-2">from last hour</span>
</div>
</div>
<div class="card animate-fade-in" style="animation-delay: 0.2s">
<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">
<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>
</div>
</div>
<div class="card animate-fade-in" style="animation-delay: 0.3s">
<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">
<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>
</div>
</div>
<div class="mt-4 flex items-center text-sm">
<div class="flex items-center text-green-600">
<div class="w-2 h-2 bg-green-600 rounded-full mr-2"></div>
Operational
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<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="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">
<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">
{{ user.login[0].toUpperCase() }}
</div>
<div>
<p class="font-medium text-gray-900">{{ user.login }}</p>
<p class="text-sm text-gray-500">{{ formatDate(user.created) }}</p>
</div>
</div>
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">New</span>
</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 items-center space-x-3">
<div :class="['w-2 h-2 rounded-full', service.status === 'healthy' ? 'bg-green-500' : 'bg-red-500']"></div>
<span class="text-gray-700">{{ service.name }}</span>
</div>
<span class="text-sm text-gray-500">{{ service.latency }}ms</span>
</div>
</div>
</div>
</div>
</AppLayout>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import AppLayout from '../components/Layout/AppLayout.vue'
import axios from 'axios'
const router = useRouter() const stats = ref({
const users = ref([]) totalUsers: 0,
activeSessions: 0,
onMounted(async () => { systemHealth: 98,
try { uptime: '99.9%'
const res = await axios.get('/api/admin/users')
users.value = res.data
} catch {
router.push('/login')
}
}) })
async function logout() { const recentUsers = ref([])
await axios.post('/api/logout') const systemServices = ref([
router.push('/login') { name: 'Database', status: 'healthy', latency: 12 },
{ name: 'Redis Cache', status: 'healthy', latency: 3 },
{ name: 'API Gateway', status: 'healthy', latency: 45 },
{ name: 'File Storage', status: 'healthy', latency: 28 }
])
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) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} }
</script> </script>

View File

@@ -1,19 +1,31 @@
<template> <template>
<div> <div class="login-container">
<h2>Login</h2> <h2>Login</h2>
<form @submit.prevent="login"> <form @submit.prevent="login">
<input v-model="loginForm.login" placeholder="Login" /> <div>
<input v-model="loginForm.password" type="password" placeholder="Password" /> <input
v-model="loginForm.login"
placeholder="Login"
required
/>
</div>
<div>
<input
v-model="loginForm.password"
type="password"
placeholder="Password"
required
/>
</div>
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
<p v-if="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from 'axios'
const router = useRouter() const router = useRouter()
const loginForm = ref({ login: '', password: '' }) const loginForm = ref({ login: '', password: '' })
@@ -21,10 +33,39 @@ const error = ref('')
async function login() { async function login() {
try { try {
await axios.post('/api/login', loginForm.value) const res = await fetch('/api/login', {
router.push('/') method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loginForm.value)
})
if (res.ok) {
router.push('/')
} else {
error.value = 'Invalid credentials'
}
} catch (e) { } catch (e) {
error.value = 'Invalid credentials' error.value = 'Network error'
} }
} }
</script> </script>
<style scoped>
.login-container {
max-width: 300px;
margin: 100px auto;
}
input {
width: 100%;
padding: 8px;
margin: 5px 0;
}
button {
width: 100%;
padding: 10px;
margin-top: 10px;
}
.error {
color: red;
}
</style>

View File

@@ -1,19 +1,33 @@
<template> <template>
<div> <div class="setup-container">
<h2>Setup Admin Account</h2> <h2>Setup Admin Account</h2>
<form @submit.prevent="setup"> <form @submit.prevent="setup">
<input v-model="form.login" placeholder="Admin login" /> <div>
<input v-model="form.password" type="password" placeholder="Password (min 6 chars)" /> <input
v-model="form.login"
placeholder="Admin login (min 3 chars)"
required
minlength="3"
/>
</div>
<div>
<input
v-model="form.password"
type="password"
placeholder="Password (min 6 chars)"
required
minlength="6"
/>
</div>
<button type="submit">Create Admin</button> <button type="submit">Create Admin</button>
</form> </form>
<p v-if="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from 'axios'
const router = useRouter() const router = useRouter()
const form = ref({ login: '', password: '' }) const form = ref({ login: '', password: '' })
@@ -21,10 +35,41 @@ const error = ref('')
async function setup() { async function setup() {
try { try {
await axios.post('/api/setup', form.value) const res = await fetch('/api/setup', {
router.push('/login') method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value)
})
const data = await res.json()
if (res.ok) {
router.push('/login')
} else {
error.value = data.error || 'Setup failed'
}
} catch (e) { } catch (e) {
error.value = e.response?.data || 'Setup failed' error.value = 'Network error'
} }
} }
</script> </script>
<style scoped>
.setup-container {
max-width: 300px;
margin: 100px auto;
}
input {
width: 100%;
padding: 8px;
margin: 5px 0;
}
button {
width: 100%;
padding: 10px;
margin-top: 10px;
}
.error {
color: red;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 via-white to-primary-50">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-700 rounded-xl mb-4">
<svg class="w-8 h-8 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" />
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900">Welcome Back</h1>
<p class="text-gray-600 mt-2">Sign in to your account</p>
</div>
<!-- Login Form -->
<div class="bg-white rounded-2xl shadow-xl p-8">
<form @submit.prevent="handleLogin" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" 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>
</div>
<input
v-model="form.login"
type="text"
required
class="input-field pl-10"
placeholder="Enter your username"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
class="input-field pl-10 pr-10"
placeholder="Enter your password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="showPassword" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path v-if="showPassword" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input type="checkbox" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<span class="ml-2 text-sm text-gray-600">Remember me</span>
</label>
<a href="#" class="text-sm text-primary-600 hover:text-primary-700">Forgot password?</a>
</div>
<button
type="submit"
:disabled="loading"
class="w-full btn-primary py-3 relative"
>
<span v-if="!loading">Sign In</span>
<svg v-else class="animate-spin h-5 w-5 mx-auto 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>
</button>
</form>
<!-- Error Message -->
<transition name="fade">
<div v-if="error" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{{ error }}</p>
</div>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const form = ref({ login: '', password: '' })
const loading = ref(false)
const error = ref('')
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) {
router.push('/dashboard')
} else {
error.value = 'Invalid username or password'
}
} catch (e) {
error.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 via-white to-primary-50">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-700 rounded-xl mb-4 animate-pulse-slow">
<svg class="w-8 h-8 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" />
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900">Setup Admin Account</h1>
<p class="text-gray-600 mt-2">Create your administrator account</p>
</div>
<!-- Setup Form -->
<div class="bg-white rounded-2xl shadow-xl p-8">
<!-- Progress Steps -->
<div class="mb-8">
<div class="flex items-center justify-center">
<div class="flex items-center">
<div class="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center text-white font-semibold">1</div>
<div class="ml-2 text-sm font-medium text-gray-900">Account Details</div>
</div>
<div class="mx-4 w-12 h-0.5 bg-gray-300"></div>
<div class="flex items-center">
<div class="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center text-gray-600 font-semibold">2</div>
<div class="ml-2 text-sm font-medium text-gray-500">Complete</div>
</div>
</div>
</div>
<form @submit.prevent="handleSetup" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" 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>
</div>
<input
v-model="form.login"
type="text"
required
minlength="3"
class="input-field pl-10"
:class="{ 'border-red-300 focus:ring-red-500': validation.login }"
placeholder="Choose a username"
/>
</div>
<p v-if="validation.login" class="mt-1 text-xs text-red-600">{{ validation.login }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
minlength="6"
class="input-field pl-10"
:class="{ 'border-red-300 focus:ring-red-500': validation.password }"
placeholder="Choose a strong password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="showPassword" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path v-if="showPassword" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<p v-if="validation.password" class="mt-1 text-xs text-red-600">{{ validation.password }}</p>
<!-- Password Strength -->
<div v-if="form.password" class="mt-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-600">Password strength</span>
<span class="text-xs font-medium" :class="strengthColor">{{ strengthText }}</span>
</div>
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="strengthBarColor"
:style="{ width: `${strengthPercent}%` }"
></div>
</div>
</div>
</div>
<button
type="submit"
:disabled="loading"
class="w-full btn-primary py-3 relative overflow-hidden group"
>
<span v-if="!loading" class="relative z-10">Create Account</span>
<svg v-else class="animate-spin h-5 w-5 mx-auto 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>
<div class="absolute inset-0 bg-gradient-to-r from-primary-600 to-primary-700 transform scale-x-0 group-hover:scale-x-100 transition-transform origin-left"></div>
</button>
</form>
<!-- Error Message -->
<transition name="fade">
<div v-if="error" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-start">
<svg class="h-5 w-5 text-red-400 mt-0.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>
<p class="ml-3 text-sm text-red-600">{{ error }}</p>
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const form = ref({ login: '', password: '' })
const loading = ref(false)
const error = ref('')
const showPassword = ref(false)
const validation = computed(() => {
const errors: any = {}
if (form.value.login && form.value.login.length < 3) {
errors.login = 'Username must be at least 3 characters'
}
if (form.value.password && form.value.password.length < 6) {
errors.password = 'Password must be at least 6 characters'
}
return errors
})
const passwordStrength = computed(() => {
const pwd = form.value.password
let strength = 0
if (pwd.length >= 6) strength++
if (pwd.length >= 8) strength++
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++
if (/\d/.test(pwd)) strength++
if (/[^a-zA-Z0-9]/.test(pwd)) strength++
return Math.min(strength, 5)
})
const strengthPercent = computed(() => (passwordStrength.value / 5) * 100)
const strengthText = computed(() => {
const texts = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong']
return texts[passwordStrength.value - 1] || ''
})
const strengthColor = computed(() => {
const colors = ['text-red-600', 'text-orange-600', 'text-yellow-600', 'text-green-600', 'text-green-700']
return colors[passwordStrength.value - 1] || ''
})
const strengthBarColor = computed(() => {
const colors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']
return colors[passwordStrength.value - 1] || ''
})
async function handleSetup() {
if (Object.keys(validation.value).length > 0) return
loading.value = true
error.value = ''
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value)
})
const data = await res.json()
if (res.ok) {
router.push('/login')
} else {
error.value = data.error || 'Failed to create account'
}
} catch (e) {
error.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-in': 'slideIn 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(0)' },
}
}
},
},
plugins: [],
}

View File

@@ -9,6 +9,7 @@ import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.BodyHandler; import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.SessionHandler; import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.handler.StaticHandler; import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore; import io.vertx.ext.web.sstore.SessionStore;
import io.vertx.ext.web.sstore.redis.RedisSessionStore; import io.vertx.ext.web.sstore.redis.RedisSessionStore;
import io.vertx.mysqlclient.MySQLConnectOptions; import io.vertx.mysqlclient.MySQLConnectOptions;
@@ -27,7 +28,6 @@ public class MainVerticle extends AbstractVerticle {
private static final Logger log = LoggerFactory.getLogger(MainVerticle.class); private static final Logger log = LoggerFactory.getLogger(MainVerticle.class);
private Pool dbPool; private Pool dbPool;
private Redis redisClient;
private UserService userService; private UserService userService;
@Override @Override
@@ -39,10 +39,10 @@ public class MainVerticle extends AbstractVerticle {
.put("db_name", System.getenv().getOrDefault("DB_NAME", "admin_db")) .put("db_name", System.getenv().getOrDefault("DB_NAME", "admin_db"))
.put("db_user", System.getenv().getOrDefault("DB_USER", "admin_user")) .put("db_user", System.getenv().getOrDefault("DB_USER", "admin_user"))
.put("db_password", System.getenv().getOrDefault("DB_PASSWORD", "admin_pass")) .put("db_password", System.getenv().getOrDefault("DB_PASSWORD", "admin_pass"))
.put("redis_host", System.getenv().getOrDefault("REDIS_HOST", "localhost"))
.put("redis_port", Integer.parseInt(System.getenv().getOrDefault("REDIS_PORT", "6379")))
.put("http_port", Integer.parseInt(System.getenv().getOrDefault("HTTP_PORT", "8080"))); .put("http_port", Integer.parseInt(System.getenv().getOrDefault("HTTP_PORT", "8080")));
log.info("Starting with config: {}", config.encodePrettily());
// Подключение к MariaDB // Подключение к MariaDB
MySQLConnectOptions connectOptions = new MySQLConnectOptions() MySQLConnectOptions connectOptions = new MySQLConnectOptions()
.setHost(config.getString("db_host")) .setHost(config.getString("db_host"))
@@ -50,109 +50,145 @@ public class MainVerticle extends AbstractVerticle {
.setDatabase(config.getString("db_name")) .setDatabase(config.getString("db_name"))
.setUser(config.getString("db_user")) .setUser(config.getString("db_user"))
.setPassword(config.getString("db_password")); .setPassword(config.getString("db_password"));
PoolOptions poolOptions = new PoolOptions().setMaxSize(5); PoolOptions poolOptions = new PoolOptions().setMaxSize(5);
dbPool = Pool.pool(vertx, connectOptions, poolOptions); dbPool = Pool.pool(vertx, connectOptions, poolOptions);
// Подключение к Redis
RedisOptions redisOptions = new RedisOptions()
.setConnectionString("redis://" + config.getString("redis_host") + ":" + config.getInteger("redis_port"));
redisClient = Redis.createClient(vertx, redisOptions);
// Инициализация сервисов // Инициализация сервисов
userService = new UserService(dbPool); userService = new UserService(dbPool);
// Инициализация БД (создание таблицы users) // Инициализация БД (создание таблицы users)
userService.initDatabase().compose(v -> { userService.initDatabase()
// Настройка сессий с Redis .onSuccess(v -> log.info("Database initialized successfully"))
SessionStore sessionStore = RedisSessionStore.create(vertx, redisClient); .onFailure(err -> {
SessionHandler sessionHandler = SessionHandler.create(sessionStore) log.error("Failed to initialize database", err);
.setSessionCookieName("admin.session") startPromise.fail(err);
.setCookieHttpOnlyFlag(true) return;
.setCookieSecureFlag(false) // для разработки, в продакшене true + HTTPS });
.setSessionTimeout(3600000); // 1 час
// Роутер // Настройка сессий (используем LocalSessionStore для простоты)
Router router = Router.router(vertx); SessionStore sessionStore = LocalSessionStore.create(vertx);
router.route().handler(BodyHandler.create()); SessionHandler sessionHandler = SessionHandler.create(sessionStore)
router.route().handler(sessionHandler); .setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
.setCookieSecureFlag(false)
.setSessionTimeout(3600000); // 1 час
// Health Checks // Роутер
HealthChecks hc = HealthChecks.create(vertx); Router router = Router.router(vertx);
hc.register("database", promise -> dbPool.getConnection().onComplete(ar -> { router.route().handler(BodyHandler.create());
router.route().handler(sessionHandler);
// CORS для разработки
router.route().handler(ctx -> {
ctx.response()
.putHeader("Access-Control-Allow-Origin", "*")
.putHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
.putHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
.putHeader("Access-Control-Allow-Credentials", "true");
if (ctx.request().method().name().equals("OPTIONS")) {
ctx.response().end();
} else {
ctx.next();
}
});
// Health Checks
HealthChecks hc = HealthChecks.create(vertx);
hc.register("database", promise ->
dbPool.getConnection().onComplete(ar -> {
if (ar.succeeded()) { if (ar.succeeded()) {
ar.result().close(); ar.result().close();
promise.complete(Status.OK()); promise.complete(Status.OK());
} else { } else {
promise.fail(ar.cause()); promise.fail(ar.cause());
} }
})); })
hc.register("redis", promise -> { );
RedisAPI.api(redisClient)
.ping(Collections.singletonList("ping")) router.get("/health").handler(rc ->
.onSuccess(response -> { hc.checkStatus().onComplete(ar -> {
if ("PONG".equals(response.toString())) {
promise.complete(Status.OK());
} else {
promise.fail("Unexpected ping response: " + response);
}
})
.onFailure(promise::fail);
});
router.get("/health").handler(rc -> hc.checkStatus().onComplete(ar -> {
if (ar.succeeded()) { if (ar.succeeded()) {
rc.response().end(ar.result().toJson().encodePrettily()); rc.response().end(ar.result().toJson().encodePrettily());
} else { } else {
rc.response().setStatusCode(503).end(ar.cause().getMessage()); rc.response().setStatusCode(503).end(ar.cause().getMessage());
} }
})); })
);
// Статическая раздача фронтенда (из webroot) // API маршруты
router.route("/*").handler(StaticHandler.create("webroot").setCachingEnabled(false).setIndexPage("index.html")); AuthHandler authHandler = new AuthHandler(userService);
SetupHandler setupHandler = new SetupHandler(userService);
// API маршруты // Проверка статуса (нужна ли инициализация)
AuthHandler authHandler = new AuthHandler(userService); router.get("/api/status").handler(setupHandler::checkStatus);
SetupHandler setupHandler = new SetupHandler(userService);
// Регистрация первого администратора (если таблица пуста) // Регистрация первого администратора
router.post("/api/setup").handler(setupHandler::handleSetup); router.post("/api/setup").handler(setupHandler::handleSetup);
// Логин // Логин
router.post("/api/login").handler(authHandler::handleLogin); router.post("/api/login").handler(authHandler::handleLogin);
// Выход // Выход
router.post("/api/logout").handler(authHandler::handleLogout); router.post("/api/logout").handler(authHandler::handleLogout);
// Защищённые маршруты (требуют сессии) // Защищённые маршруты
router.route("/api/admin/*").handler(authHandler::requireAuth); router.route("/api/admin/*").handler(authHandler::requireAuth);
// Пример защищённого эндпоинта - получение списка пользователей // Получение списка пользователей
router.get("/api/admin/users").handler(rc -> { router.get("/api/admin/users").handler(rc -> {
userService.getAllUsers().onComplete(ar -> { userService.getAllUsers().onComplete(ar -> {
if (ar.succeeded()) { if (ar.succeeded()) {
rc.response().end(ar.result().encode()); rc.response()
} else { .putHeader("Content-Type", "application/json")
rc.response().setStatusCode(500).end(ar.cause().getMessage()); .end(ar.result().encode());
} } else {
}); rc.response().setStatusCode(500).end(ar.cause().getMessage());
}
}); });
});
// Запуск HTTP сервера // Получение текущего пользователя
int port = config.getInteger("http_port"); router.get("/api/admin/me").handler(rc -> {
return vertx.createHttpServer().requestHandler(router).listen(port); Integer userId = rc.session().get("userId");
}).onComplete(ar -> { if (userId != null) {
if (ar.succeeded()) { rc.response()
log.info("Server started on port {}", config.getInteger("http_port")); .putHeader("Content-Type", "application/json")
startPromise.complete(); .end(new JsonObject()
.put("id", userId)
.put("login", rc.session().get("login"))
.encode());
} else { } else {
log.error("Failed to start", ar.cause()); rc.response().setStatusCode(401).end();
startPromise.fail(ar.cause());
} }
}); });
// Статическая раздача фронтенда
router.route("/*").handler(StaticHandler.create("webroot")
.setCachingEnabled(false)
.setIndexPage("index.html"));
// Запуск HTTP сервера
int port = config.getInteger("http_port");
vertx.createHttpServer()
.requestHandler(router)
.listen(port).onComplete(http -> {
if (http.succeeded()) {
log.info("HTTP server started on port {}", port);
startPromise.complete();
} else {
log.error("Failed to start HTTP server", http.cause());
startPromise.fail(http.cause());
}
});
} }
@Override @Override
public void stop() { public void stop(Promise<Void> stopPromise) {
if (dbPool != null) dbPool.close(); if (dbPool != null) {
dbPool.close();
}
stopPromise.complete();
} }
} }

View File

@@ -10,29 +10,59 @@ public class SetupHandler {
this.userService = userService; this.userService = userService;
} }
public void checkStatus(RoutingContext ctx) {
userService.countUsers().onComplete(ar -> {
if (ar.succeeded()) {
ctx.response()
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("needsSetup", ar.result() == 0)
.put("userCount", ar.result())
.encode());
} else {
ctx.response().setStatusCode(500).end(ar.cause().getMessage());
}
});
}
public void handleSetup(RoutingContext ctx) { public void handleSetup(RoutingContext ctx) {
// Проверяем, есть ли уже пользователи
userService.countUsers().onComplete(ar -> { userService.countUsers().onComplete(ar -> {
if (ar.succeeded() && ar.result() == 0) { if (ar.succeeded() && ar.result() == 0) {
JsonObject body = ctx.body().asJsonObject(); JsonObject body = ctx.body().asJsonObject();
if (body == null) {
ctx.response().setStatusCode(400).end("Invalid JSON body");
return;
}
String login = body.getString("login"); String login = body.getString("login");
String password = body.getString("password"); String password = body.getString("password");
if (login == null || password == null || login.length() < 3 || password.length() < 6) { if (login == null || password == null || login.length() < 3 || password.length() < 6) {
ctx.response().setStatusCode(400).end("Invalid login or password (min 3/6 chars)"); ctx.response().setStatusCode(400)
.end(new JsonObject()
.put("error", "Invalid login or password (min 3/6 chars)")
.encode());
return; return;
} }
String ip = ctx.request().remoteAddress().host(); String ip = ctx.request().remoteAddress().host();
userService.createUser(login, password, ip).onComplete(cr -> { userService.createUser(login, password, ip).onComplete(cr -> {
if (cr.succeeded()) { if (cr.succeeded()) {
ctx.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()); ctx.response().setStatusCode(201)
.end(new JsonObject().put("success", true).encode());
} else { } else {
ctx.response().setStatusCode(500).end("Failed to create admin: " + cr.cause().getMessage()); ctx.response().setStatusCode(500)
.end(new JsonObject()
.put("error", "Failed to create admin: " + cr.cause().getMessage())
.encode());
} }
}); });
} else { } else {
ctx.response().setStatusCode(403).end("Setup already completed"); ctx.response().setStatusCode(403)
.end(new JsonObject()
.put("error", "Setup already completed")
.encode());
} }
}); });
} }

View File

@@ -30,48 +30,59 @@ public class UserService {
ip VARCHAR(45) ip VARCHAR(45)
) )
"""; """;
return pool.query(createTable).execute().mapEmpty(); return pool.query(createTable).execute().mapEmpty();
} }
public Future<Long> countUsers() { public Future<Long> countUsers() {
return pool.query("SELECT COUNT(*) AS cnt FROM users").execute() return pool.query("SELECT COUNT(*) AS cnt FROM users")
.execute()
.map(rows -> rows.iterator().next().getLong("cnt")); .map(rows -> rows.iterator().next().getLong("cnt"));
} }
public Future<Void> createUser(String login, String password, String ip) { public Future<Void> createUser(String login, String password, String ip) {
String hash = BCrypt.hashpw(password, BCrypt.gensalt()); String hash = BCrypt.hashpw(password, BCrypt.gensalt());
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("login", login); params.put("login", login);
params.put("password", hash); params.put("password", hash);
params.put("ip", ip); params.put("ip", ip);
return SqlTemplate.forUpdate(pool, "INSERT INTO users (login, password, ip) VALUES (#{login}, #{password}, #{ip})")
return SqlTemplate.forUpdate(pool,
"INSERT INTO users (login, password, ip) VALUES (#{login}, #{password}, #{ip})")
.execute(params) .execute(params)
.mapEmpty(); .mapEmpty();
} }
public Future<JsonObject> findByLogin(String login) { public Future<JsonObject> findByLogin(String login) {
return SqlTemplate.forQuery(pool, "SELECT id, login, password, created, updated, ip FROM users WHERE login = #{login}") return SqlTemplate.forQuery(pool,
"SELECT id, login, password, created, updated, ip FROM users WHERE login = #{login}")
.mapTo(row -> new JsonObject() .mapTo(row -> new JsonObject()
.put("id", row.getInteger("id")) .put("id", row.getInteger("id"))
.put("login", row.getString("login")) .put("login", row.getString("login"))
.put("password", row.getString("password")) .put("password", row.getString("password"))
.put("created", row.getLocalDateTime("created").toString()) .put("created", row.getLocalDateTime("created") != null ?
.put("updated", row.getLocalDateTime("updated").toString()) row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ?
row.getLocalDateTime("updated").toString() : null)
.put("ip", row.getString("ip"))) .put("ip", row.getString("ip")))
.execute(Collections.singletonMap("login", login)) .execute(Collections.singletonMap("login", login))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null); .map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
} }
public Future<JsonArray> getAllUsers() { public Future<JsonArray> getAllUsers() {
return pool.query("SELECT id, login, created, updated, ip FROM users ORDER BY id").execute() return pool.query("SELECT id, login, created, updated, ip FROM users ORDER BY id")
.execute()
.map(rows -> { .map(rows -> {
JsonArray array = new JsonArray(); JsonArray array = new JsonArray();
for (Row row : rows) { for (Row row : rows) {
array.add(new JsonObject() array.add(new JsonObject()
.put("id", row.getInteger("id")) .put("id", row.getInteger("id"))
.put("login", row.getString("login")) .put("login", row.getString("login"))
.put("created", row.getLocalDateTime("created").toString()) .put("created", row.getLocalDateTime("created") != null ?
.put("updated", row.getLocalDateTime("updated").toString()) row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ?
row.getLocalDateTime("updated").toString() : null)
.put("ip", row.getString("ip"))); .put("ip", row.getString("ip")));
} }
return array; return array;
@@ -79,6 +90,10 @@ public class UserService {
} }
public boolean checkPassword(String plain, String hash) { public boolean checkPassword(String plain, String hash) {
return BCrypt.checkpw(plain, hash); try {
return BCrypt.checkpw(plain, hash);
} catch (Exception e) {
return false;
}
} }
} }