Add explicit "link passkey to my account" flow
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 30s
Build and push images / build-worker (push) Successful in 30s
Build and push images / build-frontend (push) Successful in 34s

Signing in by passkey on a fresh install created a new empty account because
the seeded admin has no email to match on. Add canonical SSO-style linking: an
authenticated user starts an OIDC flow whose `state` is a signed, short-lived
"link to user N" token (purpose=pocketid-link). The callback detects that state
and attaches the returned identity to that account instead of creating/matching
one — no reliance on emails lining up, and no group gating (the initiator is
already authorised; this is identity linking, not access control).

- auth.py: _make_link_state/_decode_link_state, GET /pocketid/link-url, callback
  handles state (rejects if the passkey is already on another account →
  auth_error=passkey_in_use). Expose has_passkey on /auth/me.
- Profile: "Passkey sign-in" section for all users — shows linked state or a
  "Link a passkey to this account" button; success banner on return.
- Login: messages for passkey_in_use / link_failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:11:30 +01:00
parent e0ddc4cbf4
commit e5feeb1178
6 changed files with 270 additions and 16 deletions
+7 -5
View File
@@ -4,15 +4,17 @@ import { useAuthStore } from '../hooks/useAuth'
import { useQuery } from '@tanstack/react-query'
import api from '../utils/api'
const AUTH_ERRORS = {
not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.",
passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.",
link_failed: "Couldn't link the passkey. Please try again.",
}
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 [error, setError] = useState(AUTH_ERRORS[authError] || '')
const { login, isLoading } = useAuthStore()
const navigate = useNavigate()
+43 -1
View File
@@ -43,13 +43,34 @@ function SaveButton({ onClick, loading, saved, label = 'Save' }) {
export default function ProfilePage() {
const qc = useQueryClient()
const { user } = useAuthStore()
const { user, fetchUser } = useAuthStore()
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data),
})
// Passkey linking (available to all users when PocketID is configured)
const { data: pocketidAvailable } = useQuery({
queryKey: ['pocketid-available'],
queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
})
const [showLinked, setShowLinked] = useState(
new URLSearchParams(window.location.search).get('linked') === '1'
)
useEffect(() => {
if (showLinked) {
fetchUser() // refresh has_passkey
window.history.replaceState({}, '', '/profile')
const t = setTimeout(() => setShowLinked(false), 6000)
return () => clearTimeout(t)
}
}, [])
const handleLinkPasskey = async () => {
const { data } = await api.get('/auth/pocketid/link-url')
window.location.href = data.url
}
const { data: pocketidConfig } = useQuery({
queryKey: ['pocketid-config'],
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
@@ -325,6 +346,27 @@ export default function ProfilePage() {
/>
</Section>
{/* Passkey sign-in — available to all users when PocketID is configured */}
{pocketidAvailable?.available && (
<Section title="🔑 Passkey sign-in">
{user?.has_passkey ? (
<p className="text-sm text-green-400"> A passkey is linked to this account you can sign in with PocketID.</p>
) : (
<>
<p className="text-xs text-gray-500">
Link your PocketID passkey to <strong>this</strong> account so you can sign in with a passkey
instead of being given a separate, empty account.
</p>
<button onClick={handleLinkPasskey}
className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2">
🔑 Link a passkey to this account
</button>
</>
)}
{showLinked && <p className="text-green-400 text-sm"> Passkey linked to your account.</p>}
</Section>
)}
{/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500">