eaa3399ec0
- CLAUDE.md: add Code Standards section with backend and frontend shared module maps, component architecture rules, duplication checklist, and no-dead-code enforcement rule - SECURITY.md: Phase 02 + 03 security audit results (all threats CLOSED) - .planning: update milestone audit, config, and add plan/UAT files for phases 01, 02-06, and 06.2-05 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
405 lines
19 KiB
Markdown
405 lines
19 KiB
Markdown
---
|
|
phase: "06.2"
|
|
plan: "05"
|
|
type: execute
|
|
wave: 3
|
|
depends_on:
|
|
- "06.2-04"
|
|
files_modified:
|
|
- frontend/src/views/AccountView.vue
|
|
- frontend/src/components/admin/AdminUsersTab.vue
|
|
- frontend/src/views/CloudFolderView.vue
|
|
- frontend/src/components/admin/AuditLogTab.vue
|
|
autonomous: true
|
|
gap_closure: true
|
|
requirements:
|
|
- SHARE-03
|
|
- ADMIN-06
|
|
must_haves:
|
|
truths:
|
|
- "User can see their own @handle in Account settings — enabling them to share their handle with others for document sharing"
|
|
- "Admin can see each user's handle in the Users tab — enabling handle lookup for support and sharing"
|
|
- "Cloud folder browser shows an actionable error when no cloud connection exists — directing user to Settings"
|
|
- "Audit log entries display @alice style handles (@ prefix present)"
|
|
- "Export CSV button shows active filter count when filters are set — user understands export scope before clicking"
|
|
- "Clear filters button in Audit Log tab resets all filters and re-fetches unfiltered data"
|
|
artifacts:
|
|
- path: "frontend/src/views/AccountView.vue"
|
|
provides: "Handle row in Account information section"
|
|
contains: "authStore.user?.handle"
|
|
- path: "frontend/src/components/admin/AdminUsersTab.vue"
|
|
provides: "Handle column in users table"
|
|
contains: "user.handle"
|
|
- path: "frontend/src/views/CloudFolderView.vue"
|
|
provides: "Actionable no-connection error message"
|
|
contains: "Settings"
|
|
- path: "frontend/src/components/admin/AuditLogTab.vue"
|
|
provides: "@ prefix on handles, Clear filters button, active filter count indicator"
|
|
contains: "clearFilters"
|
|
key_links:
|
|
- from: "AccountView.vue"
|
|
to: "authStore.user"
|
|
via: "authStore.user?.handle (already present in /api/auth/me response)"
|
|
pattern: "authStore.user\\?.handle"
|
|
- from: "AdminUsersTab.vue user row"
|
|
to: "adminListUsers() response"
|
|
via: "user.handle (backend returns handle in GET /api/admin/users)"
|
|
pattern: "user\\.handle"
|
|
- from: "AuditLogTab.vue entry.user_handle"
|
|
to: "rendered cell"
|
|
via: "template expression with @ prefix"
|
|
pattern: "'@' \\+ entry.user_handle"
|
|
---
|
|
|
|
<objective>
|
|
Close the four UAT-diagnosed gaps from 06.2-UAT.md: (1) user handle invisible in account settings and admin user list, (2) cloud folder browser shows unhelpful error when no connection exists, (3) audit log handle entries missing @ prefix, (4) CSV export gives no indication of active filters and no way to clear them.
|
|
|
|
Purpose: These gaps block real usage — users cannot share documents because handles are invisible, cloud storage is unusable with no diagnostic guidance, audit logs look wrong without @ prefixes, and CSV exports silently export filtered (possibly empty) data.
|
|
|
|
Output: Four targeted frontend changes across four files. No backend changes required.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UAT.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@/Users/nik/Documents/Progamming/document_scanner/.planning/ROADMAP.md
|
|
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-CONTEXT.md
|
|
|
|
<interfaces>
|
|
<!-- Extracted from codebase — executor needs no further exploration. -->
|
|
|
|
From frontend/src/views/AccountView.vue (Account information section, lines 8-24):
|
|
<!-- Currently renders email and role only -->
|
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
|
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
|
<div class="space-y-2 text-sm text-gray-700">
|
|
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-gray-500">Role:</span>
|
|
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold" ...>
|
|
{{ authStore.user?.role }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<!-- authStore.user shape (from GET /api/auth/me): { id, email, handle, role, totp_enabled } -->
|
|
<!-- authStore.user?.handle is already available — just not rendered -->
|
|
|
|
From frontend/src/components/admin/AdminUsersTab.vue (table head, lines 113-119):
|
|
<thead>
|
|
<tr class="bg-gray-50 text-left">
|
|
<th ...>Email</th>
|
|
<th ...>Role</th>
|
|
<th ...>Status</th>
|
|
<th ...>Created</th>
|
|
<th ...>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<!-- user object shape returned by adminListUsers(): { id, handle, email, role, is_active, totp_enabled, created_at } -->
|
|
<!-- user.handle is present in the API response (confirmed: admin.py line 63 returns "handle") -->
|
|
|
|
From frontend/src/views/CloudFolderView.vue (load function, lines 126-137):
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root')
|
|
items.value = data.items ?? []
|
|
} catch (e) {
|
|
error.value = e.message || 'Failed to load folder contents'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
<!-- Error rendered in template (line 36-39): -->
|
|
<div v-else-if="error" class="text-sm text-red-500 py-8 text-center">
|
|
{{ error }}
|
|
<button @click="load" class="ml-2 text-indigo-600 hover:underline">Retry</button>
|
|
</div>
|
|
<!-- Backend returns 404 with detail "No active connection" when no cloud provider connected -->
|
|
<!-- getCloudFolders() throws; e.message is whatever the request() wrapper extracts from the response -->
|
|
|
|
From frontend/src/components/admin/AuditLogTab.vue (relevant section):
|
|
<!-- Line 95 — current handle cell (no @ prefix): -->
|
|
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
|
|
|
|
<!-- Filters reactive object (lines 188-193): -->
|
|
const filters = reactive({
|
|
start: '',
|
|
end: '',
|
|
user_handle: '',
|
|
event_type: '',
|
|
})
|
|
|
|
<!-- Filter bar (lines 4-62): Apply filters button at line 44, Export CSV button at line 51 -->
|
|
<!-- applyFilters() (line 220): resets page to 1, calls fetchLog() -->
|
|
<!-- No clearFilters() function exists yet -->
|
|
<!-- No active filter count indicator exists yet near Export CSV button -->
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Show handle in AccountView and AdminUsersTab</name>
|
|
<files>frontend/src/views/AccountView.vue, frontend/src/components/admin/AdminUsersTab.vue</files>
|
|
<action>
|
|
CHANGE 1 — frontend/src/views/AccountView.vue
|
|
|
|
In the "Account information" section (lines 8-24), add a Username row immediately after the Email row and before the Role row. The new row follows the same pattern as the Email row:
|
|
|
|
<div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div>
|
|
|
|
Place this line between the email div and the role div. The @ is a literal character prepended to the handle value so users immediately recognise it as their sharing handle. No script changes needed — authStore.user?.handle is already available.
|
|
|
|
CHANGE 2 — frontend/src/components/admin/AdminUsersTab.vue
|
|
|
|
Add a "Handle" column to the users table so admins can look up other users' handles.
|
|
|
|
In the `<thead>` row (after the Email th and before the Role th), add:
|
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Handle</th>
|
|
|
|
In the `<tbody>` rows (after the email `<td>` and before the role `<td>`), add:
|
|
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ user.handle ? '@' + user.handle : '—' }}</td>
|
|
|
|
No script changes needed — adminListUsers() already returns handle in the user object (confirmed in backend/api/admin.py line 63).
|
|
</action>
|
|
<verify>
|
|
<automated>grep -n "authStore.user?.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/AccountView.vue && grep -n "user\.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AdminUsersTab.vue | grep -v "handle:"</automated>
|
|
</verify>
|
|
<done>
|
|
- `grep "authStore.user?.handle" frontend/src/views/AccountView.vue` returns a match in the template section
|
|
- `grep "user\.handle" frontend/src/components/admin/AdminUsersTab.vue` returns a match in both thead and tbody
|
|
- `cd frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>"` returns no output
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Actionable cloud connection error and audit log @ prefix</name>
|
|
<files>frontend/src/views/CloudFolderView.vue, frontend/src/components/admin/AuditLogTab.vue</files>
|
|
<action>
|
|
CHANGE 1 — frontend/src/views/CloudFolderView.vue
|
|
|
|
Replace the generic error handler in the `load()` function with one that distinguishes "no connection" from a general error. The backend returns a response whose error detail contains "No active connection" (or HTTP 404) when no cloud provider is connected.
|
|
|
|
Replace the catch block in load():
|
|
|
|
} catch (e) {
|
|
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.'
|
|
}
|
|
}
|
|
|
|
Also update the error template block (lines 36-39) to add a Settings link. Replace the existing error div with:
|
|
|
|
<div v-else-if="error" class="text-sm text-red-500 py-8 text-center">
|
|
<p>{{ error }}</p>
|
|
<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>
|
|
|
|
Confirm `router-link` is usable here — `useRouter` and `useRoute` are already imported from 'vue-router' in the script setup.
|
|
|
|
CHANGE 2 — frontend/src/components/admin/AuditLogTab.vue
|
|
|
|
Change the user handle cell (line 95) from:
|
|
{{ entry.user_handle || entry.user_id || '—' }}
|
|
to:
|
|
{{ entry.user_handle ? '@' + entry.user_handle : (entry.user_id || '—') }}
|
|
|
|
This is a template-only one-liner change. No script changes required.
|
|
</action>
|
|
<verify>
|
|
<automated>grep -n "No cloud provider connected" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/CloudFolderView.vue && grep -n "'@' + entry.user_handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue</automated>
|
|
</verify>
|
|
<done>
|
|
- `grep "No cloud provider connected" frontend/src/views/CloudFolderView.vue` returns a match
|
|
- `grep "Go to Settings" frontend/src/views/CloudFolderView.vue` returns a match
|
|
- `grep "'@' + entry.user_handle" frontend/src/components/admin/AuditLogTab.vue` returns a match
|
|
- `grep "entry.user_handle || entry.user_id" frontend/src/components/admin/AuditLogTab.vue` returns NO match (old pattern gone)
|
|
- `cd frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>"` returns no output
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Clear filters button and active filter count indicator in AuditLogTab</name>
|
|
<files>frontend/src/components/admin/AuditLogTab.vue</files>
|
|
<action>
|
|
Add two UX improvements to AuditLogTab.vue to make the CSV export scope transparent.
|
|
|
|
CHANGE 1 — Add clearFilters() function in the script setup section:
|
|
|
|
Add the following function after the existing applyFilters() function:
|
|
|
|
function clearFilters() {
|
|
filters.start = ''
|
|
filters.end = ''
|
|
filters.user_handle = ''
|
|
filters.event_type = ''
|
|
page.value = 1
|
|
fetchLog()
|
|
}
|
|
|
|
Also add a computed property (or inline expression) for active filter count. Add this computed after the clearFilters() function:
|
|
|
|
import { computed } from 'vue' // add computed to the existing vue import if not present
|
|
|
|
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
|
|
})
|
|
|
|
NOTE: `computed` must be added to the existing `import { ref, reactive, onMounted }` line at the top of the script. Change it to `import { ref, reactive, onMounted, computed }`.
|
|
|
|
CHANGE 2 — Add "Clear filters" button to filter bar in the template:
|
|
|
|
In the filter bar (the `<div class="flex flex-wrap gap-3 mb-4 items-end">` block), add a "Clear filters" button immediately after the existing "Apply filters" button. Only show it when filters are active:
|
|
|
|
<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>
|
|
|
|
CHANGE 3 — Add active filter count indicator near Export CSV button:
|
|
|
|
Wrap the existing Export CSV button in a relative container and add a badge showing the active filter count when non-zero. Replace the standalone Export CSV button block with:
|
|
|
|
<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>
|
|
|
|
The amber text "N filter(s) active" sits directly below the Export CSV button so users see at a glance that the download will be scoped. The existing `<p v-if="exportError">` block remains unchanged immediately after this new wrapper div.
|
|
</action>
|
|
<verify>
|
|
<automated>grep -n "clearFilters\|activeFilterCount\|Clear filters\|filters active" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue | head -15</automated>
|
|
</verify>
|
|
<done>
|
|
- `grep "clearFilters" frontend/src/components/admin/AuditLogTab.vue` returns at least 2 matches (definition + @click binding)
|
|
- `grep "activeFilterCount" frontend/src/components/admin/AuditLogTab.vue` returns at least 3 matches (computed definition + v-if + template text)
|
|
- `grep "Clear filters" frontend/src/components/admin/AuditLogTab.vue` returns a match in the template
|
|
- `grep "filters active" frontend/src/components/admin/AuditLogTab.vue` returns a match
|
|
- `grep "computed" frontend/src/components/admin/AuditLogTab.vue` returns a match in the import line
|
|
- `cd frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>"` returns no output
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| AccountView → authStore.user | handle is read from in-memory Pinia store — never from localStorage; no user-supplied input involved |
|
|
| CloudFolderView → error message | error text originates from backend API response; rendered via Vue template auto-escaping (no innerHTML) — XSS risk mitigated |
|
|
| AuditLogTab → entry.user_handle | handle value from API response rendered via Vue template auto-escaping — no innerHTML |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-06.2-05-01 | Information Disclosure | Handle visible in AccountView | accept | Handle is already a public-within-platform identifier (used as share target); displaying it to the owning user is expected and correct |
|
|
| T-06.2-05-02 | Information Disclosure | Handle visible in AdminUsersTab | accept | Admin already has access to email; handle is lower-sensitivity than email; admin-only endpoint already enforces get_current_admin |
|
|
| T-06.2-05-03 | XSS | Cloud error message rendered from API response | mitigate | Vue template auto-escaping prevents XSS; the error string is interpolated via {{ }} not v-html — no raw HTML injection possible |
|
|
| T-06.2-05-04 | XSS | @ + entry.user_handle rendered in table | mitigate | String concatenation in Vue template expression is auto-escaped — not v-html |
|
|
| T-06.2-05-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan — frontend-only template and script changes only |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
After all three tasks complete:
|
|
|
|
Build check (no errors):
|
|
```
|
|
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>" | head -10
|
|
```
|
|
Expected: no output.
|
|
|
|
Gap 1 — Handle in AccountView:
|
|
```
|
|
grep -n "authStore.user?.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/AccountView.vue
|
|
```
|
|
Expected: match in template section.
|
|
|
|
Gap 1 — Handle in AdminUsersTab:
|
|
```
|
|
grep -n "user\.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AdminUsersTab.vue
|
|
```
|
|
Expected: at least 2 matches (thead + tbody).
|
|
|
|
Gap 2 — Cloud actionable error:
|
|
```
|
|
grep -n "No cloud provider connected\|Go to Settings" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/CloudFolderView.vue
|
|
```
|
|
Expected: 2 matches.
|
|
|
|
Gap 3 — Audit log @ prefix:
|
|
```
|
|
grep -n "'@' + entry.user_handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue
|
|
```
|
|
Expected: 1 match.
|
|
|
|
Gap 4 — Clear filters + filter count:
|
|
```
|
|
grep -c "clearFilters\|activeFilterCount" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue
|
|
```
|
|
Expected: 5 or more matches total.
|
|
|
|
Backend test suite unaffected (no backend changes):
|
|
```
|
|
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest -x -q 2>&1 | tail -5
|
|
```
|
|
Expected: exits 0, same pass count as before this plan.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Account settings page shows the user's own @handle in the Account information section
|
|
- Admin Users tab includes a Handle column showing @handle for every user row
|
|
- Cloud folder browser shows "No cloud provider connected. Go to Settings to connect a cloud storage account." (with a Settings link) when backend returns a no-connection error
|
|
- Audit log table renders @alice style handles (@ prefix present on all non-null handles)
|
|
- AuditLogTab has a "Clear filters" button (visible only when at least one filter is active) that resets all filters and re-fetches
|
|
- Export CSV button area shows "N filter(s) active" in amber text when one or more filters are set
|
|
- `npm run build` exits 0 with no errors
|
|
- Backend pytest suite still passes (no regressions — this plan touches only frontend files)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-05-SUMMARY.md` when done.
|
|
</output>
|