Replace token-based admin auth with JWT session authentication
CI / test (push) Failing after 12m45s

- Add AdminUser model (bcrypt hashed passwords) and admin_users table
- Add AdminJWTService for HS256 JWT sessions (24h TTL)
- Add AdminSessionAuth middleware for /api/v1/admin/* routes
- Add admin handlers: login, logout, me, change-password, users CRUD
- Keys and model management routes now require admin JWT session
- Remove ADMIN_TOKEN, add ADMIN_USERNAME, ADMIN_PASSWORD env vars
- Update frontend: username/password login, admin_session storage, AdminUsers CRUD view
This commit is contained in:
2026-07-31 17:15:29 -04:00
parent b18d4bd146
commit 4d34c6d31a
21 changed files with 828 additions and 57 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_session')
window.location.href = '/admin/login'
}
return Promise.reject(error)
+5
View File
@@ -33,6 +33,11 @@ const routes = [
name: 'Usage',
component: () => import('@/views/Usage.vue'),
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/AdminUsers.vue'),
},
],
},
{
+5 -5
View File
@@ -3,20 +3,20 @@ import { ref, computed } from 'vue'
import { api } from '@/lib/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('admin_token'))
const token = ref<string | null>(localStorage.getItem('admin_session'))
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
async function login(adminToken: string) {
async function login(username: string, password: string) {
loading.value = true
error.value = null
try {
const response = await api.post('/api/v1/admin/login', { admin_token: adminToken })
const response = await api.post('/api/v1/admin/login', { username, password })
token.value = response.data.token
localStorage.setItem('admin_token', response.data.token)
localStorage.setItem('admin_session', response.data.token)
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`
return true
} catch (err: any) {
@@ -29,7 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
function logout() {
token.value = null
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_session')
delete api.defaults.headers.common['Authorization']
}
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Plus, Trash2, Edit2 } from 'lucide-vue-next'
interface AdminUser {
id: string
username: string
is_active: boolean
created_at: string
last_login_at: string | null
}
const users = ref<AdminUser[]>([])
const loading = ref(true)
const showCreateDialog = ref(false)
const showEditDialog = ref(false)
const newUsername = ref('')
const newPassword = ref('')
const editingUser = ref<AdminUser | null>(null)
const editPassword = ref('')
const editIsActive = ref(true)
async function fetchUsers() {
loading.value = true
try {
const res = await api.get('/api/v1/admin/users')
users.value = res.data
} catch (err) {
console.error('Failed to fetch users:', err)
} finally {
loading.value = false
}
}
async function createUser() {
try {
await api.post('/api/v1/admin/users', {
username: newUsername.value,
password: newPassword.value,
})
showCreateDialog.value = false
newUsername.value = ''
newPassword.value = ''
await fetchUsers()
} catch (err) {
console.error('Failed to create user:', err)
alert('Failed to create user')
}
}
async function updateUser() {
if (!editingUser.value) return
try {
const payload: any = {}
if (editPassword.value) {
payload.password = editPassword.value
}
payload.is_active = editIsActive.value
await api.put(`/api/v1/admin/users/${editingUser.value.id}`, payload)
showEditDialog.value = false
editingUser.value = null
editPassword.value = ''
await fetchUsers()
} catch (err) {
console.error('Failed to update user:', err)
alert('Failed to update user')
}
}
async function deleteUser(id: string) {
if (!confirm('Are you sure you want to delete this user?')) return
try {
await api.delete(`/api/v1/admin/users/${id}`)
await fetchUsers()
} catch (err) {
console.error('Failed to delete user:', err)
alert('Failed to delete user')
}
}
function openEditDialog(user: AdminUser) {
editingUser.value = user
editIsActive.value = user.is_active
editPassword.value = ''
showEditDialog.value = true
}
function formatDate(date: string | null) {
if (!date) return 'Never'
return new Date(date).toLocaleString()
}
onMounted(fetchUsers)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Admin Users</h1>
<button @click="showCreateDialog = true" class="btn btn-primary">
<Plus class="w-4 h-4 mr-2" />
New User
</button>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<!-- Users Table -->
<div class="card">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Status</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.username }}</td>
<td>
<span :class="user.is_active ? 'badge-success' : 'badge-error'" class="badge">
{{ user.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td>{{ formatDate(user.created_at) }}</td>
<td>{{ formatDate(user.last_login_at) }}</td>
<td>
<div class="flex gap-2">
<button @click="openEditDialog(user)" class="btn btn-secondary btn-sm">
<Edit2 class="w-4 h-4" />
</button>
<button @click="deleteUser(user.id)" class="btn btn-danger btn-sm">
<Trash2 class="w-4 h-4" />
</button>
</div>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="5" class="text-center text-text-muted py-8">
No admin users yet. Create one to get started.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-md">
<h2 class="text-lg font-semibold mb-4">Create Admin User</h2>
<form @submit.prevent="createUser">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Username</label>
<input v-model="newUsername" type="text" class="input" placeholder="admin" required minlength="3" maxlength="64" />
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Password</label>
<input v-model="newPassword" type="password" class="input" placeholder="Min 8 characters" required minlength="8" />
</div>
<div class="flex gap-3 justify-end">
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<!-- Edit Dialog -->
<div v-if="showEditDialog && editingUser" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-md">
<h2 class="text-lg font-semibold mb-4">Edit User: {{ editingUser.username }}</h2>
<form @submit.prevent="updateUser">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">New Password (leave blank to keep current)</label>
<input v-model="editPassword" type="password" class="input" placeholder="Min 8 characters" minlength="8" />
</div>
<div class="mb-4">
<label class="flex items-center gap-2">
<input v-model="editIsActive" type="checkbox" class="rounded" />
<span class="text-sm font-medium">Active</span>
</label>
</div>
<div class="flex gap-3 justify-end">
<button type="button" @click="showEditDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
</template>
+2 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut } from 'lucide-vue-next'
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut, Users } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
@@ -12,6 +12,7 @@ const navItems = [
{ name: 'API Keys', path: '/admin/keys', icon: Key },
{ name: 'Models', path: '/admin/models', icon: Cpu },
{ name: 'Usage', path: '/admin/usage', icon: BarChart3 },
{ name: 'Admin Users', path: '/admin/users', icon: Users },
]
function handleLogout() {
+23 -7
View File
@@ -7,16 +7,21 @@ import { Key } from 'lucide-vue-next'
const router = useRouter()
const authStore = useAuthStore()
const adminToken = ref('')
const username = ref('')
const password = ref('')
const error = ref('')
async function handleLogin() {
if (!adminToken.value.trim()) {
error.value = 'Admin token is required'
if (!username.value.trim()) {
error.value = 'Username is required'
return
}
if (!password.value) {
error.value = 'Password is required'
return
}
const success = await authStore.login(adminToken.value)
const success = await authStore.login(username.value, password.value)
if (success) {
router.push('/admin/')
} else {
@@ -41,12 +46,23 @@ async function handleLogin() {
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Admin Token</label>
<label class="block text-sm font-medium mb-2">Username</label>
<input
v-model="adminToken"
v-model="username"
type="text"
class="input"
placeholder="Enter your username"
autocomplete="username"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Password</label>
<input
v-model="password"
type="password"
class="input"
placeholder="Enter your admin token"
placeholder="Enter your password"
autocomplete="current-password"
/>
</div>