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
+4 -10
View File
@@ -8,9 +8,11 @@ import ActivitiesPage from './pages/ActivitiesPage'
import ActivityDetailPage from './pages/ActivityDetailPage'
import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage'
import SegmentsPage from './pages/SegmentsPage'
import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage'
import ProfilePage from './pages/ProfilePage'
import UsersPage from './pages/UsersPage'
function RequireAuth({ children }) {
const token = useAuthStore((s) => s.token)
@@ -25,16 +27,6 @@ export default function App() {
if (token) fetchUser()
}, [token])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
localStorage.setItem('token', urlToken)
useAuthStore.setState({ token: urlToken })
window.history.replaceState({}, '', '/')
}
}, [])
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
@@ -44,9 +36,11 @@ export default function App() {
<Route path="activities/:id" element={<ActivityDetailPage />} />
<Route path="health" element={<HealthPage />} />
<Route path="routes" element={<RoutesPage />} />
<Route path="segments" element={<SegmentsPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="users" element={<UsersPage />} />
</Route>
</Routes>
)