feat(connections): account linking — Connect flow + callback purpose dispatch#41
Merged
Conversation
…dispatch API just shipped bidirectional linking via a unified provider registry (ag-tech-group/criticalbit-auth-api#40). Expose it on the frontend: - New "Connected accounts" section on /profile lists linked providers (GET /auth/me/connections) and renders Connect buttons for the rest. Click → window.location.href = /auth/{provider}/associate/authorize. - Google and Steam callback pages now decode the `state` JWT payload (no signature verify) and read `purpose`; when `purpose=associate` they forward to /auth/{provider}/associate/callback instead of the login callback. Default to "login" on any parse failure so the existing login flow is preserved bit-for-bit. - New shared OAuthAssociateCompletePage with routes at /callback/google-associate-complete and /callback/steam-associate- complete. The API 302s here on success; the page navigates to /profile?linked=<provider>, which the profile page reads to toast the user and strip the param. - Map the new associate error-detail codes to friendly copy: 409 oauth_account_already_linked → "already linked elsewhere" 400 oauth_state_(invalid|expired|csrf_mismatch) → "session expired" 400 oauth_state_user_mismatch → generic 400 oauth_verify_failed → "provider rejected the response" Disconnect, the 409 unlink-would-strand-user handling, and the "set a password" entry point are deferred to PR-B.
✅ Deploy Preview for criticalbit-auth-web ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
4 tasks
amrtgaber
added a commit
that referenced
this pull request
May 27, 2026
#42) PR-B of two for #39. Builds on the Connect flow from #41 to close out the issue: removal of links, the safety rule, and the OAuth-first password on-ramp. - Plumb has_usable_password from /auth/me into the auth context. Default to false on missing/stale responses so the UI stays conservative. - Disconnect button per linked provider. Disabled with an inline hint ("You'd have no way to sign in. Set a password first or link another provider.") when removing it would strand the user. Mirrors the server-side rule so users don't have to click to learn it's blocked: canUnlink = otherConnections.length > 0 || (hasUsablePassword && email) - DELETE /auth/me/connections/{provider} on click. 204 refreshes the list + auth state and toasts success. The 409 unlink_would_strand_user response renders the message + remediation list inline; remediation strings matching /set a password/ get a "Start now" Link pointing at /forgot-password?email=<theirs>. The 404 connection_not_found response refreshes the list and shows a generic toast. - "Set a password" CTA on /profile when has_usable_password=false, linking to /forgot-password?email=<theirs>. The forgot-password route now reads ?email= via validateSearch and prefills the input; the authenticated-user guard relaxes when ?email= is present so the CTA works without forcing a logout. Closes the frontend portion of #39 once both PRs are on main.
This was referenced May 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR-A of two for #39. Ships the read-only connections section and the full Connect round-trip; PR-B will add Disconnect, the stranding guard, and the "Set a password" entry point.
Summary
/profile— callsGET /auth/me/connections, renders linked providers with "Connected as " and an enabled Connect button for the rest. Click →window.location.href = ${API}/auth/{provider}/associate/authorize. Steam goes direct (the endpoint 307s to Steam's OpenID); Google fetches{authorization_url}first since its dev mode returns JSON.google-callback-pageandsteam-callback-pagenow decode thestateJWT (base64 of the payload segment — no signature verify) and readpurpose.purpose=associateroutes the fetch to/auth/{provider}/associate/callback; anything else (or any parse failure) routes to the existing login callback, so existing login flows are unchanged.OAuthAssociateCompletePageat/callback/google-associate-completeand/callback/steam-associate-complete. The API 302s here on success; the page navigates to/profile?linked=<provider>, which the profile page reads to toast "Linked your account." and then strips viauseNavigate({ replace: true }).oauth_account_already_linked→ "This account is already linked to another criticalbit account. Unlink it there first."oauth_state_invalid/oauth_state_expired/oauth_csrf_mismatch→ "Your link session expired. Please return to your profile and try again."oauth_state_user_mismatch→ generic "something went wrong"oauth_verify_failed→ " rejected the response. Please try again."What's not in this PR (PR-B)
has_usable_passwordthrough the auth context.Pairs with
Test plan
Unit (vitest, 12 new tests, 39 total — all passing)
oauth-state.test.ts— state-purpose decoding: login default, associate parse, malformed → login fallback, missing-purpose → login fallback.google-callback-page.test.tsx— login dispatch unchanged (OAUTH_USER_ALREADY_EXISTS, generic); associate dispatch routes to the right URL and surfaces 400oauth_state_expiredand 409oauth_account_already_linkedwith friendly copy.steam-callback-page.test.tsx— associate dispatch routes correctly; login-purpose failures keep the existing generic copy.profile-page.test.tsx— Connected-as rendering for the linked provider, Connect buttons for the unlinked, graceful empty-list fallback when/auth/me/connections500s, and the?linked=toast.Manual end-to-end (Playwright against real API + Postgres)
Visual coverage (no OAuth round-trip — would need real Google/Steam creds):
/profileoauth_accountrow directly → refresh/profile${API}/auth/steam/associate/authorize, API returns{detail: {code: "provider_not_enabled"}}✅ — confirms the wiring is correct end-to-endNot covered end-to-end
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETorSTEAM_API_KEYin the local API env. Unit tests cover the dispatch + error mapping; the live UI test confirms the request shape.Reviewer checklist
pnpm test:run/profile— the Connected accounts section should appear between Privacy and Danger zone with two "Not connected" rows + Connect buttons./auth/{provider}/associate/authorize. With no provider creds set, the API returnsprovider_not_enabled, which is fine for this PR.