v01
This commit is contained in:
1448
frontend/package-lock.json
generated
1448
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.15.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@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-plugin-vue-devtools": "^8.1.1"
|
||||
},
|
||||
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
138
frontend/src/components/Layout/AppLayout.vue
Normal file
138
frontend/src/components/Layout/AppLayout.vue
Normal 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>
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
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')
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Login from '../views/Login.vue'
|
||||
import Setup from '../views/Setup.vue'
|
||||
import Login from '../views/auth/Login.vue'
|
||||
import Setup from '../views/auth/Setup.vue'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login },
|
||||
{ path: '/setup', component: Setup },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } }
|
||||
{ path: '/login', component: Login, meta: { title: 'Login' } },
|
||||
{ path: '/setup', component: Setup, meta: { title: 'Setup' } },
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: Dashboard,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@@ -15,23 +23,39 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
const loggedIn = await checkAuth() // запрос к /api/admin/users или специальному endpoint
|
||||
// Update page title
|
||||
document.title = `${to.meta.title || 'Admin Panel'} | AdminPanel`
|
||||
|
||||
if (requiresAuth && !loggedIn) {
|
||||
next('/login')
|
||||
// Check if setup is needed
|
||||
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 {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users')
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
33
frontend/src/style.css
Normal file
33
frontend/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>Dashboard</h2>
|
||||
<button @click="logout">Logout</button>
|
||||
<h3>Users</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Login</th><th>Created</th><th>IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.login }}</td>
|
||||
<td>{{ user.created }}</td>
|
||||
<td>{{ user.ip }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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="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">
|
||||
<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" />
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../components/Layout/AppLayout.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const users = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/admin/users')
|
||||
users.value = res.data
|
||||
} catch {
|
||||
router.push('/login')
|
||||
}
|
||||
const stats = ref({
|
||||
totalUsers: 0,
|
||||
activeSessions: 0,
|
||||
systemHealth: 98,
|
||||
uptime: '99.9%'
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
await axios.post('/api/logout')
|
||||
router.push('/login')
|
||||
const recentUsers = ref([])
|
||||
const systemServices = ref([
|
||||
{ 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>
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="login-container">
|
||||
<h2>Login</h2>
|
||||
<form @submit.prevent="login">
|
||||
<input v-model="loginForm.login" placeholder="Login" />
|
||||
<input v-model="loginForm.password" type="password" placeholder="Password" />
|
||||
<div>
|
||||
<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>
|
||||
</form>
|
||||
<p v-if="error">{{ error }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
const loginForm = ref({ login: '', password: '' })
|
||||
@@ -21,10 +33,39 @@ const error = ref('')
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
await axios.post('/api/login', loginForm.value)
|
||||
router.push('/')
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(loginForm.value)
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
router.push('/')
|
||||
} else {
|
||||
error.value = 'Invalid credentials'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Invalid credentials'
|
||||
error.value = 'Network error'
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="setup-container">
|
||||
<h2>Setup Admin Account</h2>
|
||||
<form @submit.prevent="setup">
|
||||
<input v-model="form.login" placeholder="Admin login" />
|
||||
<input v-model="form.password" type="password" placeholder="Password (min 6 chars)" />
|
||||
<div>
|
||||
<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>
|
||||
</form>
|
||||
<p v-if="error">{{ error }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
const form = ref({ login: '', password: '' })
|
||||
@@ -21,10 +35,41 @@ const error = ref('')
|
||||
|
||||
async function setup() {
|
||||
try {
|
||||
await axios.post('/api/setup', form.value)
|
||||
router.push('/login')
|
||||
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 || 'Setup failed'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || 'Setup failed'
|
||||
error.value = 'Network error'
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
129
frontend/src/views/auth/Login.vue
Normal file
129
frontend/src/views/auth/Login.vue
Normal 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>
|
||||
206
frontend/src/views/auth/Setup.vue
Normal file
206
frontend/src/views/auth/Setup.vue
Normal 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>
|
||||
41
frontend/tailwind.config.js
Normal file
41
frontend/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
Reference in New Issue
Block a user