feat(02-05): admin tab components and AdminView
- AdminView.vue: tabbed layout (Users | Quotas | AI Config) with UI-SPEC tab strip classes - AdminUsersTab.vue: user table with create form (crypto.getRandomValues password), inline deactivation confirmation, reactivate, reset-password, row-level spinner, empty state - AdminQuotasTab.vue: quota inline edit with MB display, usage %, warning when limit < usage - AdminAiConfigTab.vue: AI provider/model per-user with 1.5s "Saved" confirmation - client.js: fix adminDeactivateUser/adminReactivateUser to use PATCH /status endpoint, fix adminResetUserPassword to /password-reset, fix adminUpdateAiConfig to send ai_provider/ai_model, add adminGetUserQuota - No impersonation UI in any admin component (T-02-31)
This commit is contained in:
@@ -225,15 +225,27 @@ export function adminCreateUser(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function adminDeactivateUser(id) {
|
export function adminDeactivateUser(id) {
|
||||||
return request(`/api/admin/users/${id}/deactivate`, { method: 'POST' })
|
return request(`/api/admin/users/${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_active: false }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminReactivateUser(id) {
|
export function adminReactivateUser(id) {
|
||||||
return request(`/api/admin/users/${id}/reactivate`, { method: 'POST' })
|
return request(`/api/admin/users/${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_active: true }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminResetUserPassword(id) {
|
export function adminResetUserPassword(id) {
|
||||||
return request(`/api/admin/users/${id}/reset-password`, { method: 'POST' })
|
return request(`/api/admin/users/${id}/password-reset`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminGetUserQuota(id) {
|
||||||
|
return request(`/api/admin/users/${id}/quota`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminUpdateQuota(id, limitBytes) {
|
export function adminUpdateQuota(id, limitBytes) {
|
||||||
@@ -248,6 +260,6 @@ export function adminUpdateAiConfig(id, provider, model) {
|
|||||||
return request(`/api/admin/users/${id}/ai-config`, {
|
return request(`/api/admin/users/${id}/ai-config`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ provider, model }),
|
body: JSON.stringify({ ai_provider: provider, ai_model: model }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<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 AI config…
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- AI config 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">AI Provider</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI Model</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="bg-white text-sm">
|
||||||
|
<td class="px-4 py-3 text-gray-900">{{ user.email }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<select
|
||||||
|
v-model="configs[user.id].provider"
|
||||||
|
class="block w-36 rounded-lg px-2 py-1.5 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="">— None —</option>
|
||||||
|
<option v-for="p in providers" :key="p.value" :value="p.value">{{ p.label }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input
|
||||||
|
v-model="configs[user.id].model"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. gpt-4o"
|
||||||
|
class="block w-40 rounded-lg px-2 py-1.5 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="saveConfig(user.id)"
|
||||||
|
:disabled="savingId === user.id"
|
||||||
|
class="text-sm text-indigo-600 hover:text-indigo-700 font-semibold disabled:opacity-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span v-if="savingId === user.id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
|
||||||
|
{{ savingId === user.id ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="savedId === user.id"
|
||||||
|
class="text-xs text-green-600 font-semibold"
|
||||||
|
>
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<p v-if="loadError" class="mt-3 text-sm text-red-600">{{ loadError }}</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 loadError = ref(null)
|
||||||
|
const savingId = ref(null)
|
||||||
|
const savedId = ref(null)
|
||||||
|
|
||||||
|
// Per-user config state: { [userId]: { provider, model } }
|
||||||
|
const configs = reactive({})
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
|
{ value: 'anthropic', label: 'Anthropic' },
|
||||||
|
{ value: 'ollama', label: 'Ollama' },
|
||||||
|
{ value: 'lmstudio', label: 'LM Studio' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function saveConfig(userId) {
|
||||||
|
savingId.value = userId
|
||||||
|
savedId.value = null
|
||||||
|
try {
|
||||||
|
await api.adminUpdateAiConfig(
|
||||||
|
userId,
|
||||||
|
configs[userId].provider || null,
|
||||||
|
configs[userId].model || null
|
||||||
|
)
|
||||||
|
savedId.value = userId
|
||||||
|
setTimeout(() => {
|
||||||
|
if (savedId.value === userId) savedId.value = null
|
||||||
|
}, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
} finally {
|
||||||
|
savingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.adminListUsers()
|
||||||
|
users.value = data.items || []
|
||||||
|
// Initialize per-user config state
|
||||||
|
for (const user of users.value) {
|
||||||
|
configs[user.id] = {
|
||||||
|
provider: user.ai_provider || '',
|
||||||
|
model: user.ai_model || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<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 quotas…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="rows.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>
|
||||||
|
|
||||||
|
<!-- Quotas 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">Used</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Limit</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Usage %</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="row in rows" :key="row.id" class="bg-white text-sm">
|
||||||
|
<td class="px-4 py-3 text-gray-900">{{ row.email }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-700">{{ formatMB(row.used_bytes) }}</td>
|
||||||
|
|
||||||
|
<!-- Limit cell — inline edit when editing -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div v-if="editingId === row.id">
|
||||||
|
<input
|
||||||
|
v-model.number="editLimitMB"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="block w-28 rounded-lg px-2 py-1 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||||
|
@keyup.enter="saveQuota(row)"
|
||||||
|
@keyup.escape="cancelEdit"
|
||||||
|
/>
|
||||||
|
<p v-if="editWarning && editingId === row.id" class="mt-1 text-xs text-amber-600">
|
||||||
|
New limit is below current usage ({{ formatMB(row.used_bytes) }}). Existing documents will not be deleted, but uploads will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-700">{{ formatMB(row.limit_bytes) }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="px-4 py-3 text-gray-700">
|
||||||
|
{{ usagePercent(row.used_bytes, row.limit_bytes) }}%
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions cell -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div v-if="editingId === row.id" class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="saveQuota(row)"
|
||||||
|
:disabled="savingId === row.id"
|
||||||
|
class="text-sm text-indigo-600 hover:text-indigo-700 font-semibold disabled:opacity-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span v-if="savingId === row.id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
|
||||||
|
{{ savingId === row.id ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
<span class="text-gray-300">·</span>
|
||||||
|
<button @click="cancelEdit" class="text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="startEdit(row)"
|
||||||
|
class="text-sm text-indigo-600 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<p v-if="loadError" class="mt-3 text-sm text-red-600">{{ loadError }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
|
||||||
|
const rows = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref(null)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const editLimitMB = ref(0)
|
||||||
|
const editWarning = ref(false)
|
||||||
|
const savingId = ref(null)
|
||||||
|
|
||||||
|
function formatMB(bytes) {
|
||||||
|
if (bytes == null) return '—'
|
||||||
|
return Math.round(bytes / 1048576) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
function usagePercent(used, limit) {
|
||||||
|
if (!limit) return 0
|
||||||
|
return Math.round((used / limit) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(row) {
|
||||||
|
editingId.value = row.id
|
||||||
|
editLimitMB.value = Math.round(row.limit_bytes / 1048576)
|
||||||
|
editWarning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId.value = null
|
||||||
|
editWarning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQuota(row) {
|
||||||
|
const newBytes = editLimitMB.value * 1048576
|
||||||
|
if (newBytes < row.used_bytes) {
|
||||||
|
editWarning.value = true
|
||||||
|
}
|
||||||
|
savingId.value = row.id
|
||||||
|
try {
|
||||||
|
const result = await api.adminUpdateQuota(row.id, newBytes)
|
||||||
|
// Update the row in place
|
||||||
|
const idx = rows.value.findIndex(r => r.id === row.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
rows.value[idx] = {
|
||||||
|
...rows.value[idx],
|
||||||
|
limit_bytes: result.limit_bytes,
|
||||||
|
used_bytes: result.used_bytes,
|
||||||
|
}
|
||||||
|
if (result.warning) {
|
||||||
|
editWarning.value = true
|
||||||
|
// Keep edit mode open briefly to show warning, then close
|
||||||
|
setTimeout(() => {
|
||||||
|
editingId.value = null
|
||||||
|
editWarning.value = false
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
editingId.value = null
|
||||||
|
editWarning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
editingId.value = null
|
||||||
|
} finally {
|
||||||
|
savingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
// Load users first to get email addresses
|
||||||
|
const usersData = await api.adminListUsers()
|
||||||
|
const users = usersData.items || []
|
||||||
|
|
||||||
|
// Fetch quotas for all users in parallel
|
||||||
|
const quotaResults = await Promise.allSettled(
|
||||||
|
users.map(u => api.adminGetUserQuota(u.id).then(q => ({ ...q, id: u.id, email: u.email })))
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.value = quotaResults
|
||||||
|
.filter(r => r.status === 'fulfilled')
|
||||||
|
.map(r => r.value)
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<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">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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
@click="reactivate(user.id)"
|
||||||
|
class="text-indigo-600 hover:text-indigo-700 text-sm"
|
||||||
|
>
|
||||||
|
Reactivate
|
||||||
|
</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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-8 max-w-4xl mx-auto">
|
<div class="p-8 max-w-5xl mx-auto">
|
||||||
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Admin panel</h2>
|
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Admin panel</h2>
|
||||||
|
|
||||||
<!-- Sub-navigation -->
|
<!-- Tab strip -->
|
||||||
<div class="flex border-b border-gray-200 mb-6">
|
<div class="flex border-b border-gray-200 mb-6">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -17,17 +17,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab content (stub — wired in Phase 2 admin plan) -->
|
<!-- Tab content -->
|
||||||
<div class="text-sm text-gray-500">
|
<AdminUsersTab v-if="activeTab === 'users'" />
|
||||||
{{ activeTab === 'users' ? 'User management coming soon.' :
|
<AdminQuotasTab v-if="activeTab === 'quotas'" />
|
||||||
activeTab === 'quotas' ? 'Quota management coming soon.' :
|
<AdminAiConfigTab v-if="activeTab === 'ai'" />
|
||||||
'AI config management coming soon.' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import AdminUsersTab from '../components/admin/AdminUsersTab.vue'
|
||||||
|
import AdminQuotasTab from '../components/admin/AdminQuotasTab.vue'
|
||||||
|
import AdminAiConfigTab from '../components/admin/AdminAiConfigTab.vue'
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'users', label: 'Users' },
|
{ id: 'users', label: 'Users' },
|
||||||
|
|||||||
Reference in New Issue
Block a user