Plan refinements: Vitest tests added to 09/10 must-haves, explicit mock_flow two-tuple pattern in 10, test_admin_api.py fixture usage in 11. New artifacts: UAT checklist, UI-SPEC, deferred-items, debug investigation for cloud-doc-operations-fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
32 KiB
phase, slug, status, shadcn_initialized, preset, created
| phase | slug | status | shadcn_initialized | preset | created |
|---|---|---|---|---|---|
| 5 | cloud-storage-backends | approved | false | none | 2026-05-28 |
Phase 5 — UI Design Contract
Visual and interaction contract for Phase 5: Cloud Storage Backends. Generated by gsd-ui-researcher, verified by gsd-ui-checker. Design system is vanilla Tailwind CSS (no shadcn) — matches Phases 1–4.
Design System
| Property | Value |
|---|---|
| Tool | none |
| Preset | not applicable |
| Component library | none |
| Icon library | inline SVG, stroke-based, w-4 h-4 for nav, w-5 h-5 for status icons |
| Font | system-ui (Tailwind default) |
No shadcn gate applies — this is a Vue 3 / Tailwind project without shadcn.
Spacing Scale
All values match the existing 8-point scale already established in Phases 1–4:
| Token | Value | Tailwind | Usage |
|---|---|---|---|
| xs | 4px | gap-1, p-1 |
Icon gaps, tight inline pairs |
| sm | 8px | gap-2, p-2 |
Badge padding, button icon gap |
| md | 16px | gap-4, p-4 |
Default element spacing |
| lg | 24px | p-6 |
Section card inner padding |
| xl | 32px | p-8 |
Page padding (SettingsView wrapper) |
| 2xl | 48px | mb-12 |
Major section breaks (not used in this phase) |
| 3xl | 64px | — | Page-level spacing (not used in this phase) |
Exceptions:
- Provider card row inner padding:
px-4 py-3(12px top/bottom, 16px sides) — matches existing admin table row density - Modal inner padding:
p-6(lg) - Touch target minimum:
min-h-[44px]on all primary action buttons in modal (existing ConfirmBlock contract) - Badge/pill label padding:
px-2 py-0.5(status badges) andpx-1.5 py-0.5("Recommended" tag) — optical micro-sizing for pill text, carry-forward from Phases 1–4 badge pattern - Sidebar section header icon gap:
gap-0.5(2px) between chevron and nav link — optical icon alignment, matches existing Folders section pattern
Typography
All roles match the locked type scale from Phases 1–4. Exactly 4 distinct font size tokens are in use.
| Role | Tailwind Classes | Usage in Phase 5 |
|---|---|---|
| Page heading | text-2xl font-semibold text-gray-900 |
"Settings" page title (unchanged) |
| Section heading | text-xl font-semibold text-gray-800 |
Tab content section headers (e.g. "Connected providers"); also: modal header <h3> in CloudCredentialModal (same size role) |
| Subsection heading | text-sm font-semibold text-gray-900 |
Provider name inside card, modal section labels |
| Body | text-sm text-gray-700 leading-relaxed |
Description text, modal helper text, provider display names |
| Secondary body | text-sm text-gray-600 |
Status description, connected-at date |
| Meta / label | text-xs text-gray-500 |
"Connected on …" date stamps, badge text on status pills |
Font size token summary (4 tokens, no others permitted):
text-2xl— Page headingtext-xl— Section heading (also: modal header)text-sm— Body / Subsection headingtext-xs— Meta / Label
Weights in use: 400 (regular — body, secondary, meta) and 600 (semibold — headings, button labels, subsection labels). No additional weights introduced.
Body line height: leading-relaxed (1.625) — applied to the text-sm text-gray-700 role. Heading line height: leading-tight (1.25) — applied to text-2xl and text-xl heading roles.
Color
Matches 60/30/10 contract locked in Phases 1–4:
| Role | Tailwind Value | Usage |
|---|---|---|
| Dominant (60%) | bg-gray-50 |
Page background |
| Secondary (30%) | bg-white + border-gray-200 |
Section cards, modal background, provider rows |
| Accent (10%) | indigo-600 / indigo-700 |
Primary buttons (Connect {provider}, Save, Reconnect {provider}), active tab underline, focus rings |
| Destructive | red-600 / red-700 |
Remove/Disconnect button, disconnect-all action, error banner background tint |
Accent (indigo-600) is reserved for:
- "Connect {provider}" button on unconnected providers
- "Reconnect {provider}" button on REQUIRES_REAUTH providers
- "Save" button in WebDAV/Nextcloud credential modal
- Active tab indicator underline in SettingsView tab strip
- Focus rings (
focus:ring-indigo-500) on all inputs in the credential modal
Additional semantic colors introduced by Phase 5:
| Role | Tailwind Classes | Usage |
|---|---|---|
| Success (ACTIVE status) | bg-green-100 text-green-700 |
ACTIVE badge |
| Warning (REQUIRES_REAUTH) | bg-yellow-100 text-yellow-800 |
REQUIRES_REAUTH badge, warning banner background |
| Error (ERROR status) | bg-red-100 text-red-700 |
ERROR badge |
| Neutral (not connected) | bg-gray-100 text-gray-600 |
"Not connected" badge |
| Warning banner | bg-yellow-50 border border-yellow-200 text-yellow-800 |
REQUIRES_REAUTH inline banner within provider row |
| Error banner | bg-red-50 border border-red-200 text-red-700 |
OAuth error banner (persistent, query param triggered) |
Focal Point
Primary focal anchor for the Cloud Storage tab view: The provider list rows — specifically the status badges and action buttons on the right side of each row. This is where the user's eye is directed: badges communicate current state at a glance, action buttons drive the next step.
Visual hierarchy to support this: provider name (font-semibold) draws attention first, status badge follows as inline confirmation, action button column (shrink-0, right-aligned) provides a predictable vertical target for scanning. The "Disconnect all cloud storage" link at the bottom is intentionally low-contrast (plain text-red-600 text link) to keep it out of the primary scan path.
New UI Surfaces
Surface 1: SettingsView Tab Conversion
Change: Convert SettingsView from flat stacked sections to a 3-tab layout matching AdminView's pattern exactly.
Tab structure:
tabs = [
{ id: 'preferences', label: 'Preferences' },
{ id: 'ai', label: 'AI Configuration' },
{ id: 'cloud', label: 'Cloud Storage' },
]
Tab strip (copy AdminView verbatim):
<div class="flex border-b border-gray-200 mb-6">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors"
:class="activeTab === tab.id
? 'text-indigo-600 border-indigo-600'
: 'text-gray-500 hover:text-gray-700 border-transparent'"
>
{{ tab.label }}
</button>
</div>
Tab content mapping:
preferencestab: existing "Document Preferences" section (pdf_open_mode radios) — extracted intoSettingsPreferencesTab.vueaitab: existing "AI configuration" section (admin-managed notice) — extracted intoSettingsAiTab.vuecloudtab: new cloud storage management —SettingsCloudTab.vue
Active tab on mount: preferences (first tab, user's existing default context)
Active tab override on OAuth redirect: If ?cloud_connected= or ?cloud_error= query param is present in onMounted, set activeTab = 'cloud' before clearing the query params.
SettingsView wrapper: Retain p-8 max-w-3xl mx-auto. The heading ("Settings") and description stay above the tab strip.
Surface 2: SettingsCloudTab — Provider Cards
Component: frontend/src/components/settings/SettingsCloudTab.vue
Layout: A single section card (bg-white border border-gray-200 rounded-xl p-6) containing:
- Section heading:
<h3 class="text-xl font-semibold text-gray-800 mb-1">Cloud Storage</h3> - Description:
<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p> - Provider list: one row per provider, stacked with
divide-y divide-gray-100 - "Disconnect all" action (shown only when at least one provider is ACTIVE or ERROR)
Provider row structure (one row per provider, always shown — all 4 providers always visible):
<div class="flex items-center justify-between py-3 gap-4">
<!-- Left: icon + name + status badge -->
<div class="flex items-center gap-3 min-w-0">
<!-- Provider icon: w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center -->
<span class="text-sm font-semibold text-gray-900">{{ provider.label }}</span>
<StatusBadge :status="connection?.status ?? 'not_connected'" />
</div>
<!-- Right: action button(s) -->
<div class="flex items-center gap-2 shrink-0">
<!-- See button specs per status below -->
</div>
</div>
Provider labels and icons:
| Provider key | Display label | Icon description |
|---|---|---|
google_drive |
Google Drive | Cloud icon with "G" text or inline SVG cloud, text-blue-500 |
onedrive |
OneDrive | Cloud icon, text-sky-500 |
nextcloud |
Nextcloud | Cloud icon, text-orange-500 |
webdav |
WebDAV server | Server/database icon, text-gray-500 |
Provider icons are inline SVG, w-5 h-5, stroke-based consistent with the project's existing icon vocabulary. Use the standard cloud path from the project's SVG set. Color is applied via class on the SVG element.
Status badges (pill component, StatusBadge.vue or inline):
| Status | Classes | Label |
|---|---|---|
ACTIVE |
bg-green-100 text-green-700 text-xs font-semibold px-2 py-0.5 rounded-full |
Active |
REQUIRES_REAUTH |
bg-yellow-100 text-yellow-800 text-xs font-semibold px-2 py-0.5 rounded-full |
Reconnect needed |
ERROR |
bg-red-100 text-red-700 text-xs font-semibold px-2 py-0.5 rounded-full |
Error |
not_connected |
bg-gray-100 text-gray-600 text-xs font-semibold px-2 py-0.5 rounded-full |
Not connected |
Action button label pattern: All action buttons include the provider's display label as the noun object. The {provider} placeholder resolves to the display label from the provider labels table above (e.g., "Google Drive", "OneDrive", "Nextcloud", "WebDAV server").
Action buttons per status:
| Status | Button label | Classes |
|---|---|---|
not_connected |
"Connect {provider}" | bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors |
ACTIVE |
"Remove {provider}" | text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors |
REQUIRES_REAUTH |
"Reconnect {provider}" (primary) + "Remove {provider}" (secondary) | Reconnect: bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors; Remove: text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors |
ERROR |
"Remove {provider}" | text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 text-red-600 transition-colors |
Example rendered labels: "Connect Google Drive", "Reconnect OneDrive", "Remove Nextcloud", "Remove WebDAV server".
Loading state: When a Connect or Reconnect button is clicked and the API call is in-flight, the button shows a spinner icon (animate-spin) replacing the label, and is disabled opacity-50 cursor-not-allowed. Width is held fixed to prevent layout shift (use min-w-[160px] on the button — wide enough for the longest provider name combination).
Connected-at date: For ACTIVE and ERROR connections, show below the badge: <span class="text-xs text-gray-500">Connected {date}</span>. Use locale date format: new Date(connection.connected_at).toLocaleDateString(). Not shown for not_connected or REQUIRES_REAUTH.
REQUIRES_REAUTH inline banner: Below the row (not inside it), conditionally rendered per provider:
<div
v-if="connection?.status === 'REQUIRES_REAUTH'"
class="mx-0 mb-2 p-3 rounded-lg bg-yellow-50 border border-yellow-200 flex items-start gap-2"
>
<svg class="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" ...warning-triangle-icon... />
<p class="text-sm text-yellow-800">
Your {{ provider.label }} connection needs to be re-authorized.
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
</p>
</div>
"Disconnect all" action: Rendered at the bottom of the section card, below the provider list. Only visible when at least one connection exists with status ACTIVE or ERROR.
<div class="pt-4 border-t border-gray-100 flex justify-end">
<button
@click="showDisconnectAll = true"
class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors"
>
Disconnect all cloud storage
</button>
</div>
Clicking opens a confirmation block using the existing ConfirmBlock component pattern:
Message: "This will permanently delete all cloud storage credentials. Your documents will remain in DocuVault, but cloud documents may become inaccessible."
Confirm label: "Disconnect all"
Cancel label: "Keep all connected"
confirmClass: "bg-red-600 hover:bg-red-700 text-white"
Empty state: No explicit empty state needed — the tab always shows all 4 providers (3 OAuth + 1 WebDAV), each with "Not connected" status. The section heading description serves as the orientation copy.
Surface 3: OAuth Success / Error Toast
Trigger: onMounted in SettingsView reads window.location.search for ?cloud_connected={provider} or ?cloud_error={message}. After reading, replace the URL using router.replace({ path: '/settings' }) to clean the query params.
Success toast:
<div
v-if="oauthSuccessProvider"
class="fixed top-4 right-4 z-50 flex items-center gap-3 bg-white border border-green-200 rounded-xl shadow-lg px-5 py-4 max-w-sm"
>
<svg class="w-5 h-5 text-green-500 shrink-0" ...checkmark-circle-icon... />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900">{{ providerLabel }} connected</p>
<p class="text-xs text-gray-500 mt-0.5">Your files are now available in the sidebar.</p>
</div>
<button
@click="oauthSuccessProvider = null"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
>
<svg class="w-4 h-4" ...x-icon... />
</button>
</div>
- Auto-dismiss after 5000 ms (
setTimeout(() => oauthSuccessProvider = null, 5000)) - Position:
fixed top-4 right-4 z-50— top-right corner, above all content - Shadow:
shadow-lg
Error banner (persistent — requires manual dismissal):
<div
v-if="oauthError"
class="mb-6 flex items-start gap-3 bg-red-50 border border-red-200 rounded-xl px-5 py-4"
>
<svg class="w-5 h-5 text-red-500 shrink-0 mt-0.5" ...exclamation-circle-icon... />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-red-700">Connection failed</p>
<p class="text-sm text-red-600 mt-0.5">{{ oauthError }}</p>
<p class="text-xs text-red-500 mt-1">Try connecting again. If the problem persists, check that the app has the correct permissions in your provider's account settings.</p>
</div>
<button
@click="oauthError = null"
aria-label="Dismiss error"
class="text-red-400 hover:text-red-600 shrink-0"
>
<svg class="w-4 h-4" ...x-icon... />
</button>
</div>
- Position: inline, rendered above the section card inside the "Cloud Storage" tab content
- Persistent until manually dismissed or page navigation
- The
?cloud_error=value is URL-decoded and displayed as the error message body
Provider label mapping (for toast message):
| Query param value | Display label |
|---|---|
google_drive |
Google Drive |
onedrive |
OneDrive |
nextcloud |
Nextcloud |
webdav |
WebDAV server |
Surface 4: WebDAV / Nextcloud Credential Modal
Trigger: Clicking "Connect {provider}" on the Nextcloud or WebDAV provider row.
Component: CloudCredentialModal.vue — a centered modal overlay.
Overlay: fixed inset-0 bg-gray-900 bg-opacity-40 z-40 flex items-center justify-center p-4
Modal panel: bg-white rounded-xl shadow-xl w-full max-w-md p-6
Header:
<div class="flex items-center justify-between mb-5">
<h3 class="text-xl font-semibold text-gray-900">Connect {{ providerLabel }}</h3>
<button
@click="close"
aria-label="Close modal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-5 h-5" ...x-icon... />
</button>
</div>
Note: The modal <h3> uses text-xl font-semibold — the Section heading role from the typography scale.
Form fields:
- Server URL (Nextcloud and WebDAV only — not shown for OAuth providers):
<label class="block text-sm font-semibold text-gray-900 mb-1">Server URL</label>
<input
type="url"
v-model="serverUrl"
placeholder="https://nextcloud.example.com/remote.php/dav/files/username/"
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"
/>
<p class="text-xs text-gray-500 mt-1">Full WebDAV endpoint URL including username path segment.</p>
- Username:
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">Username</label>
<input
type="text"
v-model="username"
autocomplete="username"
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"
/>
- Auth method toggle (radio group, displayed between Username and Password fields):
<div class="mt-4 mb-2">
<p class="text-sm font-semibold text-gray-900 mb-2">Authentication method</p>
<div class="space-y-2">
<label class="flex items-start gap-3 cursor-pointer">
<input type="radio" value="app_password" v-model="authMethod"
class="mt-0.5 text-indigo-600 focus:ring-indigo-500" />
<div>
<span class="text-sm font-semibold text-gray-900">App password</span>
<span class="ml-2 bg-green-100 text-green-700 text-xs font-semibold px-1.5 py-0.5 rounded">Recommended</span>
<p class="text-xs text-gray-500 mt-0.5">
Can be revoked individually without changing your main account password.
</p>
</div>
</label>
<label class="flex items-start gap-3 cursor-pointer">
<input type="radio" value="account_password" v-model="authMethod"
class="mt-0.5 text-indigo-600 focus:ring-indigo-500" />
<div>
<span class="text-sm font-semibold text-gray-900">Account password</span>
<p class="text-xs text-gray-500 mt-0.5">
Simpler to set up, but revoking access requires changing your entire account password.
</p>
</div>
</label>
</div>
</div>
Default selected: app_password.
- Password / App password field:
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">
{{ authMethod === 'app_password' ? 'App password' : 'Password' }}
</label>
<input
type="password"
v-model="password"
autocomplete="current-password"
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"
/>
Validation error display (inline, shown below offending field):
<p class="text-xs text-red-600 mt-1">{{ fieldError }}</p>
Connection test error (shown above buttons after failed test):
<div
v-if="connectError"
class="mt-4 p-3 rounded-lg bg-red-50 border border-red-200"
>
<p class="text-sm font-semibold text-red-700">Connection failed</p>
<p class="text-sm text-red-600 mt-0.5">{{ connectError }}</p>
<p class="text-xs text-red-500 mt-1">Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients.</p>
</div>
Footer buttons:
<div class="flex justify-end gap-3 mt-6">
<button
type="button"
@click="close"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Keep current settings
</button>
<button
type="button"
@click="submit"
:disabled="saving"
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 min-h-[44px] min-w-[80px]"
>
<svg v-if="saving" class="w-4 h-4 animate-spin mx-auto" ...spinner... />
<span v-else>Connect {{ providerLabel }}</span>
</button>
</div>
Dismiss behavior: Clicking the X button, "Keep current settings", or pressing Escape closes the modal. Clicking the overlay background also closes. When saving is true, all close actions are disabled (prevent accidental dismissal during the API call).
Surface 5: Cloud Provider Nodes in Sidebar
Placement: A new "Cloud Storage" section added to AppSidebar.vue, positioned immediately after the Folders section (after the </div> closing the Folders collapsible block), before the Topics section.
Section header and collapsible pattern (mirrors Folders section exactly):
<div class="mt-3">
<div class="flex items-center gap-0.5">
<!-- Expand/collapse chevron -->
<button
@click="cloudExpanded = !cloudExpanded"
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0"
:title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="cloudExpanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- "Cloud Storage" label — navigates to /settings?tab=cloud -->
<a
href="/settings"
class="nav-link flex-1 min-w-0"
>
<svg class="w-4 h-4 mr-2 shrink-0 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
Cloud Storage
</a>
</div>
<!-- Collapsible content: one node per active connection -->
<template v-if="cloudExpanded">
<div v-if="loadingCloudConnections" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
<div v-else-if="activeCloudConnections.length === 0" class="pl-7 py-1 text-xs text-gray-400">
No cloud storage connected
</div>
<CloudProviderTreeItem
v-for="connection in activeCloudConnections"
:key="connection.id"
:connection="connection"
:depth="1"
/>
</template>
</div>
activeCloudConnections: Only connections with status ACTIVE are shown as tree nodes. REQUIRES_REAUTH and ERROR connections are not shown in the sidebar (user must go to Settings to resolve them).
CloudProviderTreeItem.vue — new component, mirrors FolderTreeItem.vue structure:
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow -->
<button
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse ' + connection.display_name : 'Expand ' + connection.display_name"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Provider name (click navigates to cloud folder root) -->
<button
@click="navigateToRoot"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
>
<!-- Provider cloud icon (w-4 h-4, provider color) -->
<svg class="w-4 h-4 shrink-0" :class="providerIconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<span class="truncate">{{ connection.display_name }}</span>
</button>
</div>
<!-- Children: first-level cloud folders (lazy loaded) -->
<template v-if="expanded">
<div v-if="loading" class="pl-12 py-1 text-xs text-gray-400">Loading…</div>
<div v-else-if="children.length === 0" class="pl-12 py-1 text-xs text-gray-400">Empty</div>
<CloudFolderTreeItem
v-for="folder in children"
:key="folder.id"
:folder="folder"
:provider="connection.provider"
:depth="depth + 1"
/>
</template>
</div>
</template>
Provider icon colors:
| Provider | providerIconColor class |
|---|---|
google_drive |
text-blue-500 |
onedrive |
text-sky-500 |
nextcloud |
text-orange-500 |
webdav |
text-gray-500 |
Loading behavior for cloud folder expansion:
- On first expand: show
text-xs text-gray-400"Loading…" atpl-12(depth 1 * 12 = 12px left + icon space) - On success: render
CloudFolderTreeItemnodes - On error: show
text-xs text-red-500"Failed to load — tap to retry" atpl-12, clicking retries the fetch - 60-second TTL cache handled server-side; frontend always calls the API on expand if
childrenLoaded === false
CloudFolderTreeItem.vue: A simplified version of FolderTreeItem.vue for cloud folder nodes. Uses a plain folder icon (text-gray-400). Navigates to /cloud/{provider}/{folder_id} on click. Indentation via depth * 12 matching the existing pattern. Lazy-loads nested children via the same expand/toggle mechanism.
Depth / indentation: Cloud tree nodes use the same depth * 12 px left-padding formula as FolderTreeItem. Provider root is at depth 1 (same level as local root folders). Cloud sub-folders start at depth 2.
Copywriting Contract
Primary CTA labels
All action buttons that target a specific provider use the {verb} {provider} pattern. The {provider} token resolves to the display label from the provider labels table.
| Action | Button label pattern | Example |
|---|---|---|
| Connect OAuth provider | "Connect {provider}" | "Connect Google Drive" |
| Connect WebDAV / Nextcloud (modal submit) | "Connect {provider}" | "Connect Nextcloud" |
| Reconnect (REQUIRES_REAUTH) | "Reconnect {provider}" | "Reconnect OneDrive" |
| Remove single active/error provider | "Remove {provider}" | "Remove Google Drive" |
| Remove single from REQUIRES_REAUTH row (secondary) | "Remove {provider}" | "Remove Nextcloud" |
| Disconnect all providers (trigger link) | "Disconnect all cloud storage" | (no provider token — applies to all) |
| Disconnect all (ConfirmBlock confirm label) | "Disconnect all" | |
| Cancel WebDAV/Nextcloud modal | "Keep current settings" | |
| Dismiss disconnect-single ConfirmBlock | "Keep connected" | |
| Dismiss disconnect-all ConfirmBlock | "Keep all connected" |
Status badge labels
| Status | Badge label |
|---|---|
ACTIVE |
Active |
REQUIRES_REAUTH |
Reconnect needed |
ERROR |
Error |
not_connected |
Not connected |
Empty states
| Location | Copy |
|---|---|
| Cloud tab, no active connections | (no explicit empty state — all 4 providers always shown with "Not connected" badge) |
| Sidebar cloud section, no active connections | "No cloud storage connected" |
| Cloud folder tree node, empty provider root | "Empty" |
| Cloud folder tree node, load error | "Failed to load — tap to retry" |
Error messages
| Error scenario | Copy |
|---|---|
| WebDAV connection test failed (modal inline) | "Connection failed" (heading) + server error message (body) + "Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients." |
OAuth callback error (?cloud_error=) |
"Connection failed" (heading) + decoded error value (body) + "Try connecting again. If the problem persists, check that the app has the correct permissions in your provider's account settings." |
| Disconnect failed (API error) | Toast or inline: "Failed to disconnect. Please try again." |
| Cloud folder load failed (sidebar tree) | "Failed to load — tap to retry" |
REQUIRES_REAUTH inline banner
"Your {provider label} connection needs to be re-authorized. Click Reconnect {provider label} to restore access."
Disconnect single confirmation
Uses existing ConfirmBlock component:
- Message: "This will permanently remove your {provider label} credentials from DocuVault. Your cloud documents will remain in your {provider label} account."
- Confirm label: "Remove {provider label}" (e.g. "Remove Google Drive")
- Cancel label: "Keep connected"
confirmClass:"bg-red-600 hover:bg-red-700 text-white"
Disconnect-all confirmation
Uses existing ConfirmBlock component:
- Message: "This will permanently delete all cloud storage credentials. Your documents will remain in DocuVault, but cloud documents may become inaccessible."
- Confirm label: "Disconnect all"
- Cancel label: "Keep all connected"
confirmClass:"bg-red-600 hover:bg-red-700 text-white"
OAuth success toast
"{provider label} connected" (heading) + "Your files are now available in the sidebar." (body)
Component Inventory
New components introduced in Phase 5:
| Component path | Purpose | Extends |
|---|---|---|
frontend/src/components/settings/SettingsCloudTab.vue |
Cloud Storage tab content | New |
frontend/src/components/settings/SettingsPreferencesTab.vue |
Extracted Preferences tab (pdf_open_mode) | Extracted from SettingsView |
frontend/src/components/settings/SettingsAiTab.vue |
Extracted AI config tab | Extracted from SettingsView |
frontend/src/components/cloud/CloudCredentialModal.vue |
WebDAV / Nextcloud credential input modal | New |
frontend/src/components/cloud/CloudProviderTreeItem.vue |
Provider root node in sidebar tree | Mirrors FolderTreeItem |
frontend/src/components/cloud/CloudFolderTreeItem.vue |
Cloud sub-folder node in sidebar tree | Mirrors FolderTreeItem |
Modified components:
| Component path | Change |
|---|---|
frontend/src/views/SettingsView.vue |
Convert to 3-tab layout; add OAuth param handling in onMounted; add success toast + error banner state |
frontend/src/components/layout/AppSidebar.vue |
Add "Cloud Storage" collapsible section below Folders |
New Pinia store:
| Store | State |
|---|---|
frontend/src/stores/cloudConnections.js |
connections: [], loading: bool, error: string|null. Actions: fetchConnections(), disconnect(id), disconnectAll() |
Registry Safety
No shadcn registry. No third-party component registries. All UI is custom Tailwind + inline SVG matching the existing project pattern.
| Registry | Blocks Used | Safety Gate |
|---|---|---|
| shadcn official | none | not applicable |
| third-party | none | not applicable |
Interaction States Summary
| Component | States |
|---|---|
| Connect {provider} button | default, hover, loading (spinner + disabled), disabled (already connected) |
| Remove {provider} button | default, hover, loading (spinner + disabled) |
| Reconnect {provider} button | default, hover, loading (spinner + disabled) |
| WebDAV modal "Connect {provider}" button | default, hover, loading (spinner + disabled) |
| WebDAV modal inputs | default, focus (indigo ring), error (red border + error text below) |
| Status badge | static — no hover state |
| Success toast | visible (auto-dismiss 5s), dismiss on "Dismiss notification" X button |
| Error banner | visible (persistent), dismiss on "Dismiss error" X button |
| REQUIRES_REAUTH banner | visible when status === REQUIRES_REAUTH, disappears after reconnect |
| Cloud tree provider node | default, hover (bg-gray-100), active/selected (bg-indigo-50 text-indigo-700) |
| Cloud tree expand arrow | default (text-gray-400), hover (text-gray-600), expanded (rotate-90) |
| Cloud folder tree loading | "Loading…" text (text-xs text-gray-400) |
| Cloud folder tree error | "Failed to load — tap to retry" (text-xs text-red-500, cursor-pointer) |
Checker Sign-Off
- Dimension 1 Copywriting: PASS
- Dimension 2 Visuals: PASS
- Dimension 3 Color: PASS
- Dimension 4 Typography: PASS
- Dimension 5 Spacing: PASS
- Dimension 6 Registry Safety: PASS
Approval: approved 2026-05-28