Skip to content

fix: show OAuth callback errors instead of an endless spinner - #830

Open
doowta wants to merge 2 commits into
OpenSecretCloud:masterfrom
doowta:fix/oauth-callback-error-native-flow
Open

fix: show OAuth callback errors instead of an endless spinner#830
doowta wants to merge 2 commits into
OpenSecretCloud:masterfrom
doowta:fix/oauth-callback-error-native-flow

Conversation

@doowta

@doowta doowta commented Aug 23, 2026

Copy link
Copy Markdown

Hi. I was signing in to try Maple and got stuck, so I went looking. It turned out to be a small
thing in the OAuth callback route, not anything to do with auth itself. Sharing in case it's
useful. No pressure either way.

What happens

I have an old account made with email and password. On the desktop app I clicked "Sign in with
Google", the browser opened, Google was happy, and then the page sat on "Processing Google Login /
Completing authentication..." forever. No error, nothing to click.

The backend is already doing the right thing. It answers 409 UserExistsNotLinked because the
email belongs to an account this Google identity isn't linked to. That's correct. Linking on a
matching email would be an account takeover vector, and here it would hand over the seed. The 409
just never reaches the screen.

Why

In frontend/src/routes/auth.$provider.callback.tsx the render branches are ordered:

if (nativeRedirectUrl)                              -> success + Open Maple
if (localStorage["redirect-to-native"] === "true")  -> "Completing authentication..." + spinner
if (isProcessing)                                   -> spinner
if (error)                                          -> error card

redirect-to-native is only cleared in handleSuccessfulAuth, not in handleAuthError. So on the
desktop path the error state gets set, the component re-renders, hits the native branch first, and
the error card below it is never reached. This isn't specific to the 409. Any callback failure
lands there.

There's a knock-on effect that made it confusing to diagnose from outside. /desktop-auth sets
that flag in the browser's localStorage on trymaple.ai, and nothing clears it on failure. So
afterwards, a plain web sign-in in the same browser either shows the same stuck spinner, or on
success deep-links to cloud.opensecret.maple:// instead of continuing in the browser. One failed
desktop attempt makes web sign-in look broken too.

#294 covered the deep link failing after a success. This is the error path, which I think just
never had reachable UI.

The change

Small. Clear the flag in handleAuthError, and move the if (error) branch above the native
branch. Either one fixes the hang. Both together also stop the stale flag leaking into web sign-in.

I also gave the 409 its own copy, because "try again" is the one thing that can't work here.
Retrying the same provider hits the same wall. It now says what happened and points at the method
that will work.

before after
Processing Google Login, completing authentication, with a spinner This email already has a Maple account, with Go to log in and Back buttons

On not leaking who has an account

This was the first thing I checked, since the message tells someone an account exists. I don't
think it widens disclosure:

  • It's only reachable after Google has authenticated the person and returned the address from its
    own userinfo endpoint, with email_verified enforced in fetch_google_user, and after
    consume_state has validated the one-time state. You have to control the Google account to see
    it, so it can't be used to probe other people's addresses.
  • The text is unchanged from what the SDK already produced (api.ts maps "User exists" to "An
    account with this email already exists"). Web users already saw that sentence. Desktop users saw
    nothing at all. Same disclosure, same audience, just no longer a dead end.
  • The copy doesn't say which method the account uses, since the 409 doesn't tell the client that.
    It suggests email and password as the likely one.

Happy to soften the wording if you'd rather it said less.

The loading indicator (second commit, take it or leave it)

While I was in there I noticed the auth screens use a generic spinner, so I tried something: the
Maple mark walking its own wordmark, M, A, P, L, E.

The Maple mark morphing through M, A, P, L and E in light, dark and coral

Each letter of the mark is a single closed contour, so all five resample to the same point count
and interpolate directly. That means no morph library and no new dependency. It's 2.1 KB gzipped,
about 0.03 ms per frame, and holds a static mark under prefers-reduced-motion. It paints with
currentColor, so it takes the surrounding text colour rather than introducing one of its own.
The three panels above are the same component, not three variants.

Two things to flag:

  • It's built from the wordmark on trymaple.ai (figma/home/header-logo.svg), which is a different
    logo from the one the app ships in public/maple-logo.svg. So it assumes the site's mark is the
    current one. Tell me if that's wrong.
  • It's an aesthetic call, not a bug fix. That's why it's a separate commit. Drop it and the fix
    still stands.

Testing

Added auth.$provider.callback.test.tsx (5 tests). They fail on master and pass with the fix.
The first one asserts the desktop path shows the failure instead of "Completing authentication...".
MapleLoadingMark.test.tsx adds 6 more. One pins the frame ordering, another checks the component
stops its animation frame when it unmounts.

Ran what CI runs (scripts/ci/frontend.sh): format:check, lint, typecheck, test. 775 pass,
0 fail. Lint has 0 errors. The one warning in the file I touched is pre-existing, confirmed on an
unmodified checkout.

I also drove the whole flow end to end against a small local stand-in for OpenSecret, so the before
and after above are real screenshots rather than a description. Happy to share that harness, though
you can probably reproduce it faster in your own dev stack.


There's a bigger question underneath this that I didn't touch here. Once you hit that 409 there's
no way to link the two, because there's no endpoint or UI to attach a provider to an existing
account. I wrote that up separately rather than mixing it in. It also looks entangled with the
recovery credential design in opensecret#286, so it felt like a question rather than a patch.

doowta added 2 commits August 23, 2026 16:28
The callback route checked the `redirect-to-native` flag before it checked
`error`, and only cleared that flag on success. So on the desktop and mobile
flow a failed callback set the error state, re-rendered, matched the native
branch first, and left the user on "Completing authentication..." with no way
forward. The error card below it was unreachable.

Clear the flag in handleAuthError and move the error branch above the native
one. Either change fixes the hang; together they also stop a stale flag from
making a later web sign-in deep-link into the desktop app.

Also give 409 UserExistsNotLinked its own copy. That case cannot be solved by
retrying the same provider, so pointing at "Try Again" sends people back into
the same wall. It now says the email already has an account and links to the
login screen.
The auth screens showed a generic spinner. This replaces it with the Maple
mark walking its own wordmark, M, A, P, L, E.

Every letter of the mark on trymaple.ai is a single closed contour with no
counter, so all five resample to the same point count and interpolate
directly. That is what avoids a morph library: `d` is a lerp between two
equal-length rings. Geometry is derived once from the wordmark's own path
data, so the animation follows the logo asset instead of drifting from it.

No new dependency, 2.1 KB gzipped, roughly 0.03 ms per frame. It paints with
currentColor and holds a static mark under prefers-reduced-motion.

This is an aesthetic change rather than a fix, so it is a separate commit and
can be dropped without touching the one before it.
@doowta
doowta marked this pull request as ready for review August 23, 2026 21:50
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