C-2: JWT moved from localStorage to httpOnly cookie (eliminates XSS token theft) C-3: WebSocket auth via short-lived single-use tickets (JWT no longer in URLs) H-1: JWT expiry reduced from 7 days to 4 hours H-3: TOTP secrets encrypted at rest with vault EncryptionService (auto-migrates plaintext) H-6: Rate limiting via @nestjs/throttler (60 req/min global, tighten on auth) H-8: Constant-time login — Argon2id verify runs against dummy hash for non-existent users H-9: Password hashing upgraded from bcrypt(10) to Argon2id (auto-upgrades on login) H-10: Credential list API no longer returns encrypted blobs H-16: Admin pages use Nuxt route middleware instead of client-side guard Plus: auth bootstrap plugin, cookie-parser middleware, all frontend Authorization headers removed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
315 lines
13 KiB
Vue
315 lines
13 KiB
Vue
<script setup lang="ts">
|
|
definePageMeta({ middleware: 'admin' })
|
|
const auth = useAuthStore()
|
|
|
|
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)
|
|
|
|
async function fetchUsers() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
users.value = await $fetch('/api/auth/users')
|
|
} 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,
|
|
})
|
|
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,
|
|
})
|
|
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 },
|
|
})
|
|
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',
|
|
})
|
|
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',
|
|
})
|
|
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>
|