diff --git a/backend/api/cloud/connections.py b/backend/api/cloud/connections.py index 591be1f..c9e7500 100644 --- a/backend/api/cloud/connections.py +++ b/backend/api/cloud/connections.py @@ -170,6 +170,9 @@ async def _oauth_authorization_url(provider: str, redirect_uri: str, state_token if provider == "google_drive": from google_auth_oauthlib.flow import Flow # lazy import + # D-17: Phase 13 requests the full `drive` scope so authorized users can + # operate on all existing Drive items, not just files created by this app. + # Consent text must make the expanded scope explicit. flow = Flow.from_client_config( { "web": { @@ -179,7 +182,7 @@ async def _oauth_authorization_url(provider: str, redirect_uri: str, state_token "token_uri": "https://oauth2.googleapis.com/token", } }, - scopes=["https://www.googleapis.com/auth/drive.file"], + scopes=["https://www.googleapis.com/auth/drive"], redirect_uri=redirect_uri, ) authorization_url, _ = flow.authorization_url( @@ -211,6 +214,7 @@ async def _oauth_credentials_from_code(provider: str, redirect_uri: str, code: O if provider == "google_drive": from google_auth_oauthlib.flow import Flow # lazy import + # D-17: Broader Phase 13 drive scope (must match initiation scope above). flow = Flow.from_client_config( { "web": { @@ -220,7 +224,7 @@ async def _oauth_credentials_from_code(provider: str, redirect_uri: str, code: O "token_uri": "https://oauth2.googleapis.com/token", } }, - scopes=["https://www.googleapis.com/auth/drive.file"], + scopes=["https://www.googleapis.com/auth/drive"], redirect_uri=redirect_uri, ) await asyncio.to_thread(flow.fetch_token, code=code) diff --git a/backend/api/cloud/schemas.py b/backend/api/cloud/schemas.py index 75a6271..907a590 100644 --- a/backend/api/cloud/schemas.py +++ b/backend/api/cloud/schemas.py @@ -3,6 +3,12 @@ Whitelisted Pydantic response schemas for the cloud API package. All schemas are explicit allowlists — credentials_enc, tokens, and passwords are deliberately absent. T-12-03: credential exclusion by design. + +Phase 13 additions: + - ConnectionHealthOut: typed health status response (D-12, CONN-03) + - ReconnectOut: typed reconnect response (CONN-01..03) + - ContentResultOut: typed open/preview/download response (D-02, D-18, T-13-14) + - MutationResultOut: typed kind/reason mutation response (D-05..11) """ from __future__ import annotations @@ -84,3 +90,115 @@ class ConnectionRenameRequest(BaseModel): if not stripped: raise ValueError("display_name must not be blank") return stripped + + +# ── Phase 13 health / reconnect schemas ──────────────────────────────────────── + +class ConnectionHealthOut(BaseModel): + """Typed connection health response. + + D-12: Explicit health status available without probing on every browse. + CONN-03: Never exposes credentials_enc, tokens, or raw provider URLs. + + status: 'healthy' | 'degraded' | 'auth_failed' | 'offline' + """ + + status: str + connection_id: str + provider: str + display_name: Optional[str] = None + + +class ReconnectOut(BaseModel): + """Typed reconnect result. + + CONN-01: No new connection row created (reconnected patches in place). + CONN-02: credentials_enc updated with re-encrypted token. + CONN-03: No credentials, tokens, or provider URLs in response. + D-14: Cached metadata preserved as stale. + """ + + status: str + connection_id: str + provider: str + display_name: Optional[str] = None + reconnected: bool = True + + +# ── Phase 13 content result schemas ─────────────────────────────────────────── + +class ContentResultOut(BaseModel): + """Typed open/preview result body. + + D-02: Provider credentials and raw provider URLs must never appear in responses. + D-18: Preview is binary-only; unsupported formats fall back to download fallback. + T-13-14: Stable kind/reason codes let the frontend route without parsing provider payloads. + + kind: 'open' | 'preview' | 'download' | 'unsupported_preview' + reason: discriminator code (e.g. 'binary_supported', 'unsupported_format', 'authorized') + url: DocuVault-scoped authorized URL (never a raw provider URL) + """ + + kind: str + reason: Optional[str] = None + url: Optional[str] = None + content_type: Optional[str] = None + + +# ── Phase 13 mutation result schemas ────────────────────────────────────────── + +class MutationResultOut(BaseModel): + """Typed mutation result body used by rename, move, delete, and create-folder. + + T-13-14: Stable kind/reason codes let the frontend route without parsing + raw provider error payloads. + + kind: 'renamed' | 'moved' | 'deleted' | 'folder' | 'conflict' | 'stale' | + 'offline' | 'reauth_required' | 'invalid_destination' | 'unsupported_operation' + reason: discriminator detail (e.g. 'trashed', 'permanent', 'name_collision', + 'item_changed', 'provider_unreachable', 'token_expired', + 'self_destination', 'cross_connection', 'provider_unsupported') + """ + + kind: str + reason: Optional[str] = None + name: Optional[str] = None + provider_item_id: Optional[str] = None + parent_ref: Optional[str] = None + + +# ── Phase 13 mutation request schemas ───────────────────────────────────────── + +class CreateFolderRequest(BaseModel): + """Request body for POST /connections/{id}/folders.""" + parent_ref: Optional[str] = None + name: str = Field(..., max_length=255) + + @field_validator("name") + @classmethod + def must_be_nonblank(cls, v: str) -> str: + stripped = v.strip() + if not stripped: + raise ValueError("name must not be blank") + return stripped + + +class RenameItemRequest(BaseModel): + """Request body for PATCH /connections/{id}/items/{item_id}/rename.""" + new_name: str = Field(..., max_length=255) + etag: Optional[str] = None + + @field_validator("new_name") + @classmethod + def must_be_nonblank(cls, v: str) -> str: + stripped = v.strip() + if not stripped: + raise ValueError("new_name must not be blank") + return stripped + + +class MoveItemRequest(BaseModel): + """Request body for POST /connections/{id}/items/{item_id}/move.""" + destination_parent_ref: str + destination_connection_id: Optional[str] = None + etag: Optional[str] = None diff --git a/frontend/src/api/cloud.js b/frontend/src/api/cloud.js index bd6dad7..e277458 100644 --- a/frontend/src/api/cloud.js +++ b/frontend/src/api/cloud.js @@ -87,3 +87,149 @@ export function updateWebDavCredentials(connectionId, { serverUrl, username, pas if (password !== undefined && password !== '') body.password = password return jsonRequest(`/api/cloud/connections/${connectionId}/credentials`, 'PUT', body) } + +// ── Phase 13: Connection health, reconnect, and test ───────────────────────── + +/** + * Get the health status of a cloud connection without probing the provider. + * + * D-12: Health is available explicitly (not inferred from browse). + * Returns {status, connection_id, provider, display_name}. + * status: 'healthy' | 'degraded' | 'auth_failed' | 'offline' + */ +export function getConnectionHealth(connectionId) { + return request(`/api/cloud/connections/${connectionId}/health`) +} + +/** + * Reconnect an AUTH_FAILED connection in-place. + * + * CONN-01: Patches the existing row — no new connection row created. + * CONN-02: Re-encrypts refreshed credentials in place. + * CONN-03: Response is credential-free. + * D-14: Cached metadata preserved as stale; caches invalidated server-side. + */ +export function reconnectCloudConnection(connectionId) { + return jsonRequest(`/api/cloud/connections/${connectionId}/reconnect`, 'POST', {}) +} + +/** + * Explicitly test a cloud connection by probing the provider. + * + * D-13: Explicit Test action — not triggered on every folder navigation. + * Updates the connection status row and returns the new health status. + */ +export function testCloudConnection(connectionId) { + return jsonRequest(`/api/cloud/connections/${connectionId}/test`, 'POST', {}) +} + +// ── Phase 13: Authorized cloud content access ───────────────────────────────── + +/** + * Open a cloud file via a DocuVault-authorized URL. + * + * D-02: Provider credentials and raw provider URLs are never exposed. + * Returns {kind: 'open', url: ''}. + * The caller must navigate to the returned URL — never window.open to a provider URL. + */ +export function openCloudFile(connectionId, itemId) { + return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/open`) +} + +/** + * Get an in-app preview of a cloud file (binary formats only). + * + * D-18: Preview is binary-only. Unsupported formats return + * {kind: 'unsupported_preview', reason: 'unsupported_format'} and the caller + * should fall back to openCloudFile() for authorized download. + * D-02: No raw provider URL or credentials in the response. + */ +export function previewCloudFile(connectionId, itemId) { + return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/preview`) +} + +// ── Phase 13: Cloud mutations ───────────────────────────────────────────────── + +/** + * Create a folder in cloud storage. + * + * D-05: Collision auto-naming: 'Projects (1)', 'Projects (2)', etc. + * Returns {kind: 'folder', provider_item_id, name} on success. + * Returns {kind: 'unsupported_operation', reason: 'provider_unsupported'} when + * the provider does not support folder creation. + */ +export function createCloudFolder(connectionId, parentRef, name) { + return jsonRequest(`/api/cloud/connections/${connectionId}/folders`, 'POST', { + parent_ref: parentRef, + name, + }) +} + +/** + * Rename a cloud item. + * + * D-05: Counter-suffix collision policy applies. + * D-07: etag guards against operating on stale metadata. + * Returns {kind: 'stale', reason: 'item_changed'} when externally changed. + * Returns {kind: 'renamed', name} on success. + */ +export function renameCloudItem(connectionId, itemId, newName, etag) { + return jsonRequest( + `/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/rename`, + 'PATCH', + { new_name: newName, etag }, + ) +} + +/** + * Move a cloud item within the same connection. + * + * D-08: Moves are restricted to the same cloud connection. + * D-09: Self and descendant destinations are rejected. + * Returns {kind: 'moved', parent_ref} on success. + * Returns {kind: 'invalid_destination', reason: '...'} for invalid moves. + */ +export function moveCloudItem(connectionId, itemId, destinationParentRef, etag) { + return jsonRequest( + `/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/move`, + 'POST', + { destination_parent_ref: destinationParentRef, etag }, + ) +} + +/** + * Delete a cloud item (trash preferred, permanent when unavailable). + * + * D-11: Response discloses whether trash or permanent delete was performed via + * {kind: 'deleted', reason: 'trashed' | 'permanent'}. + * D-10: Callers must confirm before calling — the API deletes immediately. + */ +export function deleteCloudItem(connectionId, itemId) { + return request( + `/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}`, + { method: 'DELETE' }, + ) +} + +/** + * Upload a file to a cloud folder. + * + * D-03: Same-name upload returns {kind: 'conflict', reason: 'name_collision'} — + * never overwrites silently. + * D-04: Caller manages the sequential upload queue; this function uploads one file. + * + * @param {string} connectionId - Connection UUID + * @param {string} parentRef - Parent folder provider_item_id + * @param {string} filename - Target filename + * @param {File|Blob} file - File bytes + */ +export function uploadCloudFile(connectionId, parentRef, filename, file) { + const formData = new FormData() + formData.append('file', file, filename) + formData.append('parent_ref', parentRef ?? '') + formData.append('filename', filename) + return request(`/api/cloud/connections/${connectionId}/items/upload`, { + method: 'POST', + body: formData, + }) +}