diff --git a/README.md b/README.md
index 205f4e0..93de6a2 100644
--- a/README.md
+++ b/README.md
@@ -111,11 +111,14 @@ forwards only the supported CalorieApp endpoints to the configured backend and
keeps mobile authentication sessions first-party.
Xaman sign-in opens in a separate tab while the original CalorieApp tab waits
-for a short-lived, one-time browser handoff. If Android returns from Xaman in a
-different default browser, the callback browser receives its normal session and
-the original tab securely claims a separate session for the same user. Only
-hashes of the handoff proof are stored, the proof is never sent through
-WordPress/Xaman URLs, and it cannot be claimed by a third browser after use.
+for a short-lived, one-time browser handoff. Every CalorieApp-owned Xaman login
+surface must clearly warn phone users before and during sign-in that the return
+page normally opens in their configured default browser, possibly in a new tab,
+and that they should keep the original CalorieApp tab open. The callback browser
+receives its normal session and the original tab securely claims a separate
+session for the same user. Only hashes of the handoff proof are stored, the
+proof is never sent through WordPress/Xaman URLs, and it cannot be claimed by a
+third browser after use.
Create frontend/.env.local from the template before running the frontend.
diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx
index f233bfb..23878be 100644
--- a/frontend/app/auth/callback/page.tsx
+++ b/frontend/app/auth/callback/page.tsx
@@ -156,8 +156,14 @@ function AuthCallbackContent() {
Returning to CalorieApp
+
+ Phone browser notice: Xaman normally opens this return page in your
+ configured default browser, possibly in a new tab. Your original
+ CalorieApp tab can remain open and will sign in automatically too.
+
+
@@ -192,8 +198,14 @@ export default function AuthCallbackPage() {
CalorieApp Sign-In
+
+ Phone browser notice: Xaman normally opens this return page in
+ your configured default browser, possibly in a new tab. Keep the
+ original CalorieApp tab open.
+
+
diff --git a/frontend/app/auth/complete/page.tsx b/frontend/app/auth/complete/page.tsx
index 3f8b14d..54662ea 100644
--- a/frontend/app/auth/complete/page.tsx
+++ b/frontend/app/auth/complete/page.tsx
@@ -45,10 +45,13 @@ function LoginCompleteContent() {
Sign-in completed
-
+
+ Phone browser notice: Xaman normally opens this return page in your
+ configured default browser, possibly in a new tab.
+
+
Your original CalorieApp tab is signing in automatically. You can
- close this tab and return there, even if Xaman opened this page in
- your phone's default browser.
+ close this tab and return there.
You are also signed in in this browser, so continuing here is safe.
diff --git a/frontend/app/auth/launching/page.tsx b/frontend/app/auth/launching/page.tsx
index 6300739..90075e7 100644
--- a/frontend/app/auth/launching/page.tsx
+++ b/frontend/app/auth/launching/page.tsx
@@ -1,4 +1,73 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const XAMAN_LAUNCH_MESSAGE_TYPE = "calorieapp-xaman-navigate";
+const XAMAN_LAUNCH_ERROR_TYPE = "calorieapp-xaman-error";
+
+type XamanLaunchMessage = {
+ type?: unknown;
+ attemptId?: unknown;
+ url?: unknown;
+};
+
+function isAllowedXamanSigninUrl(value: string): boolean {
+ try {
+ const target = new URL(value);
+ return (
+ target.protocol === "https:" &&
+ target.hostname === "calorietoken.net" &&
+ target.searchParams.has("xl-signin")
+ );
+ } catch {
+ return false;
+ }
+}
+
export default function XamanLaunchingPage() {
+ const [message, setMessage] = useState(
+ "Waiting for CalorieApp to prepare the secure Xaman request. During heavy traffic this can take up to two minutes; do not refresh either tab."
+ );
+
+ useEffect(() => {
+ const attemptId = new URLSearchParams(window.location.search).get(
+ "attempt"
+ );
+
+ const handleMessage = (event: MessageEvent) => {
+ if (
+ event.origin !== window.location.origin ||
+ event.source !== window.opener ||
+ !event.data ||
+ event.data.attemptId !== attemptId
+ ) {
+ return;
+ }
+
+ if (event.data.type === XAMAN_LAUNCH_ERROR_TYPE) {
+ setMessage(
+ "CalorieApp could not start Xaman. Return to the original tab and try again after a short wait."
+ );
+ return;
+ }
+
+ if (
+ event.data.type !== XAMAN_LAUNCH_MESSAGE_TYPE ||
+ typeof event.data.url !== "string" ||
+ !isAllowedXamanSigninUrl(event.data.url)
+ ) {
+ return;
+ }
+
+ setMessage("Opening Xaman now...");
+ window.opener = null;
+ window.location.replace(event.data.url);
+ };
+
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, []);
+
return (
@@ -6,9 +75,16 @@ export default function XamanLaunchingPage() {
Preparing Xaman sign-in
- Keep your original CalorieApp tab open. After approval, Xaman may
- return in your default browser; the original tab will finish signing
- in automatically.
+ On phones, expect the Xaman return page to open in your configured
+ default browser, possibly in a new tab. Keep your original CalorieApp
+ tab open; it will finish signing in automatically too.
+
+
+ {message}
Food search is available to everyone. Sign in with Xaman to save and manage items.
+
+ Phone browser notice: Xaman normally returns in your configured default
+ browser, possibly in a new tab. Keep the original CalorieApp tab open.
+
) : logError ? (
diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx
index 40ca2d5..3b241dc 100644
--- a/frontend/components/XamanLoginPanel.tsx
+++ b/frontend/components/XamanLoginPanel.tsx
@@ -5,6 +5,7 @@ import { announceAuthState } from "@/components/authEvents";
import {
backendRequest,
backendUnavailableMessage,
+ BackendRequestTimeoutError,
waitForBackendReady,
} from "@/lib/backendRequest";
@@ -30,6 +31,10 @@ const LOGIN_STATUS_POLL_INTERVAL_MS = 5_000;
const LOGIN_STATUS_FALLBACK_LIFETIME_MS = 5 * 60_000;
const LOGIN_STATUS_RATE_LIMIT_DELAY_MS = 15_000;
const LOGIN_STATUS_MAX_RETRY_AFTER_MS = 60_000;
+const LOGIN_START_RETRY_WINDOW_MS = 2 * 60_000;
+const LOGIN_START_RETRY_DELAY_MS = 15_000;
+const XAMAN_LAUNCH_MESSAGE_TYPE = "calorieapp-xaman-navigate";
+const XAMAN_LAUNCH_ERROR_TYPE = "calorieapp-xaman-error";
function delay(milliseconds: number, signal: AbortSignal) {
return new Promise
((resolve, reject) => {
@@ -117,6 +122,84 @@ async function waitForOriginLogin(
throw new Error("Login handoff expired");
}
+function retryAfterMilliseconds(response: Response): number {
+ const value = response.headers.get("retry-after")?.trim();
+ if (!value) {
+ return LOGIN_START_RETRY_DELAY_MS;
+ }
+
+ const seconds = Number(value);
+ if (Number.isFinite(seconds) && seconds >= 0) {
+ return Math.min(seconds * 1_000, LOGIN_STATUS_MAX_RETRY_AFTER_MS);
+ }
+
+ const retryAt = Date.parse(value);
+ if (Number.isNaN(retryAt)) {
+ return LOGIN_START_RETRY_DELAY_MS;
+ }
+
+ return Math.min(
+ Math.max(0, retryAt - Date.now()),
+ LOGIN_STATUS_MAX_RETRY_AFTER_MS
+ );
+}
+
+async function startLoginWithRetry(
+ signal: AbortSignal,
+ onRateLimited: () => void
+): Promise {
+ const deadline = Date.now() + LOGIN_START_RETRY_WINDOW_MS;
+
+ while (Date.now() < deadline) {
+ const response = await backendRequest(
+ `${BACKEND_BASE_URL}/api/identity/login/start`,
+ { method: "POST", signal }
+ );
+
+ if (response.ok) {
+ return (await response.json()) as LoginStartResponse;
+ }
+
+ if (response.status === 429) {
+ onRateLimited();
+ await delay(retryAfterMilliseconds(response), signal);
+ continue;
+ }
+
+ if ([502, 503, 504].includes(response.status)) {
+ await delay(LOGIN_START_RETRY_DELAY_MS, signal);
+ continue;
+ }
+
+ throw new Error(`Unable to start login (${response.status})`);
+ }
+
+ throw new BackendRequestTimeoutError();
+}
+
+function sendXamanLocationToLaunchTab(
+ loginWindow: Window,
+ attemptId: string,
+ wordpressSigninUrl: string
+) {
+ const message = {
+ type: XAMAN_LAUNCH_MESSAGE_TYPE,
+ attemptId,
+ url: wordpressSigninUrl,
+ };
+
+ // Repeat briefly so a slow mobile browser cannot miss the message while the
+ // holding page is still attaching its listener. Once navigation starts, the
+ // target-origin check prevents delivery to the external page.
+ [0, 300, 1_000, 2_500].forEach((delayMs) => {
+ window.setTimeout(() => {
+ if (!loginWindow.closed) {
+ loginWindow.postMessage(message, window.location.origin);
+ }
+ }, delayMs);
+ });
+}
+
export function XamanLoginPanel() {
const [isLoading, setIsLoading] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
@@ -161,8 +244,9 @@ export function XamanLoginPanel() {
}, [refreshCurrentUser]);
async function handleLogin() {
+ const attemptId = window.crypto.randomUUID();
const loginWindow = window.open(
- "/auth/launching",
+ `/auth/launching?attempt=${encodeURIComponent(attemptId)}`,
"calorieapp-xaman-login"
);
const controller = new AbortController();
@@ -173,23 +257,18 @@ export function XamanLoginPanel() {
setSuccessNotice(null);
setIsLoading(true);
setLoginStatus(
- "Preparing a separate Xaman sign-in tab. Keep this CalorieApp tab open; it will finish automatically."
+ "Preparing Xaman. On phones, expect the return page to open in your configured default browser, possibly in a new tab. Keep this original CalorieApp tab open; it will sign in automatically too."
);
try {
await waitForBackendReady(BACKEND_BASE_URL, controller.signal);
setLoginStatus("Service ready. Opening Xaman...");
- const response = await backendRequest(`${BACKEND_BASE_URL}/api/identity/login/start`, {
- method: "POST",
- signal: controller.signal,
+ const data = await startLoginWithRetry(controller.signal, () => {
+ setLoginStatus(
+ "CalorieApp is temporarily busy. Waiting safely before opening Xaman; keep both tabs open."
+ );
});
-
- if (!response.ok) {
- throw new Error("Unable to start login");
- }
-
- const data = (await response.json()) as LoginStartResponse;
if (!data.wordpress_signin_url || !data.browser_handoff_token) {
throw new Error("Missing signin handoff data");
}
@@ -199,10 +278,13 @@ export function XamanLoginPanel() {
return;
}
- loginWindow.opener = null;
- loginWindow.location.replace(data.wordpress_signin_url);
+ sendXamanLocationToLaunchTab(
+ loginWindow,
+ attemptId,
+ data.wordpress_signin_url
+ );
setLoginStatus(
- "Approve the request in Xaman. It may return in your default browser; this original tab will sign in automatically."
+ "Approve the request in Xaman. On phones, the return page normally opens in your configured default browser, possibly in a new tab. Keep this original tab open; it will sign in automatically too."
);
await waitForOriginLogin(
@@ -232,6 +314,10 @@ export function XamanLoginPanel() {
try {
if (loginWindow && !loginWindow.closed) {
+ loginWindow.postMessage(
+ { type: XAMAN_LAUNCH_ERROR_TYPE, attemptId },
+ window.location.origin
+ );
loginWindow.close();
}
} catch {
@@ -290,11 +376,15 @@ export function XamanLoginPanel() {
Sign in securely to save, review, and manage your personal food log.
-
- A separate sign-in tab opens. Xaman may return in your phone's default
- browser, while this original CalorieApp tab remains available and signs
- in automatically.
-
+
+ Phone browser notice: Expect the
+ Xaman return page to open in your configured default browser, possibly
+ in a new tab. Keep this original CalorieApp tab open; it will sign in
+ automatically too.
+
{currentUser ? (