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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user