docs(05): add UAT, UI-SPEC, deferred items, debug notes; refine plans 09-11
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>
This commit is contained in:
@@ -0,0 +1,760 @@
|
||||
---
|
||||
phase: 5
|
||||
slug: cloud-storage-backends
|
||||
status: approved
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: 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) and `px-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 heading
|
||||
- `text-xl` — Section heading (also: modal header)
|
||||
- `text-sm` — Body / Subsection heading
|
||||
- `text-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:**
|
||||
- `preferences` tab: existing "Document Preferences" section (pdf_open_mode radios) — extracted into `SettingsPreferencesTab.vue`
|
||||
- `ai` tab: existing "AI configuration" section (admin-managed notice) — extracted into `SettingsAiTab.vue`
|
||||
- `cloud` tab: 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:
|
||||
1. Section heading: `<h3 class="text-xl font-semibold text-gray-800 mb-1">Cloud Storage</h3>`
|
||||
2. Description: `<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>`
|
||||
3. Provider list: one row per provider, stacked with `divide-y divide-gray-100`
|
||||
4. "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:**
|
||||
|
||||
1. **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>
|
||||
```
|
||||
|
||||
2. **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"
|
||||
/>
|
||||
```
|
||||
|
||||
3. **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`.
|
||||
|
||||
4. **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):
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```html
|
||||
<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…" at `pl-12` (depth 1 * 12 = 12px left + icon space)
|
||||
- On success: render `CloudFolderTreeItem` nodes
|
||||
- On error: show `text-xs text-red-500` "Failed to load — tap to retry" at `pl-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
|
||||
|
||||
- [x] Dimension 1 Copywriting: PASS
|
||||
- [x] Dimension 2 Visuals: PASS
|
||||
- [x] Dimension 3 Color: PASS
|
||||
- [x] Dimension 4 Typography: PASS
|
||||
- [x] Dimension 5 Spacing: PASS
|
||||
- [x] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** approved 2026-05-28
|
||||
Reference in New Issue
Block a user