Add explicit "link passkey to my account" flow
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:
@@ -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,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">
|
||||
|
||||
Reference in New Issue
Block a user