Files
mule-image/frontend/src/components/dialogs/NextcloudFolderPicker.tsx
Claudio bc0bb44c05 feat(nextcloud): per-user Nextcloud library integration
Lets each mule-image user (matched via OIDC preferred_username,
overridable in Settings) browse their Nextcloud files/ tree from the
mule-image UI and register subfolders as per-user SourceRoots. Reads
stay direct on the bind-mounted /nextcloud-users path; mutations
(upload, delete, rename, move within NC) dispatch through Nextcloud
WebDAV so oc_filecache, trashbin, comments, and desktop-sync clients
stay coherent.

Backend:
- users.nextcloud_username + nextcloud_app_password_enc (Fernet at rest,
  key derived from SECRET_KEY) — alembic 0016
- services/nextcloud_dav.py: minimal WebDAV client (PUT, MKCOL, DELETE,
  MOVE) with HTTP Basic auth via the per-user app password
- routers/nextcloud.py: GET /browse, /whoami, GET/POST/DELETE
  /source-roots (path-scoped to current_user.nextcloud_username with
  realpath traversal guard)
- PATCH /api/v1/auth/me to update nextcloud_username and app password
- OIDC callback defaults nextcloud_username from preferred_username on
  first login; backfill on existing users; never overwrites a manual
  override
- routers/upload.py: stream upload to NamedTemporaryFile, then PUT to
  WebDAV (with MKCOL chain) when destination is NC-rooted; existing
  Photo row creation runs unchanged
- routers/discard.py empty-trash: WebDAV DELETE for NC files
- routers/photos.py rename + move: WebDAV MOVE for NC paths;
  cross-system move/copy returns a clean error
- routers/folders.py rename + create + permanent-delete: dispatch via
  WebDAV when targeting NC-rooted paths

Frontend:
- AuthUser carries nextcloud_username + has_nextcloud_app_password
- services/api.ts: nextcloud + account namespaces
- components/dialogs/NextcloudFolderPicker.tsx: lazy tree browser, name
  + submit -> POST /source-roots
- SettingsDialog: new "Nextcloud library" card with username override +
  validate, app-password input, list/remove of NC libraries, and the
  picker entry point

docker-compose.yml: NEXTCLOUD_USERS_HOST_PATH bind to /nextcloud-users
on backend + 3 workers; NEXTCLOUD_USERS_ROOT + NEXTCLOUD_BASE_URL env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:06:37 +02:00

213 lines
7.7 KiB
TypeScript

/**
* NextcloudFolderPicker — modal that lets the user navigate their
* Nextcloud `files/` tree and register a subfolder as a SourceRoot in
* mule-image. Lazy-loads each level via GET /nextcloud/browse?path=...,
* so opening the picker doesn't slurp the whole tree.
*
* Path scoping is enforced server-side; we still avoid showing a `..`
* affordance above the user's root so the UI never surfaces the idea
* that there's something to escape to.
*/
import { useCallback, useEffect, useState } from 'react'
import { ChevronRight, Folder, FolderOpen, Loader2, X } from 'lucide-react'
import { nextcloud, type NextcloudBrowseEntry } from '../../services/api'
import { toast } from '../ToastContainer'
interface Props {
open: boolean
onClose: () => void
onCreated: () => void // called after a successful POST so the parent refetches
}
export function NextcloudFolderPicker({ open, onClose, onCreated }: Props) {
const [path, setPath] = useState('')
const [entries, setEntries] = useState<NextcloudBrowseEntry[]>([])
const [parentRel, setParentRel] = useState<string | null>(null)
const [ncUser, setNcUser] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [name, setName] = useState('')
const loadPath = useCallback(async (target: string) => {
setLoading(true)
setError(null)
try {
const r = await nextcloud.browse(target)
setEntries(r.entries)
setParentRel(r.parent_rel)
setNcUser(r.nc_username)
setPath(r.rel_path)
// Default the SourceRoot name from the leaf folder name.
const leaf = r.rel_path.split('/').filter(Boolean).slice(-1)[0]
if (leaf) setName(leaf)
else setName(`${r.nc_username} library`)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setError(message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open) {
void loadPath('')
}
}, [open, loadPath])
const submit = useCallback(async () => {
if (!path) {
toast.error('Pick a subfolder first — the root is too broad.')
return
}
if (!name.trim()) {
toast.error('Library name is required')
return
}
setSubmitting(true)
try {
await nextcloud.createSourceRoot(name.trim(), path)
toast.success(`Added "${name.trim()}" to your libraries`)
onCreated()
onClose()
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
toast.error(`Could not add library: ${message}`)
} finally {
setSubmitting(false)
}
}, [path, name, onCreated, onClose])
if (!open) return null
// Breadcrumb segments for the current path. "/" rendered as the user's
// root → click jumps back to that level.
const segments = path ? path.split('/').filter(Boolean) : []
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="flex h-[36rem] w-[40rem] flex-col rounded-lg border border-border bg-surface shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium">
<FolderOpen className="h-4 w-4" />
Add library from Nextcloud
</div>
<button
onClick={onClose}
className="rounded p-1 text-text-muted hover:bg-surface-2"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-1 border-b border-border bg-surface-2 px-4 py-2 text-xs text-text-muted">
<button
onClick={() => loadPath('')}
className="font-mono text-text hover:underline"
disabled={loading}
>
{ncUser || 'home'}
</button>
{segments.map((seg, i) => {
const target = segments.slice(0, i + 1).join('/')
return (
<span key={target} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
<button
onClick={() => loadPath(target)}
className="font-mono text-text hover:underline"
disabled={loading}
>
{seg}
</button>
</span>
)
})}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{loading && (
<div className="flex h-full items-center justify-center text-xs text-text-muted">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading
</div>
)}
{error && !loading && (
<div className="rounded border border-red-500/40 bg-red-500/10 p-3 text-xs text-red-300">
{error}
</div>
)}
{!loading && !error && entries.length === 0 && (
<div className="px-3 py-6 text-center text-xs text-text-muted">
No subfolders here. Use Nextcloud to create a folder, then come
back to register it.
</div>
)}
{!loading && !error && entries.length > 0 && (
<ul className="space-y-0.5">
{/* "Up one level" — only shown when not already at root */}
{parentRel !== null && (
<li>
<button
onClick={() => loadPath(parentRel)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
>
<ChevronRight className="h-4 w-4 rotate-180 text-text-muted" />
<span className="text-text-muted">..</span>
</button>
</li>
)}
{entries.map((e) => (
<li key={e.path}>
<button
onClick={() => loadPath(e.path)}
disabled={!e.has_children && false}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
>
<Folder className="h-4 w-4 text-primary/70" />
<span className="truncate">{e.name}</span>
{e.has_children && (
<ChevronRight className="ml-auto h-3 w-3 text-text-muted" />
)}
</button>
</li>
))}
</ul>
)}
</div>
{/* Footer */}
<div className="border-t border-border bg-surface-2 px-4 py-3">
<div className="mb-2 text-[11px] uppercase tracking-wide text-text-muted">
Selected
</div>
<div className="mb-2 truncate font-mono text-xs" title={path || '(root)'}>
{path ? `${ncUser}/files/${path}` : '(pick a subfolder)'}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Library name"
className="flex-1 rounded border border-border bg-surface px-2 py-1 text-sm"
/>
<button
onClick={() => void submit()}
disabled={!path || !name.trim() || submitting}
className="rounded bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Adding…' : 'Add this folder'}
</button>
</div>
</div>
</div>
</div>
)
}