From e1bf467693c0550e82624d33159a4a0e2e032282 Mon Sep 17 00:00:00 2001
From: xrpbanks <126300068+xrpbanks@users.noreply.github.com>
Date: Sat, 29 Aug 2026 06:32:30 +0200
Subject: [PATCH] Keep Xaman login in the current tab
---
README.md | 21 +-
frontend/app/auth/callback/page.tsx | 12 +-
frontend/app/auth/complete/page.tsx | 12 +-
frontend/app/auth/launching/page.tsx | 96 -------
frontend/components/FoodSearchPlaceholder.tsx | 4 +-
frontend/components/XamanLoginPanel.tsx | 253 +++++++++++-------
6 files changed, 185 insertions(+), 213 deletions(-)
delete mode 100644 frontend/app/auth/launching/page.tsx
diff --git a/README.md b/README.md
index cbf7445..21d6c76 100644
--- a/README.md
+++ b/README.md
@@ -115,15 +115,18 @@ probe the public backend `/health` endpoint directly so a Render cold start does
not occupy the frontend proxy long enough to trigger frontend 429 responses.
All authenticated requests continue through the same-origin proxy.
-Xaman sign-in opens in a separate tab while the original CalorieApp tab waits
-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.
+Xaman sign-in starts with same-tab navigation. CalorieApp does not call
+`window.open` and therefore does not create a launch tab. Mobile platforms can
+still return from Xaman through the configured default browser because they do
+not let a web flow select or reuse the original browser tab; this limitation is
+documented by Xaman in its
+[Payload Return URL guidance](https://docs.xaman.dev/concepts/payloads-sign-requests/payload-return-url).
+The callback browser receives its normal session. A short-lived, one-time
+browser handoff is kept only in the initiating tab's session storage so that
+the session can be securely restored if the user returns to that browsing
+context. Only hashes of the handoff proof are stored server-side, 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 23878be..848f983 100644
--- a/frontend/app/auth/callback/page.tsx
+++ b/frontend/app/auth/callback/page.tsx
@@ -157,9 +157,9 @@ function AuthCallbackContent() {
- 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.
+ Phone browser notice: mobile systems may return from Xaman through
+ your configured default browser instead of the tab where you started.
+ This page completes your CalorieApp session in the browser shown now.
- 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.
+ Phone browser notice: mobile systems may return from Xaman
+ through your configured default browser. CalorieApp itself does
+ not open an extra tab.
- Phone browser notice: Xaman normally opens this return page in your
- configured default browser, possibly in a new tab.
+ Xaman may have returned through your phone's configured default
+ browser. Your CalorieApp session is active here.
- Your original CalorieApp tab is signing in automatically. You can
- close this tab and return there.
-
-
- You are also signed in in this browser, so continuing here is safe.
+ Continue below to use CalorieApp in this browser.
- Continue in this browser
+ Continue to CalorieApp
diff --git a/frontend/app/auth/launching/page.tsx b/frontend/app/auth/launching/page.tsx
deleted file mode 100644
index 90075e7..0000000
--- a/frontend/app/auth/launching/page.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"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 (
-
-
-
- Preparing Xaman sign-in
-
-
- 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}
-
-
-
-
- );
-}
diff --git a/frontend/components/FoodSearchPlaceholder.tsx b/frontend/components/FoodSearchPlaceholder.tsx
index 7793196..bf5e636 100644
--- a/frontend/components/FoodSearchPlaceholder.tsx
+++ b/frontend/components/FoodSearchPlaceholder.tsx
@@ -735,8 +735,8 @@ export function FoodSearchPlaceholder() {
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.
+ CalorieApp starts Xaman from the current tab. Your phone may return
+ through its configured default browser after signing.
) : logError ? (
diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx
index 12e4cab..da9256a 100644
--- a/frontend/components/XamanLoginPanel.tsx
+++ b/frontend/components/XamanLoginPanel.tsx
@@ -26,6 +26,12 @@ type LoginStatusResponse = {
redirect_to?: string | null;
};
+type PendingLogin = {
+ state: string;
+ expiresAt: string;
+ browserHandoffToken: string;
+};
+
const BACKEND_BASE_URL = "/api/backend";
const RENDER_BACKEND_HEALTH_URL =
"https://calorieapp-backend-rvul.onrender.com";
@@ -35,8 +41,8 @@ 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";
+const PENDING_LOGIN_STORAGE_KEY = "calorieapp-pending-xaman-login";
+const LOGIN_RETURN_STORAGE_KEY = "calorieapp-login-return";
function backendHealthBaseUrl(): string {
const configuredUrl = process.env.NEXT_PUBLIC_BACKEND_HEALTH_URL?.trim();
@@ -51,6 +57,68 @@ function backendHealthBaseUrl(): string {
return BACKEND_BASE_URL;
}
+function isAllowedWordPressSigninUrl(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;
+ }
+}
+
+function clearPendingLogin() {
+ window.sessionStorage.removeItem(PENDING_LOGIN_STORAGE_KEY);
+}
+
+function storePendingLogin(data: LoginStartResponse) {
+ const pendingLogin: PendingLogin = {
+ state: data.state,
+ expiresAt: data.expires_at,
+ browserHandoffToken: data.browser_handoff_token,
+ };
+
+ window.sessionStorage.setItem(
+ PENDING_LOGIN_STORAGE_KEY,
+ JSON.stringify(pendingLogin)
+ );
+}
+
+function readPendingLogin(): PendingLogin | null {
+ const stored = window.sessionStorage.getItem(PENDING_LOGIN_STORAGE_KEY);
+ if (!stored) {
+ return null;
+ }
+
+ try {
+ const value = JSON.parse(stored) as Partial;
+ const expiresAt = Date.parse(value.expiresAt ?? "");
+ if (
+ typeof value.state !== "string" ||
+ value.state.length < 32 ||
+ typeof value.browserHandoffToken !== "string" ||
+ value.browserHandoffToken.length < 32 ||
+ !Number.isFinite(expiresAt) ||
+ expiresAt <= Date.now()
+ ) {
+ clearPendingLogin();
+ return null;
+ }
+
+ return {
+ state: value.state,
+ expiresAt: value.expiresAt as string,
+ browserHandoffToken: value.browserHandoffToken,
+ };
+ } catch {
+ clearPendingLogin();
+ return null;
+ }
+}
+
function delay(milliseconds: number, signal: AbortSignal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
@@ -192,29 +260,6 @@ async function startLoginWithRetry(
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);
@@ -224,7 +269,7 @@ export function XamanLoginPanel() {
const [currentUser, setCurrentUser] = useState(null);
const loginAbortController = useRef(null);
- const refreshCurrentUser = useCallback(async () => {
+ const refreshCurrentUser = useCallback(async (): Promise => {
try {
const response = await backendRequest(
`${BACKEND_BASE_URL}/api/identity/me`
@@ -234,36 +279,94 @@ export function XamanLoginPanel() {
if (response.status === 401) {
announceAuthState(false);
}
- return;
+ return null;
}
const data = (await response.json()) as MeResponse;
setCurrentUser(data);
+ return data;
} catch {
setCurrentUser(null);
+ return null;
}
}, []);
useEffect(() => {
- refreshCurrentUser();
+ let cancelled = false;
+ const controller = new AbortController();
+ loginAbortController.current = controller;
- if (window.sessionStorage.getItem("calorieapp-login-return")) {
- window.sessionStorage.removeItem("calorieapp-login-return");
- setSuccessNotice(
- "Sign-in completed in your default browser. You can continue safely in this tab."
- );
+ async function restoreLogin() {
+ const user = await refreshCurrentUser();
+ if (cancelled || controller.signal.aborted) {
+ return;
+ }
+
+ if (user) {
+ clearPendingLogin();
+ if (window.sessionStorage.getItem(LOGIN_RETURN_STORAGE_KEY)) {
+ window.sessionStorage.removeItem(LOGIN_RETURN_STORAGE_KEY);
+ setSuccessNotice(
+ "Sign-in completed. You can continue safely in this browser."
+ );
+ }
+ return;
+ }
+
+ const pendingLogin = readPendingLogin();
+ if (!pendingLogin) {
+ return;
+ }
+
+ setError(null);
+ setIsLoading(true);
+ setLoginStatus("Restoring the Xaman sign-in started from this tab...");
+
+ try {
+ await waitForOriginLogin(
+ pendingLogin.state,
+ pendingLogin.browserHandoffToken,
+ pendingLogin.expiresAt,
+ controller.signal
+ );
+ clearPendingLogin();
+
+ const restoredUser = await refreshCurrentUser();
+ if (!restoredUser) {
+ throw new Error("Restored session was unavailable");
+ }
+ if (cancelled) {
+ return;
+ }
+
+ announceAuthState(true);
+ setSuccessNotice(
+ "Sign-in completed. Your session was restored in this browser."
+ );
+ setLoginStatus(null);
+ setIsLoading(false);
+ } catch {
+ if (controller.signal.aborted || cancelled) {
+ return;
+ }
+
+ clearPendingLogin();
+ setError(
+ "The earlier Xaman sign-in could not be restored. Start again from this tab."
+ );
+ setLoginStatus(null);
+ setIsLoading(false);
+ }
}
+ void restoreLogin();
+
return () => {
- loginAbortController.current?.abort();
+ cancelled = true;
+ controller.abort();
};
}, [refreshCurrentUser]);
async function handleLogin() {
- const attemptId = window.crypto.randomUUID();
- const loginWindow = window.open(
- `/auth/launching?attempt=${encodeURIComponent(attemptId)}`,
- "calorieapp-xaman-login"
- );
const controller = new AbortController();
loginAbortController.current?.abort();
loginAbortController.current = controller;
@@ -272,7 +375,7 @@ export function XamanLoginPanel() {
setSuccessNotice(null);
setIsLoading(true);
setLoginStatus(
- "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."
+ "Preparing Xaman in this tab. Please wait without refreshing."
);
try {
@@ -281,67 +384,32 @@ export function XamanLoginPanel() {
const data = await startLoginWithRetry(controller.signal, () => {
setLoginStatus(
- "CalorieApp is temporarily busy. Waiting safely before opening Xaman; keep both tabs open."
+ "CalorieApp is temporarily busy. Waiting safely before opening Xaman..."
);
});
- if (!data.wordpress_signin_url || !data.browser_handoff_token) {
+ if (
+ data.state.length < 32 ||
+ data.browser_handoff_token.length < 32 ||
+ !Number.isFinite(Date.parse(data.expires_at)) ||
+ Date.parse(data.expires_at) <= Date.now() ||
+ !isAllowedWordPressSigninUrl(data.wordpress_signin_url)
+ ) {
throw new Error("Missing signin handoff data");
}
- if (!loginWindow || loginWindow.closed) {
- window.location.assign(data.wordpress_signin_url);
- return;
- }
-
- sendXamanLocationToLaunchTab(
- loginWindow,
- attemptId,
- data.wordpress_signin_url
- );
- setLoginStatus(
- "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(
- data.state,
- data.browser_handoff_token,
- data.expires_at,
- controller.signal
- );
-
- try {
- loginWindow.close();
- } catch {
- // A browser may keep the external Xaman tab open. The login still succeeded.
- }
-
- await refreshCurrentUser();
- announceAuthState(true);
- setSuccessNotice(
- "Sign-in completed. You can continue in this original CalorieApp tab."
- );
- setLoginStatus(null);
- setIsLoading(false);
+ storePendingLogin(data);
+ setLoginStatus("Opening Xaman from this tab...");
+ window.location.assign(data.wordpress_signin_url);
} catch (requestError) {
if (controller.signal.aborted) {
return;
}
- try {
- if (loginWindow && !loginWindow.closed) {
- loginWindow.postMessage(
- { type: XAMAN_LAUNCH_ERROR_TYPE, attemptId },
- window.location.origin
- );
- loginWindow.close();
- }
- } catch {
- // The external sign-in window may no longer be script-controllable.
- }
+ clearPendingLogin();
setError(
backendUnavailableMessage(
requestError,
- "Sign-in could not be confirmed in this tab. You can continue in the browser Xaman opened, or try again."
+ "Xaman could not be opened from this tab. Please try again."
)
);
setLoginStatus(null);
@@ -395,10 +463,11 @@ export function XamanLoginPanel() {
role="note"
className="mt-3 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-xs leading-relaxed text-amber-950"
>
- 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.
+ Phone browser notice: CalorieApp
+ opens Xaman from this tab and does not create an extra tab. After
+ signing, your phone may still return through its configured default
+ browser because mobile systems do not let websites select the previous
+ browser tab.
{currentUser ? (