chore: merge executor worktree (worktree-agent-ae0df1181a91ce55b)
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
phase: "09"
|
||||||
|
plan: "03"
|
||||||
|
subsystem: frontend-admin-views
|
||||||
|
tags: [admin, vue, extraction, refactor]
|
||||||
|
dependency_graph:
|
||||||
|
requires: [09-02]
|
||||||
|
provides:
|
||||||
|
- frontend/src/views/admin/AdminUsersView.vue
|
||||||
|
- frontend/src/views/admin/AdminQuotasView.vue
|
||||||
|
- frontend/src/views/admin/AdminAiView.vue
|
||||||
|
- frontend/src/views/admin/AdminAuditView.vue
|
||||||
|
affects:
|
||||||
|
- frontend/src/views/admin/
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Behavior-preserving tab-to-view extraction — identical template + script, import paths rewritten for new depth"
|
||||||
|
- "SearchableModelSelect import rewritten from ../ui/ to ../../components/ui/ (depth change from components/admin/ to views/admin/)"
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- frontend/src/views/admin/AdminUsersView.vue
|
||||||
|
- frontend/src/views/admin/AdminQuotasView.vue
|
||||||
|
- frontend/src/views/admin/AdminAiView.vue
|
||||||
|
- frontend/src/views/admin/AdminAuditView.vue
|
||||||
|
modified: []
|
||||||
|
decisions:
|
||||||
|
- "SearchableModelSelect import path adjusted from '../ui/SearchableModelSelect.vue' to '../../components/ui/SearchableModelSelect.vue' — only import rewrite required (all other imports use ../../api/ and ../../utils/ which resolve identically from both locations)"
|
||||||
|
- "No defineProps dropped — source tab components received no props from AdminView.vue (mounted as <AdminUsersTab v-if=... /> with no prop-passing)"
|
||||||
|
- "No orphaned emit replacements — source tab components had no emit() calls"
|
||||||
|
metrics:
|
||||||
|
duration: "6 minutes"
|
||||||
|
completed: "2026-06-12"
|
||||||
|
tasks_completed: 2
|
||||||
|
tasks_total: 2
|
||||||
|
files_created: 4
|
||||||
|
files_modified: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 09 Plan 03: Tab-to-View Extraction Summary
|
||||||
|
|
||||||
|
**One-liner:** Four admin tab components promoted verbatim to standalone view files under `frontend/src/views/admin/` with one import path rewrite (SearchableModelSelect) and no behavioral changes.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commit | Files |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| 1 | Extract AdminUsersView + AdminQuotasView | `d690a44` | frontend/src/views/admin/AdminUsersView.vue, frontend/src/views/admin/AdminQuotasView.vue |
|
||||||
|
| 2 | Extract AdminAiView + AdminAuditView | `164003b` | frontend/src/views/admin/AdminAiView.vue, frontend/src/views/admin/AdminAuditView.vue |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Task 1 — AdminUsersView.vue + AdminQuotasView.vue
|
||||||
|
|
||||||
|
**AdminUsersView.vue** is a verbatim copy of `AdminUsersTab.vue` (480 lines → 480 lines). No props removed (source had none). No emit calls removed (source had none). Import paths `../../utils/formatters.js` and `../../api/client.js` resolve identically from `views/admin/` as from `components/admin/`. Root `<div>` has no padding classes.
|
||||||
|
|
||||||
|
**AdminQuotasView.vue** is a verbatim copy of `AdminQuotasTab.vue` (182 lines → 182 lines). Same import path analysis — `../../api/client.js` resolves identically. Root `<div>` has no padding classes.
|
||||||
|
|
||||||
|
### Task 2 — AdminAiView.vue + AdminAuditView.vue
|
||||||
|
|
||||||
|
**AdminAiView.vue** is a copy of `AdminAiConfigTab.vue` (391 lines → 391 lines) with one import path rewrite:
|
||||||
|
|
||||||
|
- Before (from `components/admin/`): `import SearchableModelSelect from '../ui/SearchableModelSelect.vue'`
|
||||||
|
- After (from `views/admin/`): `import SearchableModelSelect from '../../components/ui/SearchableModelSelect.vue'`
|
||||||
|
|
||||||
|
The System AI Providers (Global) section is preserved above the per-user assignment table per the Phase 7 cross-cutting constraint. All other imports (`../../api/client.js`) resolve unchanged.
|
||||||
|
|
||||||
|
**AdminAuditView.vue** is a verbatim copy of `AuditLogTab.vue` (346 lines → 346 lines) per D-13 (promote as-is). The `actionTypeClass()` helper is preserved verbatim with all dynamic color classes (`bg-blue-50 text-blue-600`, `bg-purple-50 text-purple-600`, `bg-amber-50 text-amber-700`, `bg-gray-100 text-gray-600`) intact for Tailwind safelist coverage in 09-04.
|
||||||
|
|
||||||
|
## Dead Prop Removals
|
||||||
|
|
||||||
|
None. Reading `AdminView.vue` confirmed that all four tab components were mounted with no prop-passing:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<AdminUsersTab v-if="activeTab === 'users'" />
|
||||||
|
<AdminQuotasTab v-if="activeTab === 'quotas'" />
|
||||||
|
<AdminAiConfigTab v-if="activeTab === 'ai'" />
|
||||||
|
<AuditLogTab v-if="activeTab === 'audit'" />
|
||||||
|
```
|
||||||
|
|
||||||
|
No `defineProps` blocks existed in any source component, so no prop removal was needed.
|
||||||
|
|
||||||
|
## Orphaned Emit Replacements
|
||||||
|
|
||||||
|
None. No source tab component contained any `emit()` calls. All data fetches are self-contained in `onMounted()` handlers within each view.
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
- `npm run build` (worktree): 143 modules transformed, 0 errors — PASSED
|
||||||
|
- All 4 new view files exist under `frontend/src/views/admin/` — PASSED
|
||||||
|
- Root `<div>` has no padding classes on any view — PASSED
|
||||||
|
- `grep -c "actionTypeClass" AdminAuditView.vue` returns 2 — PASSED
|
||||||
|
- `grep -c "bg-amber-50" AdminAuditView.vue` returns 1 — PASSED
|
||||||
|
- `grep -c "bg-purple-50" AdminAuditView.vue` returns 1 — PASSED
|
||||||
|
- `grep -c "System AI" AdminAiView.vue` returns 2 — PASSED
|
||||||
|
- Original tab components in `frontend/src/components/admin/` untouched — PASSED
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**[Rule 3 - Import Path Rewrite] SearchableModelSelect path adjusted for new directory depth**
|
||||||
|
|
||||||
|
- **Found during:** Task 2 (import analysis before extraction)
|
||||||
|
- **Issue:** `AdminAiConfigTab.vue` imports `SearchableModelSelect` via `'../ui/SearchableModelSelect.vue'` (relative to `components/admin/`). From `views/admin/`, this path resolves to `views/ui/` which does not exist.
|
||||||
|
- **Fix:** Rewrote to `'../../components/ui/SearchableModelSelect.vue'` — the absolute path is the same; only the relative traversal changed due to the directory depth shift.
|
||||||
|
- **Files modified:** `frontend/src/views/admin/AdminAiView.vue`
|
||||||
|
|
||||||
|
The plan's `<interfaces>` section explicitly documented this rule: "Verify before extracting each file: `grep -E "^import"` — every import path should already start with `../../`." The `../ui/` path was the one exception, caught by the pre-extraction audit.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — these are structural promotions of existing components. No data is hardcoded or mocked. All fetch logic calls the real API.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None — no new network endpoints, auth paths, file access patterns, or schema changes introduced. The extraction is purely structural; trust boundaries are unchanged from the plan's threat model (T-09-03-01, T-09-03-02 both mitigated as designed).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `frontend/src/views/admin/AdminUsersView.vue` — FOUND
|
||||||
|
- `frontend/src/views/admin/AdminQuotasView.vue` — FOUND
|
||||||
|
- `frontend/src/views/admin/AdminAiView.vue` — FOUND
|
||||||
|
- `frontend/src/views/admin/AdminAuditView.vue` — FOUND
|
||||||
|
- Commit `d690a44` (Task 1) — FOUND
|
||||||
|
- Commit `164003b` (Task 2) — FOUND
|
||||||
|
- `npm run build` succeeds (143 modules, 0 errors) — PASSED
|
||||||
|
- All source tab components untouched in `components/admin/` — PASSED
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- ── System AI Providers (Global) ───────────────────────────────────── -->
|
||||||
|
<section class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900">System AI Providers (Global)</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
|
These settings apply to all classification jobs. Per-user overrides below take precedence when set.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading spinner -->
|
||||||
|
<div v-if="loadingSystem" class="p-6 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 providers…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System error banner -->
|
||||||
|
<div v-if="systemError" class="mx-6 mt-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{{ systemError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider accordion list -->
|
||||||
|
<div v-if="!loadingSystem" class="divide-y divide-gray-100">
|
||||||
|
<div
|
||||||
|
v-for="prov in systemProviders"
|
||||||
|
:key="prov.provider_id"
|
||||||
|
:class="['transition-colors', prov.is_active ? 'border-l-4 border-l-green-500' : '']"
|
||||||
|
>
|
||||||
|
<!-- Accordion header -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between px-6 py-3 text-left hover:bg-gray-50 transition-colors"
|
||||||
|
@click="toggleProvider(prov.provider_id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-gray-900 capitalize">{{ prov.provider_id }}</span>
|
||||||
|
<span
|
||||||
|
v-if="prov.is_active"
|
||||||
|
class="bg-green-100 text-green-700 text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||||
|
>Active</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
:class="['w-4 h-4 text-gray-400 transition-transform', openProviderId === prov.provider_id ? 'rotate-180' : '']"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Accordion body -->
|
||||||
|
<div v-if="openProviderId === prov.provider_id" class="px-6 pb-5 pt-2 bg-gray-50 space-y-3">
|
||||||
|
<!-- API Key -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">API Key</label>
|
||||||
|
<input
|
||||||
|
v-model="formByProvider[prov.provider_id].api_key"
|
||||||
|
type="password"
|
||||||
|
:placeholder="prov.has_api_key ? '(unchanged)' : '(not set)'"
|
||||||
|
autocomplete="off"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Base URL -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Base URL</label>
|
||||||
|
<input
|
||||||
|
v-model="formByProvider[prov.provider_id].base_url"
|
||||||
|
type="text"
|
||||||
|
:placeholder="providerDefaultBaseUrl(prov.provider_id) || '(default)'"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Model name — searchable dropdown populated from provider's /models endpoint -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Model Name</label>
|
||||||
|
<SearchableModelSelect
|
||||||
|
v-model="formByProvider[prov.provider_id].model_name"
|
||||||
|
:provider-id="prov.provider_id"
|
||||||
|
:placeholder="providerDefaultModel(prov.provider_id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context chars -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Context Characters</label>
|
||||||
|
<input
|
||||||
|
v-model.number="formByProvider[prov.provider_id].context_chars"
|
||||||
|
type="number"
|
||||||
|
:placeholder="String(providerDefaultContextChars(prov.provider_id))"
|
||||||
|
min="1000"
|
||||||
|
max="2000000"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center gap-2 pt-1">
|
||||||
|
<!-- Set Active -->
|
||||||
|
<button
|
||||||
|
@click="saveSystemProvider(prov.provider_id, { activate: true })"
|
||||||
|
:disabled="savingProvider === prov.provider_id || prov.is_active"
|
||||||
|
class="text-xs bg-green-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Set Active
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="saveSystemProvider(prov.provider_id)"
|
||||||
|
:disabled="savingProvider === prov.provider_id"
|
||||||
|
class="text-xs bg-indigo-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="savingProvider === prov.provider_id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
|
||||||
|
{{ savingProvider === prov.provider_id ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Test Connection -->
|
||||||
|
<button
|
||||||
|
@click="runTestConnection(prov.provider_id)"
|
||||||
|
:disabled="testingProvider === prov.provider_id || savingProvider === prov.provider_id"
|
||||||
|
class="text-xs border border-gray-300 text-gray-700 font-semibold px-3 py-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-50 flex items-center gap-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="testingProvider === prov.provider_id"
|
||||||
|
class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"
|
||||||
|
></span>
|
||||||
|
{{ testingProvider === prov.provider_id ? 'Testing…' : 'Test Connection' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Test result badge -->
|
||||||
|
<span
|
||||||
|
v-if="testResults[prov.provider_id] === 'ok'"
|
||||||
|
class="text-xs bg-green-100 text-green-700 font-semibold px-2 py-1 rounded-full"
|
||||||
|
>OK</span>
|
||||||
|
<span
|
||||||
|
v-else-if="testResults[prov.provider_id] === 'failed'"
|
||||||
|
class="text-xs bg-red-100 text-red-700 font-semibold px-2 py-1 rounded-full"
|
||||||
|
>Failed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Per-user AI provider assignment (existing — DO NOT MODIFY) ─────── -->
|
||||||
|
|
||||||
|
<!-- 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'
|
||||||
|
import { getAiConfig, saveAiConfig, testAiConnection } from '../../api/client.js'
|
||||||
|
import SearchableModelSelect from '../../components/ui/SearchableModelSelect.vue'
|
||||||
|
|
||||||
|
// ── Per-provider default lookup (mirrors backend PROVIDER_DEFAULTS) ───────────
|
||||||
|
|
||||||
|
const _PROVIDER_DEFAULTS = {
|
||||||
|
openai: { base_url: null, model: 'gpt-4o', context_chars: 120000 },
|
||||||
|
anthropic: { base_url: null, model: 'claude-sonnet-4-6', context_chars: 180000 },
|
||||||
|
gemini: { base_url: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash', context_chars: 800000 },
|
||||||
|
groq: { base_url: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', context_chars: 128000 },
|
||||||
|
xai: { base_url: 'https://api.x.ai/v1', model: 'grok-3-mini', context_chars: 128000 },
|
||||||
|
deepseek: { base_url: 'https://api.deepseek.com', model: 'deepseek-chat', context_chars: 60000 },
|
||||||
|
openrouter: { base_url: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-3.5-sonnet', context_chars: 180000 },
|
||||||
|
mistral: { base_url: 'https://api.mistral.ai/v1', model: 'mistral-large-latest', context_chars: 128000 },
|
||||||
|
ollama: { base_url: 'http://host.docker.internal:11434/v1', model: 'llama3.2', context_chars: 8000 },
|
||||||
|
lmstudio: { base_url: 'http://host.docker.internal:1234/v1', model: 'gemma-4-e4b-it', context_chars: 8000 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerDefaultBaseUrl(pid) { return _PROVIDER_DEFAULTS[pid]?.base_url || '' }
|
||||||
|
function providerDefaultModel(pid) { return _PROVIDER_DEFAULTS[pid]?.model || '' }
|
||||||
|
function providerDefaultContextChars(pid) { return _PROVIDER_DEFAULTS[pid]?.context_chars || 8000 }
|
||||||
|
|
||||||
|
// ── System provider state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const systemProviders = ref([])
|
||||||
|
const loadingSystem = ref(false)
|
||||||
|
const systemError = ref(null)
|
||||||
|
const openProviderId = ref(null)
|
||||||
|
const formByProvider = reactive({})
|
||||||
|
const testResults = reactive({})
|
||||||
|
const savingProvider = ref(null)
|
||||||
|
const testingProvider = ref(null)
|
||||||
|
|
||||||
|
function toggleProvider(pid) {
|
||||||
|
openProviderId.value = openProviderId.value === pid ? null : pid
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initForm(prov) {
|
||||||
|
formByProvider[prov.provider_id] = {
|
||||||
|
api_key: '', // write-only: never pre-filled from server
|
||||||
|
base_url: prov.base_url || '',
|
||||||
|
model_name: prov.model_name || '',
|
||||||
|
context_chars: prov.context_chars || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemProviders() {
|
||||||
|
loadingSystem.value = true
|
||||||
|
systemError.value = null
|
||||||
|
try {
|
||||||
|
const data = await getAiConfig()
|
||||||
|
systemProviders.value = data.providers || []
|
||||||
|
for (const prov of systemProviders.value) {
|
||||||
|
_initForm(prov)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
systemError.value = e.message || 'Failed to load AI provider config'
|
||||||
|
} finally {
|
||||||
|
loadingSystem.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSystemProvider(providerId, opts = {}) {
|
||||||
|
savingProvider.value = providerId
|
||||||
|
const form = formByProvider[providerId] || {}
|
||||||
|
const body = { provider_id: providerId }
|
||||||
|
|
||||||
|
// Only include fields the admin actually modified (api_key only if user typed something)
|
||||||
|
if (form.api_key !== '') body.api_key = form.api_key
|
||||||
|
if (form.base_url !== '') body.base_url = form.base_url
|
||||||
|
if (form.model_name !== '') body.model_name = form.model_name
|
||||||
|
if (form.context_chars && form.context_chars !== 0) body.context_chars = form.context_chars
|
||||||
|
if (opts.activate) body.is_active = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveAiConfig(body)
|
||||||
|
await loadSystemProviders()
|
||||||
|
} catch (e) {
|
||||||
|
systemError.value = e.message || 'Failed to save provider config'
|
||||||
|
} finally {
|
||||||
|
savingProvider.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTestConnection(providerId) {
|
||||||
|
testingProvider.value = providerId
|
||||||
|
testResults[providerId] = null
|
||||||
|
const form = formByProvider[providerId] || {}
|
||||||
|
// Pass unsaved form values so admins can test credentials before saving
|
||||||
|
const overrides = {}
|
||||||
|
if (form.api_key) overrides.api_key = form.api_key
|
||||||
|
if (form.base_url) overrides.base_url = form.base_url
|
||||||
|
if (form.model_name) overrides.model_name = form.model_name
|
||||||
|
try {
|
||||||
|
const r = await testAiConnection(providerId, overrides)
|
||||||
|
testResults[providerId] = r.ok ? 'ok' : 'failed'
|
||||||
|
} catch {
|
||||||
|
testResults[providerId] = 'failed'
|
||||||
|
} finally {
|
||||||
|
testingProvider.value = null
|
||||||
|
}
|
||||||
|
setTimeout(() => { testResults[providerId] = null }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user assignment state (existing — DO NOT MODIFY) ──────────────────────
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
// Load system providers AND per-user list in parallel
|
||||||
|
loadSystemProviders()
|
||||||
|
|
||||||
|
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,346 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-500 mb-1">From</label>
|
||||||
|
<input
|
||||||
|
v-model="filters.start"
|
||||||
|
type="date"
|
||||||
|
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-500 mb-1">To</label>
|
||||||
|
<input
|
||||||
|
v-model="filters.end"
|
||||||
|
type="date"
|
||||||
|
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-500 mb-1">User handle</label>
|
||||||
|
<input
|
||||||
|
v-model="filters.user_handle"
|
||||||
|
type="text"
|
||||||
|
placeholder="All users"
|
||||||
|
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 w-36"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-500 mb-1">Action</label>
|
||||||
|
<select
|
||||||
|
v-model="filters.event_type"
|
||||||
|
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">All actions</option>
|
||||||
|
<option value="auth">Auth</option>
|
||||||
|
<option value="document">Document</option>
|
||||||
|
<option value="folder">Folder</option>
|
||||||
|
<option value="share">Share</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="applyFilters"
|
||||||
|
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Apply filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="activeFilterCount > 0"
|
||||||
|
@click="clearFilters"
|
||||||
|
class="border border-gray-300 text-gray-500 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
<div class="relative inline-flex flex-col items-start gap-1">
|
||||||
|
<button
|
||||||
|
@click="exportCsv"
|
||||||
|
:disabled="exportingCsv"
|
||||||
|
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span v-if="exportingCsv" class="flex items-center gap-1">
|
||||||
|
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
|
||||||
|
Exporting…
|
||||||
|
</span>
|
||||||
|
<span v-else>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="activeFilterCount > 0"
|
||||||
|
class="text-xs text-amber-600"
|
||||||
|
>
|
||||||
|
{{ activeFilterCount }} filter{{ activeFilterCount !== 1 ? 's' : '' }} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
|
||||||
|
</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 audit log…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fetch error state -->
|
||||||
|
<p v-else-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
|
||||||
|
No audit log entries match the selected filters.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<table class="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Timestamp</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">User</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Email</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Action Type</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">
|
||||||
|
<span v-if="entry.user_email">{{ entry.user_email }}</span>
|
||||||
|
<span v-else-if="entry.metadata_?.attempted_email_hash" class="text-amber-600 font-mono text-xs">hash:{{ entry.metadata_.attempted_email_hash }} <span class="text-xs not-italic">(attempted)</span></span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded-full font-medium"
|
||||||
|
:class="actionTypeClass(entry.event_type)"
|
||||||
|
>
|
||||||
|
{{ entry.event_type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="!loading && entries.length > 0" class="flex items-center justify-between mt-4">
|
||||||
|
<button
|
||||||
|
@click="prevPage"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-500">Page {{ page }}</span>
|
||||||
|
<button
|
||||||
|
@click="nextPage"
|
||||||
|
:disabled="page * perPage >= total"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily exports section (D-17, C-4) -->
|
||||||
|
<div class="border-t border-gray-100 mt-6 pt-6">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 mb-3">Daily exports</h3>
|
||||||
|
|
||||||
|
<p v-if="loadingExports" class="text-sm text-gray-400">Loading exports…</p>
|
||||||
|
|
||||||
|
<p v-else-if="dailyExports.length === 0" class="text-sm text-gray-400 italic">
|
||||||
|
No daily exports available.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="flex items-end gap-3">
|
||||||
|
<select
|
||||||
|
v-model="selectedExportDate"
|
||||||
|
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choose a date</option>
|
||||||
|
<option
|
||||||
|
v-for="exp in dailyExports"
|
||||||
|
:key="exp.date"
|
||||||
|
:value="exp.date"
|
||||||
|
>
|
||||||
|
{{ exp.date }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="downloadDailyExport"
|
||||||
|
:disabled="!selectedExportDate || downloadingExport"
|
||||||
|
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span v-if="downloadingExport" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="exportsError" class="text-xs text-red-600 mt-2">{{ exportsError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
|
||||||
|
const entries = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = 50
|
||||||
|
const loading = ref(false)
|
||||||
|
const fetchError = ref(null)
|
||||||
|
const exportingCsv = ref(false)
|
||||||
|
const exportError = ref(null)
|
||||||
|
|
||||||
|
// Daily exports state (D-17)
|
||||||
|
const dailyExports = ref([])
|
||||||
|
const loadingExports = ref(false)
|
||||||
|
const selectedExportDate = ref('')
|
||||||
|
const downloadingExport = ref(false)
|
||||||
|
const exportsError = ref(null)
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
start: '',
|
||||||
|
end: '',
|
||||||
|
user_handle: '',
|
||||||
|
event_type: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLog()
|
||||||
|
loadDailyExports()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchLog() {
|
||||||
|
loading.value = true
|
||||||
|
fetchError.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.adminListAuditLog({
|
||||||
|
start: filters.start || undefined,
|
||||||
|
end: filters.end || undefined,
|
||||||
|
user_handle: filters.user_handle || undefined,
|
||||||
|
event_type: filters.event_type || undefined,
|
||||||
|
page: page.value,
|
||||||
|
per_page: perPage,
|
||||||
|
})
|
||||||
|
entries.value = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
|
total.value = data.total ?? entries.value.length
|
||||||
|
} catch (e) {
|
||||||
|
entries.value = []
|
||||||
|
fetchError.value = 'Failed to load audit log. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
page.value = 1
|
||||||
|
fetchLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
filters.start = ''
|
||||||
|
filters.end = ''
|
||||||
|
filters.user_handle = ''
|
||||||
|
filters.event_type = ''
|
||||||
|
page.value = 1
|
||||||
|
fetchLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
if (filters.start) count++
|
||||||
|
if (filters.end) count++
|
||||||
|
if (filters.user_handle) count++
|
||||||
|
if (filters.event_type) count++
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
fetchLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (page.value * perPage < total.value) {
|
||||||
|
page.value++
|
||||||
|
fetchLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
exportingCsv.value = true
|
||||||
|
exportError.value = null
|
||||||
|
try {
|
||||||
|
await api.adminExportAuditLogCsv({
|
||||||
|
start: filters.start || undefined,
|
||||||
|
end: filters.end || undefined,
|
||||||
|
user_handle: filters.user_handle || undefined,
|
||||||
|
event_type: filters.event_type || undefined,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
exportError.value = 'Export failed. Please try again.'
|
||||||
|
} finally {
|
||||||
|
exportingCsv.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDailyExports() {
|
||||||
|
loadingExports.value = true
|
||||||
|
exportsError.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.adminListDailyExports()
|
||||||
|
dailyExports.value = data.items ?? []
|
||||||
|
} catch (e) {
|
||||||
|
dailyExports.value = []
|
||||||
|
exportsError.value = 'Failed to load daily exports. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loadingExports.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadDailyExport() {
|
||||||
|
if (!selectedExportDate.value) return
|
||||||
|
downloadingExport.value = true
|
||||||
|
exportsError.value = null
|
||||||
|
try {
|
||||||
|
await api.adminDownloadDailyExport(selectedExportDate.value)
|
||||||
|
} catch (e) {
|
||||||
|
exportsError.value = 'Download failed. Please try again.'
|
||||||
|
} finally {
|
||||||
|
downloadingExport.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
try {
|
||||||
|
return new Date(iso).toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTypeClass(eventType) {
|
||||||
|
if (!eventType) return 'bg-gray-100 text-gray-600'
|
||||||
|
const t = eventType.toLowerCase()
|
||||||
|
if (t.startsWith('auth')) return 'bg-blue-50 text-blue-600'
|
||||||
|
if (t.startsWith('document')) return 'bg-gray-100 text-gray-600'
|
||||||
|
if (t.startsWith('folder') || t.startsWith('share')) return 'bg-purple-50 text-purple-600'
|
||||||
|
if (t.startsWith('admin')) return 'bg-amber-50 text-amber-700'
|
||||||
|
return 'bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
</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,480 @@
|
|||||||
|
<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 { formatDate } from '../../utils/formatters.js'
|
||||||
|
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 upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||||
|
const lower = 'abcdefghijkmnpqrstuvwxyz'
|
||||||
|
const digits = '23456789'
|
||||||
|
const special = '!@#$%^&*'
|
||||||
|
const charset = upper + lower + digits + special // 64 chars — 256 % 64 === 0, no modulo bias
|
||||||
|
|
||||||
|
// Generate 16 random positions
|
||||||
|
const arr = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(arr)
|
||||||
|
const chars = Array.from(arr, byte => charset[byte % charset.length])
|
||||||
|
|
||||||
|
// Inject one guaranteed character from each required class at random positions
|
||||||
|
// using four additional random bytes to pick the injection positions.
|
||||||
|
const posArr = new Uint8Array(8)
|
||||||
|
crypto.getRandomValues(posArr)
|
||||||
|
const required = [
|
||||||
|
upper[posArr[0] % upper.length],
|
||||||
|
lower[posArr[1] % lower.length],
|
||||||
|
digits[posArr[2] % digits.length],
|
||||||
|
special[posArr[3] % special.length],
|
||||||
|
]
|
||||||
|
// Place each required char at a distinct position (0..3) in the array
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
chars[i] = required[i]
|
||||||
|
}
|
||||||
|
// Shuffle using Fisher-Yates with the last 4 random bytes as seeds
|
||||||
|
for (let i = chars.length - 1; i > 0; i--) {
|
||||||
|
const j = posArr[4 + (i % 4)] % (i + 1)
|
||||||
|
;[chars[i], chars[j]] = [chars[j], chars[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user