Multi-user via PocketID: account linking, group gating, admin user management

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>
This commit is contained in:
2026-06-08 13:19:55 +01:00
parent bc4d68da07
commit 0e4bc7b444
46 changed files with 3282 additions and 588 deletions
@@ -1,10 +1,38 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useDropzone } from 'react-dropzone'
import { useMutation } from '@tanstack/react-query'
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) => {
@@ -16,7 +44,11 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
return { file: file.name, ...data }
},
onSuccess: (data) => {
setTasks(t => [...t, { ...data, status: 'queued' }])
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
setTasks(t => [...t, task])
if (data.task_id) {
pollTask(data.task_id)
}
},
})
@@ -30,6 +62,13 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
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">
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)}
<span className="ml-2 text-green-400"> Queued</span>
<StatusBadge status={task.status} />
</div>
))}
</div>