init auth

This commit is contained in:
xds
2026-02-08 17:36:22 +03:00
parent 7732856f90
commit 6856449075
11 changed files with 473 additions and 68 deletions

View File

@@ -7,7 +7,7 @@ const route = useRoute()
<template>
<!-- Login Layout (Full Screen) -->
<div v-if="route.name === 'login'" class="h-screen w-full">
<div v-if="['login', 'register'].includes(route.name)" class="h-screen w-full">
<RouterView />
</div>

View File

@@ -1,13 +1,15 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Button from 'primevue/button'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const handleLogout = () => {
localStorage.removeItem('auth_code')
authStore.logout()
router.push('/login')
}
@@ -18,14 +20,22 @@ const isActive = (path) => {
return route.path.startsWith(path)
}
const navItems = [
{ path: '/', icon: '🏠', tooltip: 'Home' },
{ path: '/assets', icon: '📂', tooltip: 'Assets' },
{ path: '/generation', icon: '🎨', tooltip: 'Image Generation' },
{ path: '/flexible-generation', icon: '🖌️', tooltip: 'Flexible Generation' },
{ path: '/characters', icon: '👥', tooltip: 'Characters' },
{ path: '/image-to-prompt', icon: '', tooltip: 'Image to Prompt' }
]
const navItems = computed(() => {
const items = [
{ path: '/', icon: '🏠', tooltip: 'Home' },
{ path: '/assets', icon: '📂', tooltip: 'Assets' },
// { path: '/generation', icon: '🎨', tooltip: 'Image Generation' },
{ path: '/flexible', icon: '🖌️', tooltip: 'Flexible Generation' },
{ path: '/characters', icon: '👥', tooltip: 'Characters' },
{ path: '/image-to-prompt', icon: '✨', tooltip: 'Image to Prompt' }
]
if (authStore.isAdmin()) {
items.push({ path: '/admin/approvals', icon: '🛡️', tooltip: 'Approvals' })
}
return items
})
</script>
<template>

View File

