Files
kite/frontend/src/components/admin/AdminUsersTab.vue
T
curo1305 045e723f7a feat(06.2-05): show @handle in AccountView and AdminUsersTab
- Add Username row (@handle) to Account information section in AccountView.vue
- Add Handle column (th + td with @prefix) to users table in AdminUsersTab.vue
- Both use existing data already present in API responses (no backend changes)
2026-05-31 20:09:50 +02:00

466 lines
17 KiB
Vue

<template>
<div>
<!-- Create user panel (inline above table) -->
<div v-if="showCreateForm" class="bg-white border border-gray-200 rounded-xl p-6 mb-4">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Create user</h3>
<div class="space-y-3">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Email</label>
<input
v-model="newUser.email"
type="email"
placeholder="user@example.com"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Role</label>
<select
v-model="newUser.role"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors bg-white"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Temporary password</label>
<div class="flex gap-2">
<input
:value="newUser.password"
type="text"
readonly
class="block flex-1 rounded-lg px-3 py-2 text-sm border border-gray-200 bg-gray-50 text-gray-700 font-mono focus:outline-none"
/>
<button
@click="copyPassword"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition-colors"
:title="passwordCopied ? 'Copied!' : 'Copy password'"
>
<svg v-if="!passwordCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg v-else class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<button
@click="generatePassword"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition-colors"
title="Regenerate password"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<p v-if="createError" class="text-xs text-red-600">{{ createError }}</p>
<div class="flex gap-2 pt-1">
<button
@click="submitCreate"
:disabled="creating"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg disabled:opacity-50 transition-colors"
>
<span v-if="creating" class="flex items-center gap-1.5">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Creating
</span>
<span v-else>Create user</span>
</button>
<button
@click="cancelCreate"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
<!-- Table header with Create user button -->
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-gray-500">{{ users.length }} user{{ users.length !== 1 ? 's' : '' }}</p>
<button
v-if="!showCreateForm"
@click="openCreateForm"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
>
Create user
</button>
</div>
<!-- Loading state -->
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading users
</div>
</div>
<!-- Empty state -->
<div v-else-if="users.length === 0" class="bg-white rounded-xl border border-gray-200 p-12 text-center">
<h3 class="text-sm font-semibold text-gray-900 mb-1">No users yet</h3>
<p class="text-sm text-gray-500">Create the first user account to get started.</p>
</div>
<!-- Users table -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden divide-y divide-gray-200">
<table class="w-full">
<thead>
<tr class="bg-gray-50 text-left">
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Handle</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Created</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr
v-for="user in users"
:key="user.id"
:class="[
'text-sm transition-colors',
!user.is_active ? 'bg-gray-50' : 'bg-white',
pendingAction[user.id] ? 'pointer-events-none opacity-60' : '',
]"
>
<td class="px-4 py-3 text-gray-900">{{ user.email }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ user.handle ? '@' + user.handle : '—' }}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold"
:class="user.role === 'admin' ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-100 text-gray-600'"
>
{{ user.role === 'admin' ? 'Admin' : 'User' }}
</span>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold"
:class="user.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
>
{{ user.is_active ? 'Active' : 'Deactivated' }}
</span>
</td>
<td class="px-4 py-3 text-gray-500">{{ formatDate(user.created_at) }}</td>
<td class="px-4 py-3">
<!-- Inline deactivation confirmation -->
<div v-if="confirmDeactivate === user.id" class="space-y-2">
<p class="text-xs text-gray-700">
Deactivate <span class="font-semibold">{{ user.email }}</span>? They will lose access immediately. Their data is preserved.
</p>
<div class="flex items-center gap-2">
<button
@click="confirmDoDeactivate(user.id)"
:disabled="pendingAction[user.id]"
class="text-red-600 hover:text-red-700 text-sm font-semibold disabled:opacity-50"
>
<span v-if="pendingAction[user.id]" class="flex items-center gap-1">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Deactivating
</span>
<span v-else>Deactivate</span>
</button>
<span class="text-gray-300">·</span>
<button @click="confirmDeactivate = null" class="text-gray-500 hover:text-gray-700 text-sm">
Keep account
</button>
</div>
</div>
<!-- Inline delete confirmation panel -->
<div v-else-if="confirmDelete === user.id" class="space-y-2">
<p class="text-xs text-red-700 font-semibold">
Permanently delete <span class="font-bold">{{ user.email }}</span>?
This will erase all their documents, cloud connections, and quota data. This cannot be undone.
</p>
<div>
<label class="block text-xs text-gray-700 mb-1 font-semibold">Your admin password to confirm</label>
<input
v-model="deletePassword"
type="password"
autocomplete="current-password"
placeholder="Admin password"
class="block w-full rounded-lg px-2 py-1.5 text-xs border border-red-300 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
@keydown.enter.prevent="confirmDoDelete(user.id)"
/>
</div>
<p v-if="deleteError" class="text-xs text-red-600">{{ deleteError }}</p>
<div class="flex items-center gap-2">
<button
@click="confirmDoDelete(user.id)"
:disabled="pendingAction[user.id] || !deletePassword"
class="text-red-700 hover:text-red-800 text-sm font-semibold disabled:opacity-50"
>
<span v-if="pendingAction[user.id]" class="flex items-center gap-1">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Deleting
</span>
<span v-else>Delete permanently</span>
</button>
<span class="text-gray-300">·</span>
<button @click="cancelDelete" class="text-gray-500 hover:text-gray-700 text-sm">
Cancel
</button>
</div>
</div>
<!-- Normal actions -->
<div v-else class="flex items-center gap-2">
<span v-if="pendingAction[user.id]" class="flex items-center gap-1 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
</span>
<template v-else-if="user.is_active">
<button
@click="resetPassword(user.id)"
class="text-indigo-600 hover:text-indigo-700 text-sm"
>
Reset password
</button>
<span class="text-gray-300">·</span>
<button
@click="startDeactivate(user.id)"
class="text-red-600 hover:text-red-700 text-sm"
>
Deactivate
</button>
<span class="text-gray-300">·</span>
<button
@click="startDelete(user.id)"
class="text-red-800 hover:text-red-900 text-sm font-semibold"
>
Delete
</button>
</template>
<template v-else>
<button
@click="reactivate(user.id)"
class="text-indigo-600 hover:text-indigo-700 text-sm"
>
Reactivate
</button>
<span class="text-gray-300">·</span>
<button
@click="startDelete(user.id)"
class="text-red-800 hover:text-red-900 text-sm font-semibold"
>
Delete
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Action error message -->
<p v-if="actionError" class="mt-3 text-sm text-red-600">{{ actionError }}</p>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as api from '../../api/client.js'
const users = ref([])
const loading = ref(false)
const showCreateForm = ref(false)
const confirmDeactivate = ref(null)
const confirmDelete = ref(null)
const deletePassword = ref('')
const deleteError = ref(null)
const pendingAction = reactive({})
const actionError = ref(null)
const creating = ref(false)
const createError = ref(null)
const passwordCopied = ref(false)
const newUser = reactive({
email: '',
role: 'user',
password: '',
})
function generateRandomPassword() {
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'
const arr = new Uint8Array(16)
crypto.getRandomValues(arr)
let pw = ''
for (const byte of arr) {
pw += charset[byte % charset.length]
}
// Ensure all character classes are represented
pw = pw.slice(0, 12) + 'A1!'
return pw
}
function generatePassword() {
newUser.password = generateRandomPassword()
passwordCopied.value = false
}
function copyPassword() {
navigator.clipboard.writeText(newUser.password).then(() => {
passwordCopied.value = true
setTimeout(() => { passwordCopied.value = false }, 2000)
})
}
function openCreateForm() {
newUser.email = ''
newUser.role = 'user'
newUser.password = generateRandomPassword()
createError.value = null
showCreateForm.value = true
}
function cancelCreate() {
showCreateForm.value = false
createError.value = null
}
async function submitCreate() {
if (!newUser.email) {
createError.value = 'Email is required.'
return
}
creating.value = true
createError.value = null
try {
const handle = newUser.email.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 40)
const created = await api.adminCreateUser({
handle,
email: newUser.email,
password: newUser.password,
role: newUser.role,
})
// Prepend the new user with sensible defaults for display
users.value.unshift({
id: created.id,
handle: created.handle,
email: created.email,
role: created.role,
is_active: true,
totp_enabled: false,
ai_provider: null,
ai_model: null,
created_at: created.created_at,
})
showCreateForm.value = false
} catch (e) {
createError.value = e.message
} finally {
creating.value = false
}
}
function startDeactivate(id) {
confirmDeactivate.value = id
confirmDelete.value = null
deletePassword.value = ''
deleteError.value = null
}
function startDelete(id) {
confirmDelete.value = id
deletePassword.value = ''
deleteError.value = null
confirmDeactivate.value = null
}
function cancelDelete() {
confirmDelete.value = null
deletePassword.value = ''
deleteError.value = null
}
async function confirmDoDelete(id) {
pendingAction[id] = true
deleteError.value = null
try {
await api.adminDeleteUser(id, deletePassword.value)
users.value = users.value.filter(u => u.id !== id)
cancelDelete()
} catch (e) {
deleteError.value = e.message
} finally {
delete pendingAction[id]
}
}
async function confirmDoDeactivate(id) {
pendingAction[id] = true
actionError.value = null
try {
await api.adminDeactivateUser(id)
const idx = users.value.findIndex(u => u.id === id)
if (idx !== -1) {
users.value[idx] = { ...users.value[idx], is_active: false }
}
confirmDeactivate.value = null
} catch (e) {
actionError.value = e.message
confirmDeactivate.value = null
} finally {
delete pendingAction[id]
}
}
async function reactivate(id) {
pendingAction[id] = true
actionError.value = null
try {
await api.adminReactivateUser(id)
const idx = users.value.findIndex(u => u.id === id)
if (idx !== -1) {
users.value[idx] = { ...users.value[idx], is_active: true }
}
} catch (e) {
actionError.value = e.message
} finally {
delete pendingAction[id]
}
}
async function resetPassword(id) {
pendingAction[id] = true
actionError.value = null
try {
await api.adminResetUserPassword(id)
} catch (e) {
actionError.value = e.message
} finally {
delete pendingAction[id]
}
}
function formatDate(iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return iso
}
}
onMounted(async () => {
loading.value = true
try {
const data = await api.adminListUsers()
users.value = data.items || []
} catch (e) {
actionError.value = e.message
} finally {
loading.value = false
}
})
</script>