Frontend: AdminLayout, AdminSidebar, AdminOverviewView, AdminUsersView, AdminQuotasView, AdminAiView, AdminAuditView, router/index.js, LoginView, tailwind.config.js, api/admin.js — WHAT labels stripped; WHY constraints preserved (D-16 anchors, security notes, api_key write-only invariant, router guard rationale, D-08/D-09/D-10 decision refs, fetchWithRetry reason). Backend: overview.py was already clean (no WHAT comments present). Frontend: 137 tests pass, build succeeds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
175 lines
6.1 KiB
Vue
175 lines
6.1 KiB
Vue
<template>
|
|
<div>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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)
|
|
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
|
|
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 {
|
|
const usersData = await api.adminListUsers()
|
|
const users = usersData.items || []
|
|
|
|
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>
|