Skip to content

feat(connections): account linking — Connect flow + callback purpose dispatch#41

Merged
amrtgaber merged 1 commit into
mainfrom
feat/account-connections
May 26, 2026
Merged

feat(connections): account linking — Connect flow + callback purpose dispatch#41
amrtgaber merged 1 commit into
mainfrom
feat/account-connections

Conversation

@amrtgaber
Copy link
Copy Markdown
Contributor

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

  • New "Connected accounts" section on /profile — calls GET /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-page and steam-callback-page now decode the state JWT (base64 of the payload segment — no signature verify) and read purpose. purpose=associate routes 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.
  • New shared OAuthAssociateCompletePage 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 "Linked your account." and then strips via useNavigate({ replace: true }).
  • Error copy for the new associate detail codes:
    • 409 oauth_account_already_linked"This account is already linked to another criticalbit account. Unlink it there first."
    • 400 oauth_state_invalid / oauth_state_expired / oauth_csrf_mismatch"Your link session expired. Please return to your profile and try again."
    • 400 oauth_state_user_mismatch → generic "something went wrong"
    • 400 oauth_verify_failed" rejected the response. Please try again."

What's not in this PR (PR-B)

  • Disconnect buttons, the client-side stranding guard, and DELETE 409/404 handling.
  • Plumbing has_usable_password through the auth context.
  • "Set a password" entry point for OAuth-first users.

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 400 oauth_state_expired and 409 oauth_account_already_linked with 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/connections 500s, 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):

Scenario Result
Fresh registration → /profile "Connected accounts" section renders with both providers showing "Not connected" + Connect button ✅
Inserted oauth_account row directly → refresh /profile Google row shows "Connected as player@gmail.com", no Connect button; Steam still shows Connect ✅
Click Connect on Steam Browser navigates to ${API}/auth/steam/associate/authorize, API returns {detail: {code: "provider_not_enabled"}} ✅ — confirms the wiring is correct end-to-end

Not covered end-to-end

  • Full Google or Steam associate round-trip — needs GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or STEAM_API_KEY in the local API env. Unit tests cover the dispatch + error mapping; the live UI test confirms the request shape.

Reviewer checklist

  • pnpm test:run
  • Locally, register a fresh user and view /profile — the Connected accounts section should appear between Privacy and Danger zone with two "Not connected" rows + Connect buttons.
  • Click Connect on either provider — expect a redirect to /auth/{provider}/associate/authorize. With no provider creds set, the API returns provider_not_enabled, which is fine for this PR.

…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.
@netlify
Copy link
Copy Markdown

netlify Bot commented May 26, 2026

Deploy Preview for criticalbit-auth-web ready!

Name Link
🔨 Latest commit 00a77ad
🔍 Latest deploy log https://app.netlify.com/projects/criticalbit-auth-web/deploys/6a16132185eb87000870dbdc
😎 Deploy Preview https://deploy-preview-41--criticalbit-auth-web.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@amrtgaber amrtgaber merged commit c9fee2a into main May 26, 2026
6 checks passed
@amrtgaber amrtgaber deleted the feat/account-connections branch May 26, 2026 21:55
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant