Replace token-based admin auth with JWT session authentication
CI / test (push) Failing after 12m45s
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:
@@ -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>
|
||||
Reference in New Issue
Block a user