0e4bc7b444
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the data layer was already fully user-scoped. This adds the missing pieces for running real multi-user: - auth.py callback: link by email to an existing un-linked account (so the admin keeps their data when first signing in by passkey), collision-safe username generation, and request the `groups` scope. - Group gating: optional pocketid_allowed_group (admin-config or POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the callback and redirected to /login?auth_error=not_authorized. - New admin users API (app/api/users.py): list users, promote/demote admin (guards against demoting/locking out the last admin or yourself), and delete a user with ordered bulk deletes of all their data + on-disk files. - ProfilePage: allowed-group field; LoginPage: rejected-login message; Layout: admin-only Users nav; new UsersPage. Resync milevault_export to current source (it had drifted many features behind — missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own CI) and add POCKETID_ALLOWED_GROUP to .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
8.1 KiB
React
218 lines
8.1 KiB
React
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
import { useDropzone } from 'react-dropzone'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import api from '../utils/api'
|
|
|
|
function UploadZone({ title, description, accept, endpoint, icon }) {
|
|
const [tasks, setTasks] = useState([])
|
|
const queryClient = useQueryClient()
|
|
const intervalsRef = useRef({})
|
|
|
|
const pollTask = useCallback((taskId) => {
|
|
if (intervalsRef.current[taskId]) return
|
|
const intervalId = setInterval(async () => {
|
|
try {
|
|
const { data } = await api.get(`/upload/task/${taskId}`)
|
|
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
|
|
clearInterval(intervalsRef.current[taskId])
|
|
delete intervalsRef.current[taskId]
|
|
setTasks(ts => ts.map(t =>
|
|
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t
|
|
))
|
|
if (data.status === 'SUCCESS') {
|
|
queryClient.invalidateQueries({ queryKey: ['activities'] })
|
|
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
|
|
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
|
|
}
|
|
}
|
|
} catch { /* ignore transient poll errors */ }
|
|
}, 2000)
|
|
intervalsRef.current[taskId] = intervalId
|
|
}, [queryClient])
|
|
|
|
useEffect(() => {
|
|
return () => { Object.values(intervalsRef.current).forEach(clearInterval) }
|
|
}, [])
|
|
|
|
const upload = useMutation({
|
|
mutationFn: async (file) => {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
const { data } = await api.post(endpoint, form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
})
|
|
return { file: file.name, ...data }
|
|
},
|
|
onSuccess: (data) => {
|
|
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
|
|
setTasks(t => [...t, task])
|
|
if (data.task_id) {
|
|
pollTask(data.task_id)
|
|
}
|
|
},
|
|
})
|
|
|
|
const onDrop = useCallback((accepted) => {
|
|
accepted.forEach(file => upload.mutate(file))
|
|
}, [upload])
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept,
|
|
multiple: true,
|
|
})
|
|
|
|
function StatusBadge({ status }) {
|
|
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse">⏳ Processing</span>
|
|
if (status === 'done') return <span className="ml-2 text-green-400">✓ Done</span>
|
|
if (status === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
|
return <span className="ml-2 text-green-400">✓ Queued</span>
|
|
}
|
|
|
|
return (
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<span className="text-2xl">{icon}</span>
|
|
<div>
|
|
<h3 className="font-semibold text-white">{title}</h3>
|
|
<p className="text-xs text-gray-500">{description}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
{...getRootProps()}
|
|
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
|
isDragActive
|
|
? 'border-blue-500 bg-blue-950/30'
|
|
: 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
|
|
}`}
|
|
>
|
|
<input {...getInputProps()} />
|
|
{isDragActive ? (
|
|
<p className="text-blue-400 text-sm">Drop files here…</p>
|
|
) : (
|
|
<div>
|
|
<p className="text-gray-400 text-sm">Drag & drop files here, or click to browse</p>
|
|
<p className="text-gray-600 text-xs mt-1">
|
|
{Object.values(accept).flat().join(', ')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{upload.isPending && (
|
|
<p className="text-xs text-blue-400 mt-2 animate-pulse">Uploading…</p>
|
|
)}
|
|
|
|
{tasks.length > 0 && (
|
|
<div className="mt-4 space-y-2">
|
|
{tasks.map((task, i) => (
|
|
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
|
|
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
|
{task.activity_tasks !== undefined && (
|
|
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
|
)}
|
|
<StatusBadge status={task.status} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function UploadPage() {
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Import Data</h1>
|
|
<p className="text-gray-500 text-sm mt-1">
|
|
Import activities from Garmin or Strava. Large exports are processed in the background.
|
|
</p>
|
|
</div>
|
|
|
|
{/* How to export guides */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<div className="bg-blue-950/30 border border-blue-900/50 rounded-xl p-4 text-sm">
|
|
<h3 className="font-semibold text-blue-300 mb-2">📥 How to export from Garmin Connect</h3>
|
|
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
|
<li>Go to Garmin Connect → Profile → Account</li>
|
|
<li>Scroll to Data Management → Export Your Data</li>
|
|
<li>Request export and wait for the email</li>
|
|
<li>Download and upload the ZIP file below</li>
|
|
</ol>
|
|
</div>
|
|
<div className="bg-orange-950/20 border border-orange-900/40 rounded-xl p-4 text-sm">
|
|
<h3 className="font-semibold text-orange-300 mb-2">📥 How to export from Strava</h3>
|
|
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
|
<li>Go to strava.com → Settings → My Account</li>
|
|
<li>Scroll to Download or Delete Your Account</li>
|
|
<li>Click "Request Your Archive"</li>
|
|
<li>Download and upload the ZIP file below</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
{/* Single FIT/GPX */}
|
|
<UploadZone
|
|
title="Single activity"
|
|
description="Upload a .fit or .gpx file"
|
|
icon="🏃"
|
|
endpoint="/upload/activity"
|
|
accept={{
|
|
'application/octet-stream': ['.fit'],
|
|
'application/gpx+xml': ['.gpx'],
|
|
'text/xml': ['.gpx'],
|
|
}}
|
|
/>
|
|
|
|
{/* Garmin full export */}
|
|
<UploadZone
|
|
title="Garmin Connect export"
|
|
description="Upload your full Garmin data export ZIP"
|
|
icon="⌚"
|
|
endpoint="/upload/garmin-export"
|
|
accept={{ 'application/zip': ['.zip'] }}
|
|
/>
|
|
|
|
{/* Strava export */}
|
|
<UploadZone
|
|
title="Strava bulk export"
|
|
description="Upload your Strava archive ZIP"
|
|
icon="🚴"
|
|
endpoint="/upload/strava-export"
|
|
accept={{ 'application/zip': ['.zip'] }}
|
|
/>
|
|
|
|
{/* Ongoing FIT files */}
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<span className="text-2xl">🔄</span>
|
|
<div>
|
|
<h3 className="font-semibold text-white">Ongoing sync</h3>
|
|
<p className="text-xs text-gray-500">Automatically import new Garmin watch files</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-3 text-xs text-gray-500">
|
|
<p>After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:</p>
|
|
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono">
|
|
GARMIN/Activity/*.fit
|
|
</code>
|
|
<p>Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:</p>
|
|
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono whitespace-pre">
|
|
{`# Example: auto-upload new FIT files
|
|
inotifywait -m ~/Garmin/Activity/ -e create \\
|
|
--format '%f' | while read file; do
|
|
curl -X POST /api/upload/activity \\
|
|
-H "Authorization: Bearer TOKEN" \\
|
|
-F "file=@$file"
|
|
done`}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|