fix(pwa): keep mobile OAuth consent from being swallowed by the installed app (#701)

## Problem

On mobile, starting an OAuth connection from ChatGPT (MCP connector)
opens the **installed Whisper Money PWA** and lands on the **dashboard**
instead of the OAuth consent screen — so the connection can never be
approved.

## Cause

The web app manifest declares `scope: "/"`, so the installed PWA claims
the entire origin, including `/oauth/authorize`. On Android the OAuth
link gets captured into the app, and the default launch behavior just
**focuses the already-open window (the dashboard) and drops the incoming
URL** — the consent page never loads.

Narrowing `scope` to exclude `/oauth` isn't viable: the app's routes are
flat (`/dashboard`, `/transactions`, `/accounts`, …) and share no
prefix, so a narrower scope would push half the app out to the browser.

## Fix

- `site.webmanifest`: add `launch_handler.client_mode: "focus-existing"`
so the launcher hands the app the captured target URL via the launch
queue.
- `app.tsx`: a `launchQueue` consumer that navigates to the target URL
when it's an `/oauth/` path. Every other launch is a no-op (keeps its
place).

Low blast radius — additive manifest field + a guard scoped to `/oauth/`
paths; existing (desktop) OAuth is unaffected.

## Verification

Cannot be tested off-device. After deploy, on the phone:
1. **Reinstall the PWA** (remove from home screen, re-add) so Android
re-fetches the manifest.
2. Retry the ChatGPT OAuth connect → should land on the "Connect ChatGPT
to Whisper Money" consent screen.

Depends on `launch_handler`/`launchQueue` (Chrome/Edge 102+, covers
nearly all Android).
This commit is contained in:
Víctor Falcón 2026-07-18 19:37:04 +02:00 committed by GitHub
parent 4c83cb8b33
commit 2cca291773
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 35 additions and 0 deletions

View File

@ -6,6 +6,9 @@
"scope": "/", "scope": "/",
"id": "/", "id": "/",
"display": "standalone", "display": "standalone",
"launch_handler": {
"client_mode": "focus-existing"
},
"orientation": "portrait-primary", "orientation": "portrait-primary",
"theme_color": "#1b1b18", "theme_color": "#1b1b18",
"background_color": "#ffffff", "background_color": "#ffffff",

View File

@ -34,6 +34,38 @@ import { __, setTranslations } from './utils/i18n';
installChunkLoadRecovery(); installChunkLoadRecovery();
// The installed PWA claims the whole origin (manifest scope "/"), so on mobile an
// OAuth link (e.g. from ChatGPT connecting over MCP) gets captured into the app.
// Paired with launch_handler "focus-existing", the launcher hands us the target
// URL instead of dropping it and showing the dashboard — navigate there so the
// OAuth consent screen actually loads. Non-OAuth launches keep their place.
type LaunchParams = { targetURL?: string };
const launchQueue = (
window as Window & {
launchQueue?: {
setConsumer(consumer: (params: LaunchParams) => void): void;
};
}
).launchQueue;
if (launchQueue) {
launchQueue.setConsumer(({ targetURL }) => {
if (!targetURL) {
return;
}
const target = new URL(targetURL);
if (
target.pathname.startsWith('/oauth/') &&
target.href !== window.location.href
) {
window.location.href = targetURL;
}
});
}
Sentry.init({ Sentry.init({
dsn: import.meta.env.SENTRY_LARAVEL_DSN, dsn: import.meta.env.SENTRY_LARAVEL_DSN,
environment: import.meta.env.MODE, environment: import.meta.env.MODE,