@@ -1,5 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '../views/LoginView.vue'
import RegisterView from '../views/RegisterView.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -9,10 +11,21 @@ const router = createRouter({
name: 'login',
component: LoginView
},
{
path: '/register',
name: 'register',
component: RegisterView
},
{
path: '/admin/approvals',
name: 'approvals',
component: () => import('../views/UserApprovalView.vue'),
meta: { requiresAdmin: true }
},
{
path: '/',
name: 'characters-home',
component: () => import('../views/CharactersView.vue')
name: 'home',
component: () => import('../views/FlexibleGenerationView.vue')
},
{
path: '/characters',
@@ -40,23 +53,32 @@ const router = createRouter({
component: () => import('../views/ImageGenerationView.vue')
},
{
path: '/flexible-generation',
name: 'flexible-generation',
path: '/flexible',
name: 'flexible',
component: () => import('../views/FlexibleGenerationView.vue')
}
]
})
router.beforeEach((to, from, next) => {
const isAuth = localStorage.getItem('auth_code')
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
const publicPages = ['/login', '/register']
const authRequired = !publicPages.includes(to.path)
if (to.name !== 'login' && !isAuth) {
next({ name: 'login' })
} else if (to.name === 'login' && isAuth) {
next({ name: 'dashboard' })
} else {
next()
if (authRequired) {
if (!authStore.isAuthenticated) {
return next('/login')
}
// Check if route requires admin
if (to.meta.requiresAdmin && !authStore.isAdmin()) {
return next('/') // Redirect non-admins to home
}
} else if ((to.name === 'login' || to.name === 'register') && authStore.isAuthenticated) {
return next('/')
}
next()
})
export default router

View File

@@ -10,6 +10,19 @@ const api = axios.create({
})
// Request interceptor handling can be added here if needed
api.interceptors.request.use(
config => {
const user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
config.headers['Authorization'] = `${user.tokenType} ${user.token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
api.interceptors.response.use(
response => response,
error => {

View File

@@ -0,0 +1,46 @@
import api from "./api";
export const authService = {
async login(username, password) {
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
const response = await api.post("/auth/token", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return response.data;
},
async register(username, password, fullName) {
const payload = {
username,
password,
full_name: fullName,
};
const response = await api.post("/auth/register", payload);
return response.data;
},
async getMe() {
const response = await api.get("/auth/me");
return response.data;
},
async getApprovals() {
const response = await api.get("/admin/approvals");
return response.data;
},
async approveUser(username) {
const response = await api.post(`/admin/approve/${username}`);
return response.data;
},
async denyUser(username) {
const response = await api.post(`/admin/deny/${username}`);
return response.data;
},
};

View File

@@ -48,6 +48,11 @@ export const dataService = {
return response.data
},
deleteGeneration: async (id) => {
const response = await api.delete(`/generations/${id}`)
return response.data
},
generatePromptFromImage: async (files, prompt) => {
const formData = new FormData()

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";
import { authService } from "../services/authService";
export const useAuthStore = defineStore("auth", () => {
const user = ref(JSON.parse(localStorage.getItem("user")) || null);
@@ -10,44 +10,61 @@ export const useAuthStore = defineStore("auth", () => {
async function login({ username, password }) {
error.value = null;
try {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await axios.post(
`${import.meta.env.VITE_API_URL}/login/access-token`,
formData,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const { access_token, token_type } = response.data;
const data = await authService.login(username, password);
const userData = {
username,
token: access_token,
tokenType: token_type
token: data.access_token,
tokenType: data.token_type
};
user.value = userData;
isAuthenticated.value = true;
localStorage.setItem("user", JSON.stringify(userData));
// Fetch full profile to get roles/admin status
await fetchCurrentUser();
return true;
} catch (err) {
console.error('Login failed:', err);
// Backend error format: { detail: "message" } usually
error.value = err.response?.data?.detail || 'Login failed. Please check your credentials.';
return false;
}
}
async function register({ username, password, fullName }) {
error.value = null;
try {
await authService.register(username, password, fullName);
return true;
} catch (err) {
console.error('Registration failed:', err);
error.value = err.response?.data?.detail || 'Registration failed. Please try again.';
return false;
}
}
async function fetchCurrentUser() {
try {
const userData = await authService.getMe();
// Merge with existing session data if needed, or just update user object
// We keep the token from login, but update other fields
user.value = { ...user.value, ...userData };
localStorage.setItem("user", JSON.stringify(user.value));
} catch (err) {
console.error('Failed to fetch user profile:', err);
}
}
const isAdmin = () => user.value?.is_admin === true;
function logout() {
user.value = null;
isAuthenticated.value = false;
localStorage.removeItem("user");
}
return { user, isAuthenticated, error, login, logout };
return { user, isAuthenticated, error, login, register, logout, fetchCurrentUser, isAdmin };
});

View File

@@ -365,9 +365,9 @@ const deleteGeneration = async (gen) => {
// or we need a way to delete the generation record.
// The user said "delete asset", and these are likely linked.
// If gen has a result list, we delete the first asset.
if (gen.result_list && gen.result_list.length > 0) {
await dataService.deleteAsset(gen.result_list[0])
}
await dataService.deleteGeneration(gen.id)
} catch (e) {
console.error('Failed to delete generation', e)
// Reload to restore state if failed

View File

@@ -1,27 +1,32 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
const router = useRouter()
const code = ref('')
const error = ref('')
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const handleLogin = () => {
if (!code.value.trim()) {
error.value = 'Please enter your access code'
const handleLogin = async () => {
if (!username.value.trim() || !password.value.trim()) {
authStore.error = 'Please enter both username and password'
return
}
loading.value = true
// Simulate a brief delay for feel
if (code.value == 'NE_LEZ_SUDA_SUK') {
localStorage.setItem('auth_code', code.value.trim())
const success = await authStore.login({
username: username.value,
password: password.value
})
if (success) {
router.push('/')
} else {
error.value = 'Pshel nah'
}
loading.value = false
}
@@ -41,33 +46,44 @@ const handleLogin = () => {
class="w-16 h-16 bg-gradient-to-tr from-violet-600 to-cyan-500 rounded-2xl flex items-center justify-center text-3xl mb-6 mx-auto shadow-lg shadow-violet-500/20">
</div>
<h1 class="text-3xl font-bold text-white mb-2 tracking-tight">Access Gate</h1>
<p class="text-slate-400 text-sm">Please enter your authorized access code</p>
<h1 class="text-3xl font-bold text-white mb-2 tracking-tight">Welcome Back</h1>
<p class="text-slate-400 text-sm">Sign in to access your workspace</p>
</div>
<div class="w-full flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-slate-400 text-[10px] font-bold uppercase tracking-widest ml-1">Secure
Code</label>
<label class="text-slate-400 text-[10px] font-bold uppercase tracking-widest ml-1">Username</label>
<div class="relative">
<InputText v-model="code" type="password" placeholder="••••••••"
<InputText v-model="username" placeholder="Enter username"
class="w-full !bg-slate-900/50 !border-white/10 !text-white !p-4 !rounded-xl focus:!border-violet-500 !transition-all"
@keyup.enter="handleLogin" />
<i class="pi pi-lock absolute right-4 top-1/2 -translate-y-1/2 text-slate-500"></i>
<i class="pi pi-user absolute right-4 top-1/2 -translate-y-1/2 text-slate-500"></i>
</div>
</div>
<div v-if="error" class="text-red-400 text-xs ml-1 flex items-center gap-1.5 animate-bounce">
<i class="pi pi-exclamation-circle"></i>
{{ error }}
<div class="flex flex-col gap-1.5">
<label class="text-slate-400 text-[10px] font-bold uppercase tracking-widest ml-1">Password</label>
<div class="relative">
<Password v-model="password" :feedback="false" placeholder="••••••••"
inputClass="w-full !bg-slate-900/50 !border-white/10 !text-white !p-4 !rounded-xl focus:!border-violet-500 !transition-all"
toggleMask @keyup.enter="handleLogin" class="w-full" />
</div>
</div>
<Button label="Initialize Session" icon="pi pi-bolt" :loading="loading" @click="handleLogin"
class="w-full !py-4 !rounded-xl !bg-gradient-to-r !from-violet-600 !to-cyan-500 !border-none !font-bold !text-lg !shadow-xl !shadow-violet-900/20 hover:scale-[1.02] active:scale-[0.98] transition-all" />
</div>
<div v-if="authStore.error" class="text-red-400 text-xs ml-1 flex items-center gap-1.5 animate-bounce">
<i class="pi pi-exclamation-circle"></i>
{{ authStore.error }}
</div>
<div class="pt-4 border-t border-white/5 w-full text-center">
<p class="text-slate-600 text-[10px] uppercase font-bold tracking-widest">Authorized Personnel Only</p>
<Button label="Sign In" icon="pi pi-sign-in" :loading="loading" @click="handleLogin"
class="w-full !py-4 !rounded-xl !bg-gradient-to-r !from-violet-600 !to-cyan-500 !border-none !font-bold !text-lg !shadow-xl !shadow-violet-900/20 hover:scale-[1.02] active:scale-[0.98] transition-all" />
<div class="text-center mt-2">
<span class="text-slate-500 text-sm">Don't have an account? </span>
<router-link to="/register" class="text-violet-400 hover:text-violet-300 transition-colors text-sm font-semibold">
Create one
</router-link>
</div>
</div>
</div>
</div>
@@ -95,4 +111,8 @@ const handleLogin = () => {
.animate-pulse {
animation: pulse 8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
:deep(.p-password-input) {
width: 100%;
}
</style>

138
src/views/RegisterView.vue Normal file
View File

@@ -0,0 +1,138 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const fullName = ref('')
const loading = ref(false)
const handleRegister = async () => {
if (!username.value.trim() || !password.value.trim()) {
authStore.error = 'Please fill in all required fields'
return
}
loading.value = true
const success = await authStore.register({
username: username.value,
password: password.value,
fullName: fullName.value
})
if (success) {
// Auto-login after successful registration
const loginSuccess = await authStore.login({
username: username.value,
password: password.value
})
if (loginSuccess) {
router.push('/')
} else {
router.push('/login')
}
}
loading.value = false
}
</script>
<template>
<div class="h-screen w-full flex items-center justify-center bg-slate-950 overflow-hidden relative">
<!-- Animated Background Blur -->
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-violet-600/20 blur-[120px] rounded-full animate-pulse"></div>
<div class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-cyan-600/20 blur-[120px] rounded-full animate-pulse"
style="animation-delay: 1s;"></div>
<div
class="glass-panel p-10 rounded-3xl border border-white/10 bg-white/5 backdrop-blur-xl w-full max-w-md z-10 flex flex-col items-center gap-8 shadow-2xl">
<div class="text-center">
<div
class="w-16 h-16 bg-gradient-to-tr from-violet-600 to-cyan-500 rounded-2xl flex items-center justify-center text-3xl mb-6 mx-auto shadow-lg shadow-violet-500/20">
🚀
</div>
<h1 class="text-3xl font-bold text-white mb-2 tracking-tight">Create Account</h1>
<p class="text-slate-400 text-sm">Join the workspace</p>
</div>
<div class="w-full flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-slate-400 text-[10px] font-bold uppercase tracking-widest ml-1">Username</label>
<div class="relative">
<InputText v-model="username" placeholder="Choose a username"
class="w-full !bg-slate-900/50 !border-white/10 !text-white !p-4 !rounded-xl focus:!border-violet-500 !transition-all" />
<i class="pi pi-user absolute right-4 top-1/2 -translate-y-1/2 text-slate-500"></i>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-slate-400 text-[10px] font-bold uppercase tracking-widest ml-1">Full Name (Optional)</label>
<div class="relative">
<InputText v-model="fullName" placeholder="John Doe"
class="w-full !bg-slate-900/50 !border-white/10 !text-white !p-4 !rounded-xl focus:!border-violet-500 !transition-all" />
<i class="pi pi-id-card absolute right-4 top-1/2 -translate-y-1/2 text-slate-500"></i>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-slate-400 text-[10px] font-bold uppercase tracking-widest ml-1">Password</label>
<div class="relative">
<Password v-model="password" placeholder="••••••••"
inputClass="w-full !bg-slate-900/50 !border-white/10 !text-white !p-4 !rounded-xl focus:!border-violet-500 !transition-all"
toggleMask @keyup.enter="handleRegister" class="w-full" />
</div>
</div>
<div v-if="authStore.error" class="text-red-400 text-xs ml-1 flex items-center gap-1.5 animate-bounce">
<i class="pi pi-exclamation-circle"></i>
{{ authStore.error }}
</div>
<Button label="Create Account" icon="pi pi-user-plus" :loading="loading" @click="handleRegister"
class="w-full !py-4 !rounded-xl !bg-gradient-to-r !from-violet-600 !to-cyan-500 !border-none !font-bold !text-lg !shadow-xl !shadow-violet-900/20 hover:scale-[1.02] active:scale-[0.98] transition-all" />
<div class="text-center mt-2">
<span class="text-slate-500 text-sm">Already have an account? </span>
<router-link to="/login" class="text-violet-400 hover:text-violet-300 transition-colors text-sm font-semibold">
Sign in
</router-link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.glass-panel {
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 0.2;
}
50% {
transform: scale(1.1);
opacity: 0.3;
}
}
.animate-pulse {
animation: pulse 8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
:deep(.p-password-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,134 @@
<script setup>
import { ref, onMounted } from 'vue'
import { authService } from '@/services/authService'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
const users = ref([])
const loading = ref(true)
const toast = useToast()
const processing = ref({})
const loadApprovals = async () => {
loading.value = true
try {
users.value = await authService.getApprovals()
} catch (e) {
console.error('Failed to load approvals', e)
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load pending approvals', life: 3000 })
} finally {
loading.value = false
}
}
const handleApprove = async (username) => {
processing.value[username] = true
try {
await authService.approveUser(username)
toast.add({ severity: 'success', summary: 'Approved', detail: `User ${username} approved`, life: 3000 })
await loadApprovals()
} catch (e) {
console.error('Failed to approve user', e)
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to approve user', life: 3000 })
} finally {
processing.value[username] = false
}
}
const handleDeny = async (username) => {
processing.value[username] = true
try {
await authService.denyUser(username)
toast.add({ severity: 'info', summary: 'Denied', detail: `User ${username} denied`, life: 3000 })
await loadApprovals()
} catch (e) {
console.error('Failed to deny user', e)
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to deny user', life: 3000 })
} finally {
processing.value[username] = false
}
}
onMounted(() => {
loadApprovals()
})
</script>
<template>
<div class="p-6 md:p-8 min-h-screen">
<div class="max-w-7xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">User Approvals</h1>
<p class="text-slate-400">Manage pending user registration requests</p>
</div>
<Button icon="pi pi-refresh" rounded outlined @click="loadApprovals" :loading="loading" />
</div>
<div class="glass-panel rounded-2xl p-1 overflow-hidden">
<DataTable :value="users" :loading="loading" class="w-full"
pt:headerRow:class="bg-white/5 text-slate-300"
pt:bodyRow:class="hover:bg-white/5 transition-colors border-b border-white/5">
<template #empty>
<div class="p-8 text-center text-slate-500">
No pending approvals found.
</div>
</template>
<Column field="username" header="Username" class="text-white font-medium"></Column>
<Column field="full_name" header="Full Name" class="text-slate-300"></Column>
<Column field="email" header="Email" class="text-slate-400">
<template #body="slotProps">
{{ slotProps.data.email || '-' }}
</template>
</Column>
<Column field="created_at" header="Registered" class="text-slate-400">
<template #body="slotProps">
{{ new Date(slotProps.data.created_at).toLocaleDateString() }}
</template>
</Column>
<Column header="Actions" alignFrozen="right" frozen class="w-[200px]">
<template #body="slotProps">
<div class="flex gap-2 justify-end">
<Button icon="pi pi-check" severity="success" text rounded
@click="handleApprove(slotProps.data.username)"
:loading="processing[slotProps.data.username]" tooltip="Approve" />
<Button icon="pi pi-times" severity="danger" text rounded
@click="handleDeny(slotProps.data.username)"
:loading="processing[slotProps.data.username]" tooltip="Deny" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<style scoped>
.glass-panel {
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
:deep(.p-datatable-header-cell) {
background: transparent !important;
color: inherit;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
:deep(.p-datatable-tbody > tr) {
background: transparent !important;
color: inherit;
}
:deep(.p-datatable-tbody > tr:hover) {
background: rgba(255, 255, 255, 0.03) !important;
}
</style>