diff --git a/.planning/phases/12-cloud-resource-foundation/12-03-SUMMARY.md b/.planning/phases/12-cloud-resource-foundation/12-03-SUMMARY.md new file mode 100644 index 0000000..25c12ba --- /dev/null +++ b/.planning/phases/12-cloud-resource-foundation/12-03-SUMMARY.md @@ -0,0 +1,132 @@ +--- +phase: "12" +plan: "03" +subsystem: cloud-ui-foundation +tags: [cloud, routing, capabilities, breadcrumb, freshness, accessibility, session-storage] +dependency_graph: + requires: + - "12-02" # connection-ID browse API, CloudResourceAdapter, cloud_items metadata + provides: + - connection-ID routing (/cloud/:connectionId/:folderId) + - capability-aware action rendering (StorageBrowser) + - breadcrumb freshness indicators (BreadcrumbBar) + - session-only folder memory (cloudConnections store) + - display-name rename for cloud connections + affects: + - frontend/src/api/cloud.js + - frontend/src/stores/cloudConnections.js + - frontend/src/router/index.js + - frontend/src/views/CloudStorageView.vue + - frontend/src/views/CloudFolderView.vue + - frontend/src/components/settings/SettingsCloudTab.vue + - frontend/src/components/storage/StorageBrowser.vue + - frontend/src/components/ui/BreadcrumbBar.vue + - frontend/src/components/ui/AppIcon.vue + - frontend/src/utils/formatters.js + - backend/main.py +tech_stack: + added: + - CapabilityButton inline render-function component (StorageBrowser.vue) + - formatRelativeTime (formatters.js) + - saveLastFolder / loadLastFolder (cloudConnections.js session helpers) + patterns: + - capability-aware rendering (aria-disabled, not native disabled) + - session-storage for navigation state only (never tokens/credentials) + - connection UUID as routing identity (opaque, not provider slug) + - immediate watcher for freshness state transitions +key_files: + created: + - frontend/src/views/__tests__/CloudFolderView.test.js + - frontend/src/views/__tests__/CloudStorageView.test.js + - frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js + modified: + - frontend/src/api/cloud.js + - frontend/src/stores/cloudConnections.js + - frontend/src/stores/__tests__/cloudConnections.test.js + - frontend/src/router/index.js + - frontend/src/views/CloudStorageView.vue + - frontend/src/views/CloudFolderView.vue + - frontend/src/components/settings/SettingsCloudTab.vue + - frontend/src/components/settings/__tests__/SettingsCloudTab.test.js + - frontend/src/components/storage/StorageBrowser.vue + - frontend/src/components/ui/BreadcrumbBar.vue + - frontend/src/components/ui/__tests__/BreadcrumbBar.test.js + - frontend/src/components/ui/AppIcon.vue + - frontend/src/utils/formatters.js + - backend/main.py + - frontend/package.json + - AGENTS.md + - README.md +decisions: + - "Connection identity is the connection UUID, never a provider slug, in routes and API calls" + - "CapabilityButton uses aria-disabled (not native disabled) so controls remain keyboard-focusable" + - "Local mode uses full capability defaults — no mode==='local' gates remain in action rendering" + - "session-only folder memory stored under namespaced key docuvault:cloud:folder:{uuid}" + - "Fresh indicator fades after 3s using immediate watcher + fake-timer-tested setTimeout" + - "formatRelativeTime added to formatters.js — not duplicated inline" +metrics: + duration: "~45 minutes" + completed: "2026-06-18" + tasks: 3 + files: 18 +--- + +# Phase 12 Plan 03: Cloud UI Foundation Summary + +## One-liner + +Connection-UUID routing, CapabilityButton-based action rendering, and BreadcrumbBar freshness indicators unify cloud and local file navigation under one shared StorageBrowser. + +## What was built + +### Task 1: Route and store cloud navigation by connection identity + +- `api/cloud.js`: added `getCloudFoldersByConnectionId(connectionId, folderId)` and `renameCloudConnection(id, displayName)`; deprecated provider-keyed `getCloudFolders` +- `cloudConnections.js` store: added `rename`, `selectConnection`, `setBrowseState`, `defaultDisplayName` (4-char UUID suffix for duplicate same-provider connections), and `saveLastFolder`/`loadLastFolder` session helpers +- Router: `/cloud/:provider/:folderId` → `/cloud/:connectionId/:folderId` +- `CloudStorageView`: thin data provider — maps connections to folder-like items and passes them to `StorageBrowser`; no parallel grid markup +- `CloudFolderView`: thin data provider — uses `connectionId` param, calls `getCloudFoldersByConnectionId`, saves last folder in sessionStorage on navigate +- `SettingsCloudTab`: added inline rename input per ACTIVE connection with Rename / Save / Cancel flow + +### Task 2: Replace mode-gated actions with accessible capability rendering + +- `StorageBrowser`: removed all `mode === 'local'` action visibility gates +- Added props: `capabilities`, `connectionRoot`, `folderFreshness`, `lastRefreshedAt`, `byteAvailability` +- `LOCAL_CAPS` constant provides full-supported defaults so local mode unchanged +- `CapabilityButton` inline component: `aria-disabled="true"` (never native `disabled`), click/Enter/Space/touchend all suppressed for non-supported states; emits `explain` → `capability-explain` event and shows inline dismissible notice +- Gray styling for `unsupported`; amber for `temporarily_unavailable` +- Cached-byte marker (`clock` icon) on size column when `byteAvailability === 'cached'`; no marker for `cloud_only` +- `AppIcon`: added `info` and `clock` icons + +### Task 3: Breadcrumb freshness and plan protocol + +- `BreadcrumbBar`: optional `folderFreshness` and `lastRefreshedAt` props; refreshing spinner (animated SVG), fresh checkmark (fades 3s via immediate watcher), persistent stale warning banner +- All freshness states have `role="status"` and `aria-label` for screen readers +- `formatters.js`: `formatRelativeTime` — human-readable relative time, no inline duplication +- Version bumped 0.1.5 → 0.1.6 in `backend/main.py` and `frontend/package.json` +- `AGENTS.md` and `README.md` updated with new state, shared module map, and feature text + +## Test results + +- 323 frontend tests pass (39 test files) +- Production build: `✓ built in 510ms` + +## Deviations from Plan + +None — plan executed exactly as written. + +## Threat surface scan + +No new network endpoints introduced by this plan. Frontend adds: + +| Flag | File | Description | +|------|------|-------------| +| threat_flag: sessionStorage-check | cloudConnections.js | `saveLastFolder` stores only folder path string under namespaced key; negative test asserts value is not `{` (not an object with credentials) | + +## Known Stubs + +None — StorageBrowser capability props are connected through CloudFolderView; default `LOCAL_CAPS` wires all local actions. + +## Self-Check: PASSED + +All key files found. All commits verified (bf9af11, 4424433, c6c0742). 323 tests pass. Production build clean. diff --git a/AGENTS.md b/AGENTS.md index 12f961f..4708f3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV). -**Current state:** v0.1.5 — Phase 12 Plan 02 complete (2026-06-18). Connection-ID browse API (`GET /api/cloud/connections/{connection_id}/items`) with owner-scoped IDOR protection, stale-while-revalidate caching, and durable `refresh_cloud_folder` Celery task. All 4 providers implement `CloudResourceAdapter`. Not cleared for public internet deployment. +**Current state:** v0.1.6 — Phase 12 Plan 03 complete (2026-06-18). Connection-ID routing, capability-aware action rendering, breadcrumb freshness indicators. Cloud and local files share one StorageBrowser row/action implementation. Not cleared for public internet deployment. ## Stack @@ -59,7 +59,7 @@ Before adding a helper, check if it belongs in an existing shared module: | Module | What lives here | |---|---| -| `src/utils/formatters.js` | `formatDate`, `formatSize`, `providerColor`, `providerBg`, `providerLabel` | +| `src/utils/formatters.js` | `formatDate`, `formatSize`, `formatRelativeTime`, `providerColor`, `providerBg`, `providerLabel` | | `src/components/ui/TreeItem.vue` | Generic expand/collapse tree node — all sidebar tree items wrap this | | `src/components/storage/StorageBrowser.vue` | Unified file browser grid — used by both `FileManagerView` and `CloudFolderView` | @@ -123,9 +123,9 @@ This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts /gsd:progress — check status and advance workflow ``` -### Current state: v0.1.5 — Phase 12 Plan 02 complete (2026-06-18) +### Current state: v0.1.6 — Phase 12 Plan 03 complete (2026-06-18) -Phase 12 Plan 02: connection-ID browse endpoint, `api/cloud/` package decomposition, `CloudResourceAdapter` mixin on all 4 providers, and `refresh_cloud_folder` Celery task with bounded retry. Stale-while-revalidate caching active. The app is functional but alpha-quality — not cleared for public internet deployment. +Phase 12 Plan 03: connection-ID routing (`/cloud/:connectionId`), capability-aware action rendering in StorageBrowser, breadcrumb freshness indicators (refreshing/fresh/stale), session-only folder memory, and display-name rename for cloud connections. Cloud and local files share one row/action implementation via `CapabilityButton`. The app is functional but alpha-quality — not cleared for public internet deployment. ## Development Setup diff --git a/README.md b/README.md index 46946ac..ac9037b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DocuVault -**Version 0.1.5 — Alpha** +**Version 0.1.6 — Alpha** > **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared. @@ -17,6 +17,8 @@ A self-hosted, multi-user document management platform with AI-powered topic cla - **Document sharing** — share by user handle with view or edit permission; "Shared with me" virtual folder; per-recipient revocation - **Storage quota** — per-user limit enforced atomically; amber/red quota bar at 80 % / 95 %; quota decremented on delete - **Cloud storage backends** — connect OneDrive, Google Drive, Nextcloud, or any WebDAV server as a personal storage backend; credentials encrypted with HKDF per-user keys +- **Unified connection-root browsing** — each connected account appears as a distinct top-level root navigable by connection UUID; duplicate same-provider accounts are disambiguated automatically; local and cloud files share one row and action implementation +- **Capability-aware actions** — unsupported cloud actions render gray with accessible explanations; temporarily unavailable actions render amber; all remain keyboard-focusable - **Auth** — email/password registration with HIBP breach check, optional TOTP 2FA, 8–10 backup codes, password reset by email, sign-out-all-devices - **Admin panel** — user CRUD, quota assignment, per-user AI provider override, AI provider system configuration, audit log viewer with CSV export - **Observability** — structured JSON logging via structlog, per-request correlation IDs, Loki + Promtail + Grafana stack included in Docker Compose diff --git a/backend/main.py b/backend/main.py index afe566f..2f3c743 100644 --- a/backend/main.py +++ b/backend/main.py @@ -244,7 +244,7 @@ async def lifespan(app: FastAPI): # ── Application factory ─────────────────────────────────────────────────────── -app = FastAPI(title="Document Scanner API", version="0.1.5", lifespan=lifespan) +app = FastAPI(title="Document Scanner API", version="0.1.6", lifespan=lifespan) # Rate limiter state (slowapi) app.state.limiter = auth_limiter diff --git a/frontend/package.json b/frontend/package.json index 763034f..d7ee56c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "document-scanner-frontend", - "version": "0.1.5", + "version": "0.1.6", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/cloud.js b/frontend/src/api/cloud.js index 7cf83d2..8582b31 100644 --- a/frontend/src/api/cloud.js +++ b/frontend/src/api/cloud.js @@ -29,10 +29,26 @@ export function updateDefaultStorage(backend) { return jsonRequest('/api/users/me/default-storage', 'PATCH', { backend }) } +/** + * Browse a cloud folder by connection UUID and folder path. + * connectionId is always a UUID; folderId is a path string (default 'root'). + */ +export function getCloudFoldersByConnectionId(connectionId, folderId) { + return request(`/api/cloud/connections/${connectionId}/folders/${folderId}`) +} + +/** @deprecated Use getCloudFoldersByConnectionId instead. */ export function getCloudFolders(provider, folderId) { return request(`/api/cloud/folders/${provider}/${folderId}`) } +/** + * Rename a cloud connection's display name. + */ +export function renameCloudConnection(id, displayName) { + return jsonRequest(`/api/cloud/connections/${id}`, 'PATCH', { display_name: displayName }) +} + /** * Initiate OAuth flow for Google Drive or OneDrive. * diff --git a/frontend/src/components/settings/SettingsCloudTab.vue b/frontend/src/components/settings/SettingsCloudTab.vue index 5fe3b31..46cb644 100644 --- a/frontend/src/components/settings/SettingsCloudTab.vue +++ b/frontend/src/components/settings/SettingsCloudTab.vue @@ -60,8 +60,33 @@ + + +
  • + + + + Refreshing + + + + + + + Up to date + + + + + +
  • + + + + + diff --git a/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js b/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js index 9d9b6be..deaf9f0 100644 --- a/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js +++ b/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' import BreadcrumbBar from '../BreadcrumbBar.vue' const STUBS = { global: { stubs: { AppIcon: true } } } @@ -114,3 +115,83 @@ describe('BreadcrumbBar', () => { expect(appIcons.length).toBeGreaterThanOrEqual(1) }) }) + +describe('BreadcrumbBar freshness indicator', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('shows refreshing spinner with accessible label when folderFreshness="refreshing"', () => { + const w = mount(BreadcrumbBar, { + props: { segments: [], folderFreshness: 'refreshing' }, + ...STUBS, + }) + const indicator = w.find('[data-freshness="refreshing"]') + expect(indicator.exists()).toBe(true) + expect(indicator.attributes('aria-label')).toBe('Refreshing folder contents') + }) + + it('shows fresh checkmark with accessible label when folderFreshness="fresh"', async () => { + const w = mount(BreadcrumbBar, { + props: { segments: [], folderFreshness: 'fresh' }, + ...STUBS, + }) + // Wait for watcher to update showFreshMark + await nextTick() + const fresh = w.find('[data-freshness="fresh"]') + expect(fresh.exists()).toBe(true) + expect(fresh.attributes('aria-label')).toBe('Folder contents are up to date') + }) + + it('fresh indicator fades after 3 seconds', async () => { + const w = mount(BreadcrumbBar, { + props: { segments: [], folderFreshness: 'fresh' }, + ...STUBS, + }) + await nextTick() + expect(w.vm.showFreshMark).toBe(true) + // Advance timer by 3s + vi.advanceTimersByTime(3001) + await nextTick() + expect(w.vm.showFreshMark).toBe(false) + }) + + it('warning indicator persists when folderFreshness="stale"', async () => { + const w = mount(BreadcrumbBar, { + props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' }, + ...STUBS, + }) + const stale = w.find('[data-freshness="stale"]') + expect(stale.exists()).toBe(true) + }) + + it('stale warning banner is shown with last-update info', async () => { + const w = mount(BreadcrumbBar, { + props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' }, + ...STUBS, + }) + const banner = w.find('[data-test="stale-warning-banner"]') + expect(banner.exists()).toBe(true) + expect(banner.text()).toContain('Could not refresh folder contents') + }) + + it('no freshness indicator shown when folderFreshness is null', () => { + const w = mount(BreadcrumbBar, { + props: { segments: [], folderFreshness: null }, + ...STUBS, + }) + expect(w.find('[data-test="freshness-indicator"]').exists()).toBe(false) + }) + + it('freshness indicator role="status" for accessible announcement', async () => { + const w = mount(BreadcrumbBar, { + props: { segments: [], folderFreshness: 'refreshing' }, + ...STUBS, + }) + const indicator = w.find('[data-freshness="refreshing"]') + expect(indicator.attributes('role')).toBe('status') + }) +}) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ab183f3..d5f88e0 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -62,7 +62,7 @@ const routes = [ meta: { requiresAuth: true }, }, { - path: '/cloud/:provider/:folderId(.*)', + path: '/cloud/:connectionId/:folderId(.*)', name: 'cloud-folder', component: () => import('../views/CloudFolderView.vue'), meta: { requiresAuth: true }, diff --git a/frontend/src/stores/__tests__/cloudConnections.test.js b/frontend/src/stores/__tests__/cloudConnections.test.js index 94ba165..bac5f65 100644 --- a/frontend/src/stores/__tests__/cloudConnections.test.js +++ b/frontend/src/stores/__tests__/cloudConnections.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' // Mock api/client.js — no real HTTP calls in unit tests (CLAUDE.md W4) @@ -7,14 +7,22 @@ vi.mock('../../api/client.js', () => ({ disconnectCloud: vi.fn(), connectWebDav: vi.fn(), updateDefaultStorage: vi.fn(), + renameCloudConnection: vi.fn(), + getCloudFoldersByConnectionId: vi.fn(), })) -import { useCloudConnectionsStore } from '../cloudConnections.js' +import { useCloudConnectionsStore, saveLastFolder, loadLastFolder } from '../cloudConnections.js' import * as api from '../../api/client.js' beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() + // Clear sessionStorage between tests + sessionStorage.clear() +}) + +afterEach(() => { + sessionStorage.clear() }) describe('useCloudConnectionsStore', () => { @@ -56,4 +64,95 @@ describe('useCloudConnectionsStore', () => { await store.disconnectAll() expect(store.connections).toHaveLength(0) }) + + // Rename tests + it('rename updates the connection in state on success', async () => { + api.renameCloudConnection.mockResolvedValue({ + id: 'conn-1', provider: 'google_drive', display_name: 'My Drive', + }) + const store = useCloudConnectionsStore() + store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }] + await store.rename('conn-1', 'My Drive') + expect(store.connections[0].display_name).toBe('My Drive') + expect(api.renameCloudConnection).toHaveBeenCalledWith('conn-1', 'My Drive') + }) + + it('rename rejects on empty display name', async () => { + const store = useCloudConnectionsStore() + await expect(store.rename('conn-1', '')).rejects.toThrow() + }) + + it('rename rejects on whitespace-only display name', async () => { + const store = useCloudConnectionsStore() + await expect(store.rename('conn-1', ' ')).rejects.toThrow() + }) + + it('rename propagates API error', async () => { + api.renameCloudConnection.mockRejectedValue(new Error('API error')) + const store = useCloudConnectionsStore() + store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }] + await expect(store.rename('conn-1', 'New Name')).rejects.toThrow('API error') + }) + + // Two same-provider connections render as separate roots + it('two google_drive connections render as separate roots with UUID suffixes in defaultDisplayName', () => { + const store = useCloudConnectionsStore() + store.connections = [ + { id: 'aabb-ccdd-1', provider: 'google_drive', display_name: null }, + { id: 'eeff-gggg-2', provider: 'google_drive', display_name: null }, + ] + const nameA = store.defaultDisplayName(store.connections[0]) + const nameB = store.defaultDisplayName(store.connections[1]) + // Both should contain "Google Drive" and a 4-char suffix + expect(nameA).toContain('Google Drive') + expect(nameB).toContain('Google Drive') + expect(nameA).toContain('aabb') + expect(nameB).toContain('eeff') + }) + + it('single connection has no suffix in defaultDisplayName', () => { + const store = useCloudConnectionsStore() + store.connections = [{ id: 'conn-1', provider: 'google_drive' }] + expect(store.defaultDisplayName(store.connections[0])).toBe('Google Drive') + }) + + // Connection UUID based URLs + it('selectConnection sets selectedConnectionId', () => { + const store = useCloudConnectionsStore() + store.connections = [{ id: 'uuid-123', provider: 'onedrive' }] + store.selectConnection('uuid-123') + expect(store.selectedConnectionId).toBe('uuid-123') + expect(store.selectedConnection?.id).toBe('uuid-123') + }) + + // Session storage for folder navigation + it('saveLastFolder stores folder in sessionStorage', () => { + saveLastFolder('conn-abc', 'Documents/Work') + expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBe('Documents/Work') + }) + + it('loadLastFolder retrieves stored folder', () => { + sessionStorage.setItem('docuvault:cloud:folder:conn-xyz', 'Photos') + expect(loadLastFolder('conn-xyz')).toBe('Photos') + }) + + it('saveLastFolder removes key for root folder', () => { + sessionStorage.setItem('docuvault:cloud:folder:conn-abc', 'Docs') + saveLastFolder('conn-abc', 'root') + expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBeNull() + }) + + it('loadLastFolder returns null when nothing stored', () => { + expect(loadLastFolder('nonexistent')).toBeNull() + }) + + it('sessionStorage stores only folder references, not tokens', () => { + // Ensure save only stores the folderId string under the namespaced key + saveLastFolder('conn-123', 'Projects/Secret') + const stored = sessionStorage.getItem('docuvault:cloud:folder:conn-123') + expect(stored).toBe('Projects/Secret') + // Stored value is a plain string path, not a JSON object with credentials + expect(typeof stored).toBe('string') + expect(stored.startsWith('{')).toBe(false) + }) }) diff --git a/frontend/src/stores/cloudConnections.js b/frontend/src/stores/cloudConnections.js index fb39a41..1054e16 100644 --- a/frontend/src/stores/cloudConnections.js +++ b/frontend/src/stores/cloudConnections.js @@ -1,12 +1,57 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, computed } from 'vue' import * as api from '../api/client.js' +/** + * Session-storage key for last visited folder per connection. + * Only folder navigation state is stored — never tokens or credentials. + */ +function folderSessionKey(connectionId) { + return `docuvault:cloud:folder:${connectionId}` +} + +export function saveLastFolder(connectionId, folderId) { + try { + if (folderId && folderId !== 'root') { + sessionStorage.setItem(folderSessionKey(connectionId), folderId) + } else { + sessionStorage.removeItem(folderSessionKey(connectionId)) + } + } catch { + // sessionStorage unavailable — ignore + } +} + +export function loadLastFolder(connectionId) { + try { + return sessionStorage.getItem(folderSessionKey(connectionId)) || null + } catch { + return null + } +} + export const useCloudConnectionsStore = defineStore('cloudConnections', () => { const connections = ref([]) const loading = ref(false) const error = ref(null) + // Active browse state (populated by CloudFolderView) + const selectedConnectionId = ref(null) + const browseItems = ref([]) + const browseLoading = ref(false) + const browseError = ref(null) + /** Capabilities for the currently selected connection */ + const capabilities = ref(null) + /** 'refreshing' | 'fresh' | 'stale' | null */ + const folderFreshness = ref(null) + const lastRefreshedAt = ref(null) + /** 'cached' | 'cloud_only' | null */ + const byteAvailability = ref(null) + + const selectedConnection = computed(() => + connections.value.find(c => c.id === selectedConnectionId.value) ?? null + ) + async function fetchConnections() { loading.value = true error.value = null @@ -24,6 +69,9 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => { try { await api.disconnectCloud(id) connections.value = connections.value.filter(c => c.id !== id) + if (selectedConnectionId.value === id) { + selectedConnectionId.value = null + } } catch (e) { throw e } @@ -35,5 +83,79 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => { connections.value = [] } - return { connections, loading, error, fetchConnections, disconnect, disconnectAll } + /** + * Rename a connection's display_name via the API. + * Only updates the single returned connection in state. + */ + async function rename(id, displayName) { + const trimmed = displayName?.trim() + if (!trimmed) throw new Error('Display name cannot be empty') + const updated = await api.renameCloudConnection(id, trimmed) + const idx = connections.value.findIndex(c => c.id === id) + if (idx !== -1) { + connections.value[idx] = { ...connections.value[idx], ...updated } + } + return updated + } + + /** + * Default display_name for a connection. + * If another connection shares the same provider, append a 4-char UUID suffix. + */ + function defaultDisplayName(connection) { + const providerLabel = { + google_drive: 'Google Drive', + onedrive: 'OneDrive', + nextcloud: 'Nextcloud', + webdav: 'WebDAV', + }[connection.provider] ?? connection.provider + const siblings = connections.value.filter( + c => c.provider === connection.provider && c.id !== connection.id + ) + if (siblings.length > 0) { + return `${providerLabel} (${connection.id.slice(0, 4)})` + } + return providerLabel + } + + function selectConnection(id) { + selectedConnectionId.value = id + browseItems.value = [] + browseError.value = null + capabilities.value = null + folderFreshness.value = null + lastRefreshedAt.value = null + byteAvailability.value = null + } + + function setBrowseState({ items, caps, freshness, refreshedAt, byteAvail, err }) { + if (err !== undefined) browseError.value = err + if (items !== undefined) browseItems.value = items + if (caps !== undefined) capabilities.value = caps + if (freshness !== undefined) folderFreshness.value = freshness + if (refreshedAt !== undefined) lastRefreshedAt.value = refreshedAt + if (byteAvail !== undefined) byteAvailability.value = byteAvail + } + + return { + connections, + loading, + error, + selectedConnectionId, + selectedConnection, + browseItems, + browseLoading, + browseError, + capabilities, + folderFreshness, + lastRefreshedAt, + byteAvailability, + fetchConnections, + disconnect, + disconnectAll, + rename, + defaultDisplayName, + selectConnection, + setBrowseState, + } }) diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index cc0135c..f8cd6a6 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -50,6 +50,23 @@ export function providerBg(provider) { return map[provider] ?? 'bg-gray-50' } +/** + * Returns a human-readable relative time string for an ISO timestamp. + * e.g. "just now", "2 minutes ago", "1 hour ago", "3 days ago" + */ +export function formatRelativeTime(iso) { + if (!iso) return '' + const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + if (diff < 10) return 'just now' + if (diff < 60) return `${diff} seconds ago` + const mins = Math.floor(diff / 60) + if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago` + const days = Math.floor(hrs / 24) + return days === 1 ? '1 day ago' : `${days} days ago` +} + /** Human-readable label for a cloud provider slug. */ export function providerLabel(provider) { const map = { diff --git a/frontend/src/views/CloudFolderView.vue b/frontend/src/views/CloudFolderView.vue index 6c9351d..39c6152 100644 --- a/frontend/src/views/CloudFolderView.vue +++ b/frontend/src/views/CloudFolderView.vue @@ -7,7 +7,12 @@ :upload-queue="uploadQueue" :loading="loading" :empty-message="error || 'This folder is empty'" - :empty-hint="error ? '' : 'Files stored here are managed by ' + providerLabel(provider)" + :empty-hint="error ? '' : 'Files stored here are managed by your cloud provider'" + :capabilities="cloudStore.capabilities" + :connection-root="connectionRoot" + :folder-freshness="cloudStore.folderFreshness" + :last-refreshed-at="cloudStore.lastRefreshedAt" + :byte-availability="cloudStore.byteAvailability" @breadcrumb-navigate="handleBreadcrumbNavigate" @upload="onFilesSelected" @folder-navigate="item => navigateTo(item)" @@ -20,25 +25,37 @@ import { ref, computed, watch, onMounted, reactive } from 'vue' import { useRoute, useRouter } from 'vue-router' import * as api from '../api/client.js' import StorageBrowser from '../components/storage/StorageBrowser.vue' -import { providerLabel } from '../utils/formatters.js' import { useToastStore } from '../stores/toast.js' +import { useCloudConnectionsStore } from '../stores/cloudConnections.js' +import { saveLastFolder, loadLastFolder } from '../stores/cloudConnections.js' const route = useRoute() const router = useRouter() - const toast = useToastStore() +const cloudStore = useCloudConnectionsStore() const items = ref([]) const loading = ref(true) const error = ref('') +const uploadQueue = ref([]) -const provider = computed(() => route.params.provider) +/** Connection UUID from route — never uses provider slug */ +const connectionId = computed(() => route.params.connectionId) const folderId = computed(() => route.params.folderId) const folders = computed(() => items.value.filter(i => i.is_dir)) const files = computed(() => items.value.filter(i => !i.is_dir)) -/** Breadcrumb built from the folder path segments. */ +const connectionRoot = computed(() => { + const conn = cloudStore.connections.find(c => c.id === connectionId.value) + if (!conn) return null + return { + id: conn.id, + name: conn.display_name || cloudStore.defaultDisplayName(conn), + provider: conn.provider, + } +}) + const breadcrumb = computed(() => { if (!folderId.value || folderId.value === 'root') return [] const parts = folderId.value.replace(/\/$/, '').split('/') @@ -55,39 +72,54 @@ const mappedBreadcrumb = computed(() => async function load() { loading.value = true error.value = '' + cloudStore.setBrowseState({ freshness: 'refreshing' }) try { - const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root') + const data = await api.getCloudFoldersByConnectionId( + connectionId.value, + folderId.value ?? 'root' + ) items.value = data.items ?? [] + cloudStore.setBrowseState({ + items: items.value, + caps: data.capabilities ?? null, + freshness: 'fresh', + refreshedAt: new Date().toISOString(), + byteAvail: data.byte_availability ?? null, + err: null, + }) } catch (e) { const msg = e.message || '' - if (msg.toLowerCase().includes('no active connection') || msg.includes('404') || msg.toLowerCase().includes('not found')) { + 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.' } + cloudStore.setBrowseState({ freshness: 'stale', err: error.value }) } finally { loading.value = false } + // Save last folder in sessionStorage (folder refs only — no tokens/credentials) + saveLastFolder(connectionId.value, folderId.value) } function navigateTo(item) { - router.push(`/cloud/${provider.value}/${item.id}`) + router.push(`/cloud/${connectionId.value}/${item.id}`) } function handleBreadcrumbNavigate(id) { - if (id == null) router.push(`/cloud/${provider.value}/root`) - else router.push(`/cloud/${provider.value}/${id}`) + if (id == null) router.push(`/cloud/${connectionId.value}/root`) + else router.push(`/cloud/${connectionId.value}/${id}`) } -// ── Upload ──────────────────────────────────────────────────────────────────── - -const uploadQueue = ref([]) - async function onFilesSelected({ files: selectedFiles }) { const promises = selectedFiles.map(file => { const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' }) uploadQueue.value.unshift(item) - return api.uploadToCloud(file, provider.value, folderId.value || null) + return api.uploadToCloud(file, connectionId.value, folderId.value || null) .then(() => { item.done = true; item.status = null }) .catch(e => { item.error = e.message || 'Upload failed' }) }) @@ -99,6 +131,27 @@ function onFileOpen(file) { toast.show(`"${file.name}" can't be opened here — open it directly in your cloud storage provider.`, 'info') } -onMounted(load) -watch([provider, folderId], load) +onMounted(async () => { + // Ensure connections are loaded so connectionRoot resolves + if (cloudStore.connections.length === 0) { + await cloudStore.fetchConnections() + } + cloudStore.selectConnection(connectionId.value) + + // Resume last folder if entering root during the same session + const isRoot = !folderId.value || folderId.value === 'root' + if (isRoot) { + const last = loadLastFolder(connectionId.value) + if (last) { + router.replace(`/cloud/${connectionId.value}/${last}`) + return + } + } + load() +}) + +watch([connectionId, folderId], () => { + cloudStore.selectConnection(connectionId.value) + load() +}) diff --git a/frontend/src/views/CloudStorageView.vue b/frontend/src/views/CloudStorageView.vue index 18459d5..d618313 100644 --- a/frontend/src/views/CloudStorageView.vue +++ b/frontend/src/views/CloudStorageView.vue @@ -1,83 +1,50 @@ diff --git a/frontend/src/views/__tests__/CloudFolderView.test.js b/frontend/src/views/__tests__/CloudFolderView.test.js new file mode 100644 index 0000000..00cd73a --- /dev/null +++ b/frontend/src/views/__tests__/CloudFolderView.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +// Use connection UUID, never provider slug +const mockPush = vi.fn() +const mockReplace = vi.fn() + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush, replace: mockReplace }), + useRoute: () => ({ + params: { connectionId: 'uuid-conn-1', folderId: 'root' }, + query: {}, + }), +})) + +const mockFetchConnections = vi.fn().mockResolvedValue(undefined) +const mockSelectConnection = vi.fn() +const mockSetBrowseState = vi.fn() + +vi.mock('../../stores/cloudConnections.js', () => ({ + useCloudConnectionsStore: () => ({ + connections: [{ id: 'uuid-conn-1', provider: 'google_drive', display_name: 'My Drive' }], + loading: false, + capabilities: null, + folderFreshness: null, + lastRefreshedAt: null, + byteAvailability: null, + fetchConnections: mockFetchConnections, + selectConnection: mockSelectConnection, + setBrowseState: mockSetBrowseState, + defaultDisplayName: (c) => c.provider, + }), + saveLastFolder: vi.fn(), + loadLastFolder: vi.fn(() => null), +})) + +vi.mock('../../api/client.js', () => ({ + getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }), + uploadToCloud: vi.fn(), + listCloudConnections: vi.fn().mockResolvedValue({ items: [] }), +})) + +vi.mock('../../stores/toast.js', () => ({ + useToastStore: () => ({ show: vi.fn() }), +})) + +import CloudFolderView from '../CloudFolderView.vue' +import * as api from '../../api/client.js' + +const globalStubs = { + StorageBrowser: { + template: '
    ', + props: [ + 'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading', + 'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot', + 'folderFreshness', 'lastRefreshedAt', 'byteAvailability', + ], + emits: ['breadcrumb-navigate', 'upload', 'folder-navigate', 'file-open'], + }, +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + sessionStorage.clear() +}) + +afterEach(() => { + sessionStorage.clear() +}) + +describe('CloudFolderView', () => { + it('calls getCloudFoldersByConnectionId with connection UUID, never provider slug', async () => { + const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) + await flushPromises() + expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', 'root') + // Never called with provider slug + expect(api.getCloudFoldersByConnectionId).not.toHaveBeenCalledWith('google_drive', expect.anything()) + }) + + it('renders StorageBrowser (no parallel file grid)', () => { + const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) + expect(w.find('[data-test="storage-browser"]').exists()).toBe(true) + // No grid markup in view itself + expect(w.findAll('table').length).toBe(0) + }) + + it('passes connectionId to store selectConnection', async () => { + const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) + await flushPromises() + expect(mockSelectConnection).toHaveBeenCalledWith('uuid-conn-1') + }) + + it('fresh session at root does not redirect when no stored folder', async () => { + const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) + await flushPromises() + expect(mockReplace).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/views/__tests__/CloudStorageView.test.js b/frontend/src/views/__tests__/CloudStorageView.test.js new file mode 100644 index 0000000..b1104fd --- /dev/null +++ b/frontend/src/views/__tests__/CloudStorageView.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('../../stores/cloudConnections.js', () => ({ + useCloudConnectionsStore: () => ({ + connections: [], + loading: false, + error: null, + fetchConnections: vi.fn(), + defaultDisplayName: (c) => c.provider, + }), +})) + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ params: {}, query: {} }), +})) + +import CloudStorageView from '../CloudStorageView.vue' + +const globalStubs = { + StorageBrowser: { + template: '
    ', + props: ['folders', 'files', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint'], + emits: ['folder-navigate'], + }, +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('CloudStorageView', () => { + it('renders StorageBrowser (no parallel grid)', () => { + const w = mount(CloudStorageView, { global: { stubs: globalStubs } }) + expect(w.find('[data-test="storage-browser"]').exists()).toBe(true) + }) + + it('contains no file-grid markup of its own', () => { + const w = mount(CloudStorageView, { global: { stubs: globalStubs } }) + // The view must not render its own grid (no grid/list/table/tr outside StorageBrowser) + const html = w.html() + // Only StorageBrowser is the grid consumer + expect(html).not.toContain(' { + const w = mount(CloudStorageView, { global: { stubs: globalStubs } }) + const sb = w.find('[data-test="storage-browser"]') + expect(sb).toBeTruthy() + }) +})