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>
213 lines
7.7 KiB
TypeScript
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>
|
|
)
|
|
}
|