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>
This commit is contained in:
212
frontend/src/components/dialogs/NextcloudFolderPicker.tsx
Normal file
212
frontend/src/components/dialogs/NextcloudFolderPicker.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Cpu,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Cloud,
|
||||
Copy,
|
||||
Sparkles,
|
||||
Activity,
|
||||
@@ -17,21 +18,26 @@ import {
|
||||
Shield,
|
||||
Brain,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
library,
|
||||
admin as adminApi,
|
||||
account as accountApi,
|
||||
nextcloud as nextcloudApi,
|
||||
type MediaType,
|
||||
type PipelineStage,
|
||||
type ScanStatus,
|
||||
type WorkerStatus,
|
||||
type FeatureFlagSnapshot,
|
||||
type NextcloudSourceRoot,
|
||||
} from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { UserManagement } from '../admin/UserManagement'
|
||||
import { NextcloudFolderPicker } from './NextcloudFolderPicker'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -323,6 +329,8 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<NextcloudIntegrationCard />
|
||||
|
||||
<Section
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
title="Pipeline progress"
|
||||
@@ -1277,3 +1285,282 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Nextcloud integration card ─────────────────────────────────────────
|
||||
//
|
||||
// Lives inside the Library tab. Lets the user (a) review/override their
|
||||
// Nextcloud username, (b) store the Nextcloud app password used for
|
||||
// outgoing WebDAV mutations, and (c) browse + register subfolders of
|
||||
// their Nextcloud `files/` tree as per-user SourceRoots.
|
||||
//
|
||||
// Username defaults to the OIDC `preferred_username` claim on first
|
||||
// login; the override is here for cases where the local mule-image user
|
||||
// doesn't match the Nextcloud user (e.g. authentik `dtoro` vs Nextcloud
|
||||
// `admin`). The app password is set from Nextcloud → Settings →
|
||||
// Security → App passwords; we encrypt at rest and never echo it back.
|
||||
const NEXTCLOUD_ROOTS_KEY = ['settings', 'nextcloud', 'source-roots'] as const
|
||||
|
||||
function NextcloudIntegrationCard() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [usernameInput, setUsernameInput] = useState('')
|
||||
const [passwordInput, setPasswordInput] = useState('')
|
||||
const [whoamiState, setWhoamiState] = useState<
|
||||
{ state: 'idle' } | { state: 'checking' } | { state: 'ok' } | { state: 'bad'; reason: string }
|
||||
>({ state: 'idle' })
|
||||
|
||||
// Initialize the editable fields from the live user.
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setUsernameInput(user.nextcloud_username ?? '')
|
||||
}
|
||||
}, [user?.id, user?.nextcloud_username])
|
||||
|
||||
const rootsQuery = useQuery({
|
||||
queryKey: NEXTCLOUD_ROOTS_KEY,
|
||||
queryFn: () => nextcloudApi.listSourceRoots(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const validate = useCallback(async () => {
|
||||
const candidate = usernameInput.trim()
|
||||
if (!candidate) {
|
||||
setWhoamiState({ state: 'bad', reason: 'no_username' })
|
||||
return
|
||||
}
|
||||
setWhoamiState({ state: 'checking' })
|
||||
try {
|
||||
const r = await nextcloudApi.whoami(candidate)
|
||||
setWhoamiState(r.valid ? { state: 'ok' } : { state: 'bad', reason: r.reason ?? 'invalid' })
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
setWhoamiState({ state: 'bad', reason: message })
|
||||
}
|
||||
}, [usernameInput])
|
||||
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: async (body: { nextcloud_username?: string; nextcloud_app_password?: string }) =>
|
||||
accountApi.updateMe(body),
|
||||
onSuccess: () => {
|
||||
toast.success('Nextcloud settings updated')
|
||||
// Force AuthContext to refetch so the new flags propagate; the
|
||||
// /auth/me endpoint backs both this and the AuthContext.
|
||||
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
|
||||
// Reload page to pick up the new auth user object — cheaper than
|
||||
// wiring a refresh function through context for a one-off save.
|
||||
window.setTimeout(() => window.location.reload(), 600)
|
||||
},
|
||||
onError: (e) => {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
toast.error(`Save failed: ${message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const removeRoot = useMutation({
|
||||
mutationFn: async (id: string) => nextcloudApi.deleteSourceRoot(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Nextcloud library removed')
|
||||
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
|
||||
},
|
||||
onError: (e) => {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
toast.error(`Could not remove: ${message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const roots = rootsQuery.data ?? []
|
||||
const hasPw = user?.has_nextcloud_app_password ?? false
|
||||
const usernameDirty = usernameInput.trim() !== (user?.nextcloud_username ?? '')
|
||||
|
||||
return (
|
||||
<Section
|
||||
icon={<Cloud className="h-4 w-4" />}
|
||||
title="Nextcloud library"
|
||||
>
|
||||
<p className="text-xs text-text-muted">
|
||||
Browse subfolders of your Nextcloud <code>files/</code> tree and
|
||||
register them as photo libraries. Reads stay on the filesystem
|
||||
(fast); uploads, deletes, renames, and moves dispatch through
|
||||
Nextcloud's WebDAV so its database, trashbin, comments, and sync
|
||||
clients stay in sync.
|
||||
</p>
|
||||
|
||||
{/* Username override */}
|
||||
<div className="mt-3">
|
||||
<label className="block text-[11px] uppercase tracking-wide text-text-muted">
|
||||
Nextcloud username
|
||||
</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={usernameInput}
|
||||
onChange={(e) => {
|
||||
setUsernameInput(e.target.value)
|
||||
setWhoamiState({ state: 'idle' })
|
||||
}}
|
||||
placeholder="e.g. admin"
|
||||
className="flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void validate()}
|
||||
disabled={whoamiState.state === 'checking'}
|
||||
>
|
||||
{whoamiState.state === 'checking' ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : whoamiState.state === 'ok' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
'Validate'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!usernameDirty || saveSettings.isPending}
|
||||
onClick={() =>
|
||||
saveSettings.mutate({ nextcloud_username: usernameInput.trim() })
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{whoamiState.state === 'bad' && (
|
||||
<div className="mt-1 flex items-center gap-1 text-[11px] text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{whoamiState.reason === 'no_files_dir'
|
||||
? 'No files/ directory for that user on the mounted Nextcloud volume.'
|
||||
: whoamiState.reason === 'no_username'
|
||||
? 'Enter a username first.'
|
||||
: whoamiState.reason}
|
||||
</div>
|
||||
)}
|
||||
{whoamiState.state === 'ok' && (
|
||||
<div className="mt-1 flex items-center gap-1 text-[11px] text-green-400">
|
||||
<CheckCircle2 className="h-3 w-3" /> Found a Nextcloud files/ directory.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* App password */}
|
||||
<div className="mt-3">
|
||||
<label className="block text-[11px] uppercase tracking-wide text-text-muted">
|
||||
Nextcloud app password{' '}
|
||||
{hasPw && <span className="text-green-400">(set)</span>}
|
||||
</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
placeholder={hasPw ? '•••••••• (leave blank to keep)' : 'Generate in Nextcloud → Settings → Security'}
|
||||
className="flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saveSettings.isPending || (passwordInput.length === 0 && !hasPw)}
|
||||
onClick={() => {
|
||||
if (passwordInput.length > 0) {
|
||||
saveSettings.mutate({ nextcloud_app_password: passwordInput })
|
||||
setPasswordInput('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{hasPw && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={saveSettings.isPending}
|
||||
onClick={() => saveSettings.mutate({ nextcloud_app_password: '' })}
|
||||
title="Clear stored password"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-text-muted">
|
||||
Used as HTTP Basic auth on outgoing WebDAV calls. Stored
|
||||
encrypted at rest with a key derived from the backend{' '}
|
||||
<code>SECRET_KEY</code>; the cleartext is never logged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SourceRoot list + Add button */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] uppercase tracking-wide text-text-muted">
|
||||
Nextcloud-rooted libraries
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!user?.nextcloud_username}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Cloud className="mr-1 h-3 w-3" />
|
||||
Add from Nextcloud
|
||||
</Button>
|
||||
</div>
|
||||
{!user?.nextcloud_username && (
|
||||
<p className="mt-1 text-[11px] text-text-muted">
|
||||
Set + save your Nextcloud username above before adding libraries.
|
||||
</p>
|
||||
)}
|
||||
{rootsQuery.isLoading && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{!rootsQuery.isLoading && roots.length === 0 && (
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
No Nextcloud libraries yet. Click "Add from Nextcloud" once
|
||||
you've validated your username and stored an app password.
|
||||
</p>
|
||||
)}
|
||||
{roots.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{roots.map((r: NextcloudSourceRoot) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center gap-2 rounded border border-border bg-surface px-2 py-1 text-xs"
|
||||
>
|
||||
<Cloud className="h-3 w-3 text-primary/70" />
|
||||
<div className="flex-1 truncate">
|
||||
<div className="font-medium text-text">{r.name}</div>
|
||||
<div className="truncate font-mono text-text-muted" title={r.path}>
|
||||
{r.path}
|
||||
</div>
|
||||
</div>
|
||||
{!r.is_active && (
|
||||
<span className="rounded bg-amber-500/20 px-1 py-0.5 text-[10px] text-amber-400">
|
||||
inactive
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={removeRoot.isPending}
|
||||
onClick={() => removeRoot.mutate(r.id)}
|
||||
title="Remove this library"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NextcloudFolderPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onCreated={() => {
|
||||
queryClient.invalidateQueries({ queryKey: NEXTCLOUD_ROOTS_KEY })
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface AuthUser {
|
||||
is_active: boolean
|
||||
avatar_url: string | null
|
||||
display_name: string | null
|
||||
// Nextcloud integration. `nextcloud_username` is the override (defaults
|
||||
// to OIDC preferred_username on first login). The boolean flag is
|
||||
// server-side only — the cleartext password never reaches the client.
|
||||
nextcloud_username: string | null
|
||||
has_nextcloud_app_password: boolean
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
|
||||
@@ -1108,4 +1108,89 @@ export const features = {
|
||||
},
|
||||
}
|
||||
|
||||
// ── Nextcloud integration ──────────────────────────────────────────────
|
||||
//
|
||||
// Browse the user's Nextcloud `files/` tree, register subfolders as
|
||||
// per-user SourceRoots, and validate the username override. Path
|
||||
// scoping is enforced server-side; all paths in/out are relative to
|
||||
// the user's NC `files/` root.
|
||||
|
||||
export interface NextcloudBrowseEntry {
|
||||
name: string
|
||||
path: string
|
||||
has_children: boolean
|
||||
}
|
||||
|
||||
export interface NextcloudBrowseResponse {
|
||||
nc_username: string
|
||||
rel_path: string
|
||||
parent_rel: string | null
|
||||
entries: NextcloudBrowseEntry[]
|
||||
}
|
||||
|
||||
export interface NextcloudWhoamiResponse {
|
||||
configured: boolean
|
||||
candidate: string | null
|
||||
valid: boolean
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export interface NextcloudSourceRoot {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
is_active: boolean
|
||||
is_nextcloud: true
|
||||
user_id?: string
|
||||
}
|
||||
|
||||
export const nextcloud = {
|
||||
whoami: async (candidate?: string): Promise<NextcloudWhoamiResponse> => {
|
||||
const response = await api.get('/nextcloud/whoami', {
|
||||
params: candidate ? { candidate } : undefined,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
browse: async (path: string = ''): Promise<NextcloudBrowseResponse> => {
|
||||
const response = await api.get('/nextcloud/browse', {
|
||||
params: { path },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
listSourceRoots: async (): Promise<NextcloudSourceRoot[]> => {
|
||||
const response = await api.get('/nextcloud/source-roots')
|
||||
return response.data
|
||||
},
|
||||
|
||||
createSourceRoot: async (
|
||||
name: string,
|
||||
nextcloudPath: string,
|
||||
): Promise<NextcloudSourceRoot> => {
|
||||
const response = await api.post('/nextcloud/source-roots', {
|
||||
name,
|
||||
nextcloud_path: nextcloudPath,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteSourceRoot: async (id: string): Promise<void> => {
|
||||
await api.delete(`/nextcloud/source-roots/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
// ── Account self-service (Nextcloud creds + username override) ─────────
|
||||
export const account = {
|
||||
/** Update the current user's Nextcloud integration settings. Pass an
|
||||
* empty string for `nextcloud_app_password` to clear it. */
|
||||
updateMe: async (body: {
|
||||
nextcloud_username?: string
|
||||
nextcloud_app_password?: string
|
||||
}) => {
|
||||
const response = await api.patch('/auth/me', body)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
Reference in New Issue
Block a user