Files
MileVault/milevault_export/frontend/src/pages/LoginPage.jsx
T
owain 0e4bc7b444 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>
2026-06-08 13:19:55 +01:00

108 lines
3.9 KiB
React

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../hooks/useAuth'
import { useQuery } from '@tanstack/react-query'
import api from '../utils/api'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = 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()
const { data: pocketidData } = useQuery({
queryKey: ['pocketid-available'],
queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
})
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
try {
await login(username, password)
navigate('/')
} catch (err) {
setError(err.response?.data?.detail || 'Login failed')
}
}
const handlePocketID = async () => {
const { data } = await api.get('/auth/pocketid/login-url')
window.location.href = data.url
}
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white">
<span className="text-blue-400">Mile</span>Vault
</h1>
<p className="text-gray-500 mt-2 text-sm">Your personal fitness dashboard</p>
</div>
<div className="bg-gray-900 rounded-2xl p-6 border border-gray-800">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-1">Username</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="current-password"
required
/>
</div>
{error && (
<p className="text-red-400 text-xs">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 rounded-lg text-sm transition-colors"
>
{isLoading ? 'Signing in…' : 'Sign in'}
</button>
</form>
{pocketidData?.available && (
<>
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-gray-800" />
<span className="text-xs text-gray-600">or</span>
<div className="flex-1 h-px bg-gray-800" />
</div>
<button
onClick={handlePocketID}
className="w-full bg-gray-800 hover:bg-gray-700 text-gray-300 font-medium py-2.5 rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
>
🔑 Sign in with passkey
</button>
</>
)}
</div>
</div>
</div>
)
}