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
+2
View File
@@ -12,6 +12,7 @@ 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)
@@ -39,6 +40,7 @@ export default function App() {
<Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="users" element={<UsersPage />} />
</Route>
</Routes>
)
+2 -1
View File
@@ -10,6 +10,7 @@ const nav = [
{ to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' },
{ to: '/profile', label: 'Profile', icon: '⚙️' },
{ to: '/users', label: 'Users', icon: '👥', adminOnly: true },
]
export default function Layout() {
@@ -32,7 +33,7 @@ export default function Layout() {
</div>
<nav className="flex-1 py-4 overflow-y-auto">
{nav.map(({ to, label, icon, exact }) => (
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
+6 -1
View File
@@ -7,7 +7,12 @@ import api from '../utils/api'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const authError = new URLSearchParams(window.location.search).get('auth_error')
const [error, setError] = useState(
authError === 'not_authorized'
? "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group."
: ''
)
const { login, isLoading } = useAuthStore()
const navigate = useNavigate()
+6 -2
View File
@@ -205,10 +205,10 @@ export default function ProfilePage() {
}
// PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
const [pidSaved, setPidSaved] = useState(false)
useEffect(() => {
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '', allowed_group: pocketidConfig.allowed_group || '' })
}, [pocketidConfig])
const savePocketID = useMutation({
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
@@ -471,6 +471,10 @@ export default function ProfilePage() {
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
</Field>
<Field label="Allowed PocketID group" hint="Only members of this PocketID group may sign in. Leave blank to allow all.">
<Input value={pidForm.allowed_group} placeholder="e.g. milevault-users"
onChange={e => setPidForm(f => ({ ...f, allowed_group: e.target.value }))} />
</Field>
{pocketidConfig?.enabled && (
<p className="text-xs text-green-400"> PocketID is currently active</p>
)}
+98
View File
@@ -0,0 +1,98 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
export default function UsersPage() {
const qc = useQueryClient()
const { user: me } = useAuthStore()
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users/').then(r => r.data),
})
const setAdmin = useMutation({
mutationFn: ({ id, is_admin }) => api.patch(`/users/${id}`, { is_admin }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
onError: e => alert(e.response?.data?.detail || 'Failed to update user'),
})
const deleteUser = useMutation({
mutationFn: id => api.delete(`/users/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
onError: e => alert(e.response?.data?.detail || 'Failed to delete user'),
})
const handleDelete = u => {
if (confirm(`Delete ${u.username} and ALL of their data (activities, routes, health, records)? This cannot be undone.`)) {
deleteUser.mutate(u.id)
}
}
return (
<div className="p-6 max-w-3xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Users</h1>
<p className="text-xs text-gray-500 mt-1">
New users are created in PocketID and provisioned automatically on first passkey sign-in.
Each user's data is fully separate.
</p>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
{isLoading ? (
<p className="p-5 text-sm text-gray-500">Loading…</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Sign-in</th>
<th className="px-4 py-3 font-medium text-right">Activities</th>
<th className="px-4 py-3 font-medium text-center">Admin</th>
<th className="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{users?.map(u => {
const isMe = u.id === me?.id
return (
<tr key={u.id} className="border-b border-gray-800/60 last:border-0">
<td className="px-4 py-3">
<div className="text-white">@{u.username}{isMe && <span className="text-gray-500"> (you)</span>}</div>
{u.email && <div className="text-xs text-gray-500">{u.email}</div>}
</td>
<td className="px-4 py-3 text-gray-400">
{u.has_passkey ? '🔑 Passkey' : '🔒 Password'}
</td>
<td className="px-4 py-3 text-right text-gray-300">{u.activity_count}</td>
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={u.is_admin}
disabled={isMe || setAdmin.isPending}
onChange={e => setAdmin.mutate({ id: u.id, is_admin: e.target.checked })}
className="w-4 h-4 accent-blue-500 disabled:opacity-40"
title={isMe ? "You can't change your own admin status" : ''}
/>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(u)}
disabled={isMe || deleteUser.isPending}
className="text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs transition-colors"
title={isMe ? "You can't delete your own account" : ''}
>
Delete
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}