chore: merge executor worktree (worktree-agent-ad4015e9fb03e9447)
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
phase: "06.2"
|
||||||
|
plan: "05"
|
||||||
|
subsystem: "frontend"
|
||||||
|
tags: ["gap-closure", "ux", "handle-visibility", "audit-log", "cloud-storage", "csv-export"]
|
||||||
|
dependency_graph:
|
||||||
|
requires: ["06.2-04"]
|
||||||
|
provides: ["handle-visibility", "cloud-error-ux", "audit-log-prefixes", "filter-ux"]
|
||||||
|
affects: ["AccountView.vue", "AdminUsersTab.vue", "CloudFolderView.vue", "AuditLogTab.vue"]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: ["Vue 3 computed property", "router-link for Settings navigation"]
|
||||||
|
key_files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- "frontend/src/views/AccountView.vue"
|
||||||
|
- "frontend/src/components/admin/AdminUsersTab.vue"
|
||||||
|
- "frontend/src/views/CloudFolderView.vue"
|
||||||
|
- "frontend/src/components/admin/AuditLogTab.vue"
|
||||||
|
decisions:
|
||||||
|
- "@ prefix rendered as literal character in template (not from data) for XSS safety"
|
||||||
|
- "Cloud error detection uses lowercase includes for no active connection plus 404/not found fallback"
|
||||||
|
- "activeFilterCount as computed property (not inline expression) for reuse in two template locations"
|
||||||
|
metrics:
|
||||||
|
duration: "2m 8s"
|
||||||
|
completed: "2026-05-31"
|
||||||
|
tasks_completed: 3
|
||||||
|
tasks_total: 3
|
||||||
|
files_changed: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 06.2 Plan 05: Close Four UAT-Diagnosed UI Gaps Summary
|
||||||
|
|
||||||
|
**One-liner:** Four targeted frontend changes that make user handles visible, cloud storage errors actionable, audit log handles correctly prefixed with @, and CSV export scope transparent.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
Closed all four UAT-diagnosed UI gaps from 06.2-UAT.md with template-only and minimal script changes across four Vue components. No backend changes were required.
|
||||||
|
|
||||||
|
### Gap 1: Handle visibility (SHARE-03)
|
||||||
|
|
||||||
|
- **AccountView.vue**: Added "Username" row between Email and Role in the Account information section displaying `@{{ authStore.user?.handle }}` — users can now see and share their own handle
|
||||||
|
- **AdminUsersTab.vue**: Added "Handle" column (th + td) to the users table showing `@handle` or `—` — admins can look up users' handles for support and sharing
|
||||||
|
|
||||||
|
### Gap 2: Actionable cloud connection error (CloudFolderView)
|
||||||
|
|
||||||
|
- Updated `load()` catch block to detect "no active connection" / 404 / "not found" errors and replace the generic error message with: "No cloud provider connected. Go to Settings to connect a cloud storage account."
|
||||||
|
- Updated error template block to show a `router-link` to `/settings` (Go to Settings) plus a Retry button, replacing the single inline Retry button
|
||||||
|
|
||||||
|
### Gap 3: Audit log @ prefix (AuditLogTab)
|
||||||
|
|
||||||
|
- Changed the User column cell from `{{ entry.user_handle || entry.user_id || '—' }}` to `{{ entry.user_handle ? '@' + entry.user_handle : (entry.user_id || '—') }}` — entries now display @alice style handles
|
||||||
|
|
||||||
|
### Gap 4: Clear filters + active filter count (AuditLogTab)
|
||||||
|
|
||||||
|
- Added `clearFilters()` function that resets all four filter fields and re-fetches from page 1
|
||||||
|
- Added `activeFilterCount` computed property counting non-empty filter fields
|
||||||
|
- Added "Clear filters" button (v-if visible only when activeFilterCount > 0) after the Apply filters button
|
||||||
|
- Wrapped Export CSV button in a container that shows "N filter(s) active" in amber text below the button when any filters are set
|
||||||
|
- Added `computed` to the vue import
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Task | Description | Commit |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| 1 | Show @handle in AccountView and AdminUsersTab | 045e723 |
|
||||||
|
| 2 | Actionable cloud error + audit log @ prefix | f5e111b |
|
||||||
|
| 3 | Clear filters button and active filter count in AuditLogTab | 5d457d6 |
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
All plan verification checks pass:
|
||||||
|
|
||||||
|
```
|
||||||
|
Gap 1 - Handle in AccountView: line 12 match
|
||||||
|
Gap 1 - Handle in AdminUsersTab: lines 115 (th) + 133 (td)
|
||||||
|
Gap 2 - Cloud actionable error: 2 matches (error.value + Go to Settings link)
|
||||||
|
Gap 3 - Audit log @ prefix: line 110 match
|
||||||
|
Gap 4 - clearFilters|activeFilterCount: 6 matches (>= 5 required)
|
||||||
|
npm run build: exits 0 with no errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Threat Surface Scan
|
||||||
|
|
||||||
|
No new security-relevant surface introduced. All four changes render user-supplied data through Vue template auto-escaping (`{{ }}` not `v-html`). The `@ + entry.user_handle` concatenation in the template is auto-escaped. The error message from the cloud API response is similarly template-interpolated. No new network endpoints, auth paths, or schema changes introduced.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Files exist:
|
||||||
|
- frontend/src/views/AccountView.vue: FOUND (modified)
|
||||||
|
- frontend/src/components/admin/AdminUsersTab.vue: FOUND (modified)
|
||||||
|
- frontend/src/views/CloudFolderView.vue: FOUND (modified)
|
||||||
|
- frontend/src/components/admin/AuditLogTab.vue: FOUND (modified)
|
||||||
|
|
||||||
|
Commits verified:
|
||||||
|
- 045e723: feat(06.2-05): show @handle in AccountView and AdminUsersTab
|
||||||
|
- f5e111b: feat(06.2-05): actionable cloud error + audit log @ prefix
|
||||||
|
- 5d457d6: feat(06.2-05): clear filters button and active filter count in AuditLogTab
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 text-left">
|
<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">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">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">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">Created</th>
|
||||||
@@ -129,6 +130,7 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-gray-900">{{ user.email }}</td>
|
<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">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold"
|
class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold"
|
||||||
|
|||||||
@@ -48,16 +48,31 @@
|
|||||||
Apply filters
|
Apply filters
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="exportCsv"
|
v-if="activeFilterCount > 0"
|
||||||
:disabled="exportingCsv"
|
@click="clearFilters"
|
||||||
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"
|
class="border border-gray-300 text-gray-500 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<span v-if="exportingCsv" class="flex items-center gap-1">
|
Clear filters
|
||||||
<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>
|
</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>
|
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,7 +107,7 @@
|
|||||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
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 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-700">{{ entry.user_handle ? '@' + entry.user_handle : (entry.user_id || '—') }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-1 rounded-full font-medium"
|
class="text-xs px-2 py-1 rounded-full font-medium"
|
||||||
@@ -167,7 +182,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
|
|
||||||
const entries = ref([])
|
const entries = ref([])
|
||||||
@@ -222,6 +237,24 @@ function applyFilters() {
|
|||||||
fetchLog()
|
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() {
|
function prevPage() {
|
||||||
if (page.value > 1) {
|
if (page.value > 1) {
|
||||||
page.value--
|
page.value--
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
||||||
<div class="space-y-2 text-sm text-gray-700">
|
<div class="space-y-2 text-sm text-gray-700">
|
||||||
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
|
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
|
||||||
|
<div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-gray-500">Role:</span>
|
<span class="text-gray-500">Role:</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -34,8 +34,18 @@
|
|||||||
<div v-if="loading" class="text-sm text-gray-400 py-8 text-center">Loading…</div>
|
<div v-if="loading" class="text-sm text-gray-400 py-8 text-center">Loading…</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="text-sm text-red-500 py-8 text-center">
|
<div v-else-if="error" class="text-sm text-red-500 py-8 text-center">
|
||||||
{{ error }}
|
<p>{{ error }}</p>
|
||||||
<button @click="load" class="ml-2 text-indigo-600 hover:underline">Retry</button>
|
<div class="flex items-center justify-center gap-3 mt-2">
|
||||||
|
<router-link
|
||||||
|
to="/settings"
|
||||||
|
class="text-indigo-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
Go to Settings
|
||||||
|
</router-link>
|
||||||
|
<button @click="load" class="text-indigo-600 hover:underline text-sm">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -130,7 +140,12 @@ async function load() {
|
|||||||
const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root')
|
const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root')
|
||||||
items.value = data.items ?? []
|
items.value = data.items ?? []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message || 'Failed to load folder contents'
|
const msg = e.message || ''
|
||||||
|
if (msg.toLowerCase().includes('no active connection') || msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
||||||
|
error.value = 'No cloud provider connected. Go to Settings to connect a cloud storage account.'
|
||||||
|
} else {
|
||||||
|
error.value = msg || 'Failed to load folder contents.'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user