4d34c6d31a
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
200 lines
6.2 KiB
Vue
200 lines
6.2 KiB
Vue
<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>
|