feat: admin user management UI

- Add admin-only "Users" nav link in header
- Create /admin/users page with full CRUD:
  create user, edit, delete, reset password, reset TOTP
- Matches existing wraith dark theme
- Client-side admin guard redirects non-admins

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-14 13:07:20 -04:00
parent 6d76558bc3
commit 733fe6aca1
2 changed files with 327 additions and 0 deletions

View File

@ -71,6 +71,7 @@ const fontSize = computed({
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<NuxtLink to="/" class="text-sm text-gray-400 hover:text-white">Home</NuxtLink> <NuxtLink to="/" class="text-sm text-gray-400 hover:text-white">Home</NuxtLink>
<NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white">Vault</NuxtLink> <NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white">Vault</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/users" class="text-sm text-gray-400 hover:text-white">Users</NuxtLink>
<NuxtLink to="/profile" class="text-sm text-gray-400 hover:text-white">Profile</NuxtLink> <NuxtLink to="/profile" class="text-sm text-gray-400 hover:text-white">Profile</NuxtLink>
<button @click="showSettings = !showSettings" class="text-sm text-gray-400 hover:text-white" :class="showSettings ? 'text-white' : ''">Settings</button> <button @click="showSettings = !showSettings" class="text-sm text-gray-400 hover:text-white" :class="showSettings ? 'text-white' : ''">Settings</button>
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button> <button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>

View File

@ -0,0 +1,326 @@
<script setup lang="ts">
const auth = useAuthStore()
if (!auth.isAdmin) {
navigateTo('/')
}
interface ManagedUser {
id: number
email: string
displayName: string | null
role: string
totpEnabled: boolean
createdAt: string
}
const users = ref<ManagedUser[]>([])
const loading = ref(true)
const error = ref('')
const successMsg = ref('')
// Create user form
const showCreateForm = ref(false)
const createForm = ref({ email: '', password: '', displayName: '', role: 'user' })
const createLoading = ref(false)
const createError = ref('')
// Edit user
const editingUser = ref<ManagedUser | null>(null)
const editForm = ref({ email: '', displayName: '', role: 'user' })
const editLoading = ref(false)
const editError = ref('')
// Reset password
const resetPasswordUser = ref<ManagedUser | null>(null)
const resetPasswordValue = ref('')
const resetPasswordLoading = ref(false)
// Delete confirm
const deletingUser = ref<ManagedUser | null>(null)
const deleteLoading = ref(false)
function headers() {
return { Authorization: `Bearer ${auth.token}` }
}
async function fetchUsers() {
loading.value = true
error.value = ''
try {
users.value = await $fetch('/api/auth/users', { headers: headers() })
} catch (e: any) {
error.value = e.data?.message || 'Failed to load users'
} finally {
loading.value = false
}
}
async function createUser() {
createError.value = ''
createLoading.value = true
try {
await $fetch('/api/auth/users', {
method: 'POST',
body: createForm.value,
headers: headers(),
})
showCreateForm.value = false
createForm.value = { email: '', password: '', displayName: '', role: 'user' }
showSuccess('User created')
await fetchUsers()
} catch (e: any) {
createError.value = e.data?.message || 'Failed to create user'
} finally {
createLoading.value = false
}
}
function startEdit(user: ManagedUser) {
editingUser.value = user
editForm.value = { email: user.email, displayName: user.displayName || '', role: user.role }
editError.value = ''
}
async function saveEdit() {
if (!editingUser.value) return
editError.value = ''
editLoading.value = true
try {
await $fetch(`/api/auth/users/${editingUser.value.id}`, {
method: 'PUT',
body: editForm.value,
headers: headers(),
})
editingUser.value = null
showSuccess('User updated')
await fetchUsers()
} catch (e: any) {
editError.value = e.data?.message || 'Failed to update user'
} finally {
editLoading.value = false
}
}
async function resetPassword() {
if (!resetPasswordUser.value) return
resetPasswordLoading.value = true
try {
await $fetch(`/api/auth/users/${resetPasswordUser.value.id}/reset-password`, {
method: 'POST',
body: { newPassword: resetPasswordValue.value },
headers: headers(),
})
resetPasswordUser.value = null
resetPasswordValue.value = ''
showSuccess('Password reset')
} catch (e: any) {
showSuccess('')
error.value = e.data?.message || 'Failed to reset password'
} finally {
resetPasswordLoading.value = false
}
}
async function resetTotp(user: ManagedUser) {
try {
await $fetch(`/api/auth/users/${user.id}/reset-totp`, {
method: 'POST',
headers: headers(),
})
showSuccess(`TOTP reset for ${user.email}`)
await fetchUsers()
} catch (e: any) {
error.value = e.data?.message || 'Failed to reset TOTP'
}
}
async function deleteUser() {
if (!deletingUser.value) return
deleteLoading.value = true
try {
await $fetch(`/api/auth/users/${deletingUser.value.id}`, {
method: 'DELETE',
headers: headers(),
})
deletingUser.value = null
showSuccess('User deleted')
await fetchUsers()
} catch (e: any) {
error.value = e.data?.message || 'Failed to delete user'
} finally {
deleteLoading.value = false
}
}
function showSuccess(msg: string) {
successMsg.value = msg
if (msg) setTimeout(() => (successMsg.value = ''), 3000)
}
onMounted(fetchUsers)
</script>
<template>
<div class="max-w-4xl mx-auto p-6 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-white">User Management</h2>
<button
@click="showCreateForm = !showCreateForm"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm font-medium"
>
{{ showCreateForm ? 'Cancel' : '+ New User' }}
</button>
</div>
<!-- Success / Error banners -->
<p v-if="successMsg" class="text-green-400 text-sm bg-green-900/20 border border-green-800 rounded px-3 py-2">{{ successMsg }}</p>
<p v-if="error" class="text-red-400 text-sm bg-red-900/20 border border-red-800 rounded px-3 py-2">{{ error }}</p>
<!-- Create User Form -->
<form v-if="showCreateForm" @submit.prevent="createUser" class="bg-gray-900 p-5 rounded-lg border border-gray-800 space-y-4">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Create User</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Email</label>
<input v-model="createForm.email" type="email" required
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Password</label>
<input v-model="createForm.password" type="password" required minlength="6"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Display Name</label>
<input v-model="createForm.displayName" type="text"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Role</label>
<select v-model="createForm.role"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<p v-if="createError" class="text-red-400 text-sm">{{ createError }}</p>
<button type="submit" :disabled="createLoading"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
{{ createLoading ? 'Creating...' : 'Create User' }}
</button>
</form>
<!-- User Table -->
<div v-if="loading" class="text-gray-500 text-sm">Loading users...</div>
<div v-else-if="users.length" class="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-800 text-gray-500 text-left">
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Role</th>
<th class="px-4 py-3 font-medium">2FA</th>
<th class="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id" class="border-b border-gray-800/50 hover:bg-gray-800/30">
<td class="px-4 py-3">
<div class="text-white">{{ user.displayName || user.email }}</div>
<div v-if="user.displayName" class="text-xs text-gray-500">{{ user.email }}</div>
</td>
<td class="px-4 py-3">
<span
class="px-2 py-0.5 rounded text-xs font-medium"
:class="user.role === 'admin' ? 'bg-wraith-600/20 text-wraith-400' : 'bg-gray-700/50 text-gray-400'"
>
{{ user.role }}
</span>
</td>
<td class="px-4 py-3">
<span v-if="user.totpEnabled" class="text-green-400 text-xs">Enabled</span>
<span v-else class="text-gray-600 text-xs">Off</span>
</td>
<td class="px-4 py-3 text-right space-x-2">
<button @click="startEdit(user)" class="text-xs text-gray-400 hover:text-white">Edit</button>
<button @click="resetPasswordUser = user; resetPasswordValue = ''" class="text-xs text-gray-400 hover:text-yellow-400">Reset Pwd</button>
<button v-if="user.totpEnabled" @click="resetTotp(user)" class="text-xs text-gray-400 hover:text-yellow-400">Reset 2FA</button>
<button v-if="user.id !== auth.user?.id" @click="deletingUser = user" class="text-xs text-gray-400 hover:text-red-400">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Edit User Modal -->
<div v-if="editingUser" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" @click.self="editingUser = null">
<form @submit.prevent="saveEdit" class="bg-gray-900 p-6 rounded-lg border border-gray-800 w-full max-w-md space-y-4">
<h3 class="text-sm font-semibold text-white">Edit User</h3>
<div>
<label class="block text-sm text-gray-400 mb-1">Email</label>
<input v-model="editForm.email" type="email" required
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Display Name</label>
<input v-model="editForm.displayName" type="text"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Role</label>
<select v-model="editForm.role"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<p v-if="editError" class="text-red-400 text-sm">{{ editError }}</p>
<div class="flex gap-2">
<button type="submit" :disabled="editLoading"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
{{ editLoading ? 'Saving...' : 'Save' }}
</button>
<button type="button" @click="editingUser = null" class="px-4 py-2 text-sm text-gray-500 hover:text-gray-300">Cancel</button>
</div>
</form>
</div>
<!-- Reset Password Modal -->
<div v-if="resetPasswordUser" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" @click.self="resetPasswordUser = null">
<form @submit.prevent="resetPassword" class="bg-gray-900 p-6 rounded-lg border border-gray-800 w-full max-w-md space-y-4">
<h3 class="text-sm font-semibold text-white">Reset Password for {{ resetPasswordUser.email }}</h3>
<div>
<label class="block text-sm text-gray-400 mb-1">New Password</label>
<input v-model="resetPasswordValue" type="password" required minlength="6"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div class="flex gap-2">
<button type="submit" :disabled="resetPasswordLoading || !resetPasswordValue"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
{{ resetPasswordLoading ? 'Resetting...' : 'Reset Password' }}
</button>
<button type="button" @click="resetPasswordUser = null" class="px-4 py-2 text-sm text-gray-500 hover:text-gray-300">Cancel</button>
</div>
</form>
</div>
<!-- Delete Confirm Modal -->
<div v-if="deletingUser" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" @click.self="deletingUser = null">
<div class="bg-gray-900 p-6 rounded-lg border border-red-800 w-full max-w-md space-y-4">
<h3 class="text-sm font-semibold text-red-400">Delete User</h3>
<p class="text-sm text-gray-300">
Are you sure you want to delete <strong class="text-white">{{ deletingUser.email }}</strong>?
This will permanently remove all their hosts, credentials, SSH keys, and connection logs.
</p>
<div class="flex gap-2">
<button @click="deleteUser" :disabled="deleteLoading"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded text-sm disabled:opacity-50">
{{ deleteLoading ? 'Deleting...' : 'Delete User' }}
</button>
<button @click="deletingUser = null" class="px-4 py-2 text-sm text-gray-500 hover:text-gray-300">Cancel</button>
</div>
</div>
</div>
</div>
</template>