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:
@@ -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>
|
||||
Reference in New Issue
Block a user