From bc3c70871ac444ebc18612fcfb5b5452fdc44946 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 11:33:20 +0300 Subject: [PATCH 1/5] feat(shared): route mobile devices to the mobile sign-in page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /auth leaves DESKTOP_EXCEPTIONS now that /m/auth exists: mobile devices navigating desktop /auth land on the mobile sign-in via the deep-link map. /auth/callback stays a desktop exception — the OAuth/SSO redirect landing must complete on the desktop app (mobile has no SSO). The web/mobile middleware carries only the reverse-gate subset (none of the changed functions), so its verbatim copy needs no mirror; the desktop middlewares import from @agenta/shared and pick this up without edits. --- .../src/utils/mobileGate/index.ts | 30 +++++++++++++++++-- .../tests/unit/mobileGate.test.ts | 30 ++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/web/packages/agenta-shared/src/utils/mobileGate/index.ts b/web/packages/agenta-shared/src/utils/mobileGate/index.ts index 35d8a5d063..1a5e7507a5 100644 --- a/web/packages/agenta-shared/src/utils/mobileGate/index.ts +++ b/web/packages/agenta-shared/src/utils/mobileGate/index.ts @@ -58,16 +58,38 @@ export function isDocumentNavigation(input: Pick /** * Desktop routes that must never redirect to /m (design.md documented - * exceptions). /auth stays here until WP2 ships the mobile sign-in, then - * moves into the map (→ /m/auth); /post-signup and /workspaces/accept are + * exceptions). /auth now maps to the mobile sign-in (→ /m/auth, auth-lite), + * EXCEPT /auth/callback: the OAuth/SSO redirect landing must complete on the + * desktop app (mobile has no SSO). /post-signup and /workspaces/accept are * permanently desktop-only. */ -const DESKTOP_EXCEPTIONS = [/^\/auth(\/|$)/, /^\/post-signup(\/|$)/, /^\/workspaces\/accept(\/|$)/] +const AUTH_RE = /^\/auth(\/|$)/ + +/** + * An `/auth` link carrying a one-time `token` (SuperTokens password reset, invite acceptance — + * `web/oss/src/pages/auth/[[...path]].tsx` reads `router.query.token`) completes on desktop. + * + * Redirecting it would drop the token twice over: `mapDesktopToMobile` returns a bare `/m/auth`, + * and the mobile app has no screen that consumes one. Same reasoning as the OAuth callback + * exception — a single-use credential must not be bounced. + */ +export function isTokenBearingAuthLink(pathname: string, search: string): boolean { + if (!AUTH_RE.test(pathname)) return false + return Boolean(new URLSearchParams(search).get("token")) +} + +const DESKTOP_EXCEPTIONS = [ + /^\/auth\/callback(\/|$)/, + /^\/post-signup(\/|$)/, + /^\/workspaces\/accept(\/|$)/, +] const PROJECT_PATH_RE = /^\/w\/([^/]+)\/p\/([^/]+)(\/|$)/ /** Desktop URL → mobile equivalent (design.md "Gate and routing"). */ export function mapDesktopToMobile(pathname: string, search: string): string { + // Mobile sign-in (auth-lite): /auth/callback never reaches here (exception). + if (/^\/auth(\/|$)/.test(pathname)) return "/m/auth" const m = pathname.match(PROJECT_PATH_RE) if (m) { const [, ws, proj] = m @@ -123,6 +145,8 @@ export function decideDesktopGate(input: GateInput): GateDecision { } if (DESKTOP_EXCEPTIONS.some((re) => re.test(input.pathname))) return {kind: "pass"} + // A one-time token completes where it landed; see isTokenBearingAuthLink. + if (isTokenBearingAuthLink(input.pathname, input.search)) return {kind: "pass"} if (input.cookie(MOBILE_OPTOUT_COOKIE)) return {kind: "pass"} if (!isMobileDevice(input.header)) return {kind: "pass"} diff --git a/web/packages/agenta-shared/tests/unit/mobileGate.test.ts b/web/packages/agenta-shared/tests/unit/mobileGate.test.ts index 184d5ccabf..e4ce071231 100644 --- a/web/packages/agenta-shared/tests/unit/mobileGate.test.ts +++ b/web/packages/agenta-shared/tests/unit/mobileGate.test.ts @@ -39,6 +39,25 @@ const docHeaders = (ua: string, extra: Record = {}) => ({ }) describe("isMobileDevice", () => { + it("leaves a token-bearing auth link on desktop", () => { + // SuperTokens password-reset and invite links carry a one-time ?token=. Redirecting + // drops it twice over: mapDesktopToMobile returns a bare /m/auth, and mobile has no + // screen that consumes a token. + for (const pathname of ["/auth", "/auth/reset-password"]) { + expect( + decideDesktopGate( + input({pathname, search: "?token=abc123", headers: docHeaders(MOBILE_UA)}), + ), + ).toEqual({kind: "pass"}) + } + }) + + it("still redirects a plain auth link with no token", () => { + expect( + decideDesktopGate(input({pathname: "/auth", headers: docHeaders(MOBILE_UA)})), + ).toEqual({kind: "redirect", location: "/m/auth"}) + }) + it("trusts sec-ch-ua-mobile ?1 over a desktop UA", () => { expect( isMobileDevice( @@ -89,6 +108,10 @@ describe("isDocumentNavigation", () => { }) describe("mapDesktopToMobile", () => { + it("maps desktop auth to the mobile sign-in page", () => { + expect(mapDesktopToMobile("/auth", "")).toBe("/m/auth") + expect(mapDesktopToMobile("/auth/reset-password", "")).toBe("/m/auth") + }) it("maps an observability session deep link to the mobile chat", () => { expect(mapDesktopToMobile("/w/ws1/p/pr1/observability", "?session=abc&span=s1")).toBe( "/m/w/ws1/p/pr1/sessions/abc", @@ -137,12 +160,17 @@ describe("decideDesktopGate", () => { ).toEqual({kind: "redirect", location: "/m/w/ws1/p/pr1/sessions"}) }) it("never redirects the documented exceptions", () => { - for (const pathname of ["/auth", "/auth/callback", "/post-signup", "/workspaces/accept"]) { + for (const pathname of ["/auth/callback", "/post-signup", "/workspaces/accept"]) { expect(decideDesktopGate(input({pathname, headers: docHeaders(MOBILE_UA)}))).toEqual({ kind: "pass", }) } }) + it("redirects mobile devices on desktop /auth to the mobile sign-in", () => { + expect( + decideDesktopGate(input({pathname: "/auth", headers: docHeaders(MOBILE_UA)})), + ).toEqual({kind: "redirect", location: "/m/auth"}) + }) it("honors the opt-out cookie", () => { const i = input({headers: docHeaders(MOBILE_UA)}) i.cookie = (name) => (name === MOBILE_OPTOUT_COOKIE ? "1" : undefined) From de79d0ea57a9ecacbc368887df55bc99c083f0ef Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 11:36:31 +0300 Subject: [PATCH 2/5] feat(mobile): attempt session refresh before the signed-out verdict An expired access token was indistinguishable from signed-out: fetchProjects returned {kind:"unauthenticated"} on the first 401 even when a valid refresh token cookie was sitting right there. Add a headless supertokens-web-js client (same 0.16.x the desktop's supertokens-auth-react wraps, appInfo mirroring web/oss/src/config/appInfo.ts) and have fetchProjects try one Session.attemptRefreshingSession() + retry before the verdict stands. --- web/mobile/package.json | 1 + web/mobile/src/lib/auth.ts | 40 +++++++++++++++++++++++++++++++++++ web/mobile/src/lib/context.ts | 13 +++++++++++- web/pnpm-lock.yaml | 3 +++ 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 web/mobile/src/lib/auth.ts diff --git a/web/mobile/package.json b/web/mobile/package.json index 5b14dabc81..42ee8268d4 100644 --- a/web/mobile/package.json +++ b/web/mobile/package.json @@ -37,6 +37,7 @@ "radix-ui": "^1.6.2", "react": "^19.0.0", "react-dom": "^19.0.0", + "supertokens-web-js": "^0.16.0", "tailwind-merge": "^3.3.1", "zod": "^4.3.6" }, diff --git a/web/mobile/src/lib/auth.ts b/web/mobile/src/lib/auth.ts new file mode 100644 index 0000000000..008468dd74 --- /dev/null +++ b/web/mobile/src/lib/auth.ts @@ -0,0 +1,40 @@ +import SuperTokens from "supertokens-web-js" +import Session from "supertokens-web-js/recipe/session" + +import {getApiUrl} from "./env" + +/** + * Headless SuperTokens client (supertokens-web-js — same 0.16.x the desktop's + * supertokens-auth-react wraps). appInfo mirrors web/oss/src/config/appInfo.ts + * exactly (apiDomain + "/api/auth"), so both apps share the one cookie session + * against the same backend. + */ +let initialized = false + +export function ensureAuthInit(): void { + if (initialized || typeof window === "undefined") return + SuperTokens.init({ + appInfo: { + appName: "agenta", + apiDomain: getApiUrl(), + apiBasePath: "/api/auth", + }, + recipeList: [Session.init()], + }) + initialized = true +} + +/** + * Attempt a cookie-based session refresh. Resolves false when there is no + * refresh token or the backend rejects it — the caller's signed-out verdict + * stands. Never throws (network failure counts as "not refreshed"). + */ +export async function tryRefreshSession(): Promise { + if (typeof window === "undefined") return false + ensureAuthInit() + try { + return await Session.attemptRefreshingSession() + } catch { + return false + } +} diff --git a/web/mobile/src/lib/context.ts b/web/mobile/src/lib/context.ts index 62634fb5c4..9f42c05dc6 100644 --- a/web/mobile/src/lib/context.ts +++ b/web/mobile/src/lib/context.ts @@ -2,6 +2,8 @@ import {safeParseWithLogging} from "@agenta/entities/shared" import {getProjectsClient} from "@agenta/sdk/resources" import {z} from "zod" +import {tryRefreshSession} from "./auth" + /** Mobile's own last-visited workspace/project, for `/m/` root resolution. */ export const LAST_CONTEXT_KEY = "agenta:mobile:last-context" @@ -83,7 +85,7 @@ export type ProjectsResult = | {kind: "unauthenticated"} | {kind: "error"} -export async function fetchProjects(): Promise { +async function fetchProjectsOnce(): Promise { try { const data = await getProjectsClient().getProjects() const projects = safeParseWithLogging(z.array(projectRowSchema), data, "[fetchProjects]") @@ -95,3 +97,12 @@ export async function fetchProjects(): Promise { return {kind: "error"} } } + +export async function fetchProjects(): Promise { + const first = await fetchProjectsOnce() + if (first.kind !== "unauthenticated") return first + // An expired access token is not signed-out: try one cookie refresh, then + // retry once before letting the unauthenticated verdict stand. + if (!(await tryRefreshSession())) return first + return fetchProjectsOnce() +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7edba8e878..15dd2ee609 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -405,6 +405,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.6(react@19.2.6) + supertokens-web-js: + specifier: ^0.16.0 + version: 0.16.0 tailwind-merge: specifier: ^3.3.1 version: 3.6.0 From ba8ec9cb4469e2c341f656d9f4d04979ece58375 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 11:43:24 +0300 Subject: [PATCH 3/5] feat(mobile): raw email sign-in page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /m/auth: thin page shell + SignInScreen with a raw email/password form against the shared SuperTokens backend (EmailPassword recipe joins the headless init). The effective email mode mirrors the desktop's getEffectiveAuthConfig — otp and disabled deployments get a notice instead of the form, and SSO/social is signposted to the desktop app. On success the cached unauthenticated projects verdict is invalidated and the root context resolver takes over. SignedOutNotice now links to /m/auth. --- web/mobile/src/features/auth/SignInScreen.tsx | 89 +++++++++++++++++++ .../context/states/SignedOutNotice.tsx | 7 +- web/mobile/src/lib/auth.ts | 67 +++++++++++++- web/mobile/src/pages/auth.tsx | 16 ++++ 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 web/mobile/src/features/auth/SignInScreen.tsx create mode 100644 web/mobile/src/pages/auth.tsx diff --git a/web/mobile/src/features/auth/SignInScreen.tsx b/web/mobile/src/features/auth/SignInScreen.tsx new file mode 100644 index 0000000000..6c289ef93c --- /dev/null +++ b/web/mobile/src/features/auth/SignInScreen.tsx @@ -0,0 +1,89 @@ +import {useEffect, useState, type FormEvent} from "react" + +import {useRouter} from "next/router" + +import {getEmailSignInMode, signInWithEmailPassword, type EmailSignInMode} from "@/lib/auth" +import {queryClient} from "@/lib/queryClient" + +/** Raw email/password sign-in (auth-lite). OIDC/social stays a desktop flow. */ +export const SignInScreen = () => { + const router = useRouter() + // Mode reads window.__env — resolve after mount so SSR markup never differs. + const [mode, setMode] = useState(null) + useEffect(() => setMode(getEmailSignInMode()), []) + + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + + const onSubmit = async (event: FormEvent) => { + event.preventDefault() + if (pending) return + setPending(true) + setError(null) + const outcome = await signInWithEmailPassword(email.trim(), password) + if (outcome.kind === "ok") { + // Drop the cached unauthenticated verdict before the resolver reruns. + await queryClient.invalidateQueries({queryKey: ["mobile", "projects"]}) + void router.replace("/") + return + } + setPending(false) + setError(outcome.kind === "rejected" ? outcome.message : "Something went wrong. Try again.") + } + + let body + if (mode === null) { + body =

Loading…

+ } else if (mode !== "password") { + body = ( +

+ {mode === "otp" + ? "Email code sign-in is not available here." + : "Email sign-in is disabled on this deployment."} +

+ ) + } else { + body = ( +
+ setEmail(event.target.value)} + className="border-border bg-background rounded-md border px-3 py-2 text-sm" + /> + setPassword(event.target.value)} + className="border-border bg-background rounded-md border px-3 py-2 text-sm" + /> + {error ?

{error}

: null} + +
+ ) + } + + return ( +
+

Sign in to Agenta

+ {body} +

+ For SSO or social sign-in, use the desktop app. +

+
+ ) +} diff --git a/web/mobile/src/features/context/states/SignedOutNotice.tsx b/web/mobile/src/features/context/states/SignedOutNotice.tsx index 85f52ff2d7..8089037dbe 100644 --- a/web/mobile/src/features/context/states/SignedOutNotice.tsx +++ b/web/mobile/src/features/context/states/SignedOutNotice.tsx @@ -1,8 +1,13 @@ +import Link from "next/link" + export const SignedOutNotice = () => (

You are signed out

+ + Sign in +

- Sign in on the desktop app first, then reload this page. + Or sign in on the desktop app, then reload this page.

) diff --git a/web/mobile/src/lib/auth.ts b/web/mobile/src/lib/auth.ts index 008468dd74..8486519c61 100644 --- a/web/mobile/src/lib/auth.ts +++ b/web/mobile/src/lib/auth.ts @@ -1,7 +1,8 @@ import SuperTokens from "supertokens-web-js" +import EmailPassword from "supertokens-web-js/recipe/emailpassword" import Session from "supertokens-web-js/recipe/session" -import {getApiUrl} from "./env" +import {getApiUrl, getEnv} from "./env" /** * Headless SuperTokens client (supertokens-web-js — same 0.16.x the desktop's @@ -19,11 +20,73 @@ export function ensureAuthInit(): void { apiDomain: getApiUrl(), apiBasePath: "/api/auth", }, - recipeList: [Session.init()], + recipeList: [Session.init(), EmailPassword.init()], }) initialized = true } +/** OIDC client-id env keys the desktop's getEffectiveAuthConfig checks. */ +const OIDC_CLIENT_ID_KEYS = [ + "NEXT_PUBLIC_AGENTA_AUTH_GOOGLE_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_GOOGLE_WORKSPACES_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_GITHUB_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_FACEBOOK_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_APPLE_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_DISCORD_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_TWITTER_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_GITLAB_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_BITBUCKET_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_LINKEDIN_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_OKTA_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_AZURE_AD_OAUTH_CLIENT_ID", + "NEXT_PUBLIC_AGENTA_AUTH_BOXY_SAML_OAUTH_CLIENT_ID", +] + +export type EmailSignInMode = "password" | "otp" | "disabled" + +/** + * Effective email-auth mode, mirroring the desktop's getEffectiveAuthConfig + * (web/oss/src/lib/helpers/dynamicEnv.ts): NEXT_PUBLIC_AGENTA_AUTHN_EMAIL + * wins; unset defaults to "password" only when no OIDC provider is enabled. + * Mobile can act on "password" only — "otp" and SSO stay desktop flows. + */ +export function getEmailSignInMode(): EmailSignInMode { + const oidcEnabled = + getEnv("NEXT_PUBLIC_AGENTA_AUTH_OIDC_ENABLED").toLowerCase() === "true" || + OIDC_CLIENT_ID_KEYS.some((key) => Boolean(getEnv(key))) + const authnEmail = getEnv("NEXT_PUBLIC_AGENTA_AUTHN_EMAIL") || (oidcEnabled ? "" : "password") + if (authnEmail === "password" || authnEmail === "otp") return authnEmail + return "disabled" +} + +export type SignInOutcome = {kind: "ok"} | {kind: "rejected"; message: string} | {kind: "error"} + +export async function signInWithEmailPassword( + email: string, + password: string, +): Promise { + ensureAuthInit() + try { + const result = await EmailPassword.signIn({ + formFields: [ + {id: "email", value: email}, + {id: "password", value: password}, + ], + }) + if (result.status === "OK") return {kind: "ok"} + if (result.status === "WRONG_CREDENTIALS_ERROR") + return {kind: "rejected", message: "Incorrect email or password."} + if (result.status === "FIELD_ERROR") + return { + kind: "rejected", + message: result.formFields[0]?.error ?? "Invalid email or password.", + } + return {kind: "rejected", message: result.reason} + } catch { + return {kind: "error"} + } +} + /** * Attempt a cookie-based session refresh. Resolves false when there is no * refresh token or the backend rejects it — the caller's signed-out verdict diff --git a/web/mobile/src/pages/auth.tsx b/web/mobile/src/pages/auth.tsx new file mode 100644 index 0000000000..d220e94f36 --- /dev/null +++ b/web/mobile/src/pages/auth.tsx @@ -0,0 +1,16 @@ +import Head from "next/head" + +import {SignInScreen} from "@/features/auth/SignInScreen" + +// Thin shell: raw email/password sign-in (auth-lite). On success the root +// context resolver takes over. +export default function Auth() { + return ( + <> + + Sign in — Agenta + + + + ) +} From 842116f69e6d85216ccb013057ad7142bfe10e69 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 13:26:09 +0300 Subject: [PATCH 4/5] fix(mobile): install the session refresh interceptor at provider scope --- web/mobile/src/features/app/AppProviders.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/mobile/src/features/app/AppProviders.tsx b/web/mobile/src/features/app/AppProviders.tsx index 3ba359bcaf..83d692140d 100644 --- a/web/mobile/src/features/app/AppProviders.tsx +++ b/web/mobile/src/features/app/AppProviders.tsx @@ -6,6 +6,7 @@ import {Provider, getDefaultStore} from "jotai" import {useHydrateAtoms} from "jotai/react/utils" import {queryClientAtom} from "jotai-tanstack-query" +import {ensureAuthInit} from "@/lib/auth" import {getApiUrl} from "@/lib/env" import {queryClient} from "@/lib/queryClient" @@ -14,6 +15,10 @@ import {ContextSync} from "./ContextSync" // Module scope, like the desktop _app: __env.js is beforeInteractive, so // window.__env is already populated when this module first evaluates. configureAgentaSdk({host: getApiUrl()}) +// Install the SuperTokens fetch interceptor before any API call — it is what +// transparently refreshes an expired access token on 401 (desktop parity); +// without it only fetchProjects' explicit retry path could heal a 401. +ensureAuthInit() const HydrateAtoms = ({children}: PropsWithChildren) => { useHydrateAtoms([[queryClientAtom, queryClient]]) From 4d36078f6fcf98a4c855efa9eeea77374074774b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 5 Aug 2026 15:42:51 +0300 Subject: [PATCH 5/5] fix(shared): let a policy auth link finish on desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/auth` starts mapping to the mobile sign-in in this lane, and `mapDesktopToMobile` returns a bare `/m/auth` — the query is dropped. That breaks the flow `axiosConfig`'s 403 handler depends on: it sends the user to `/auth?auth_error=...` so they can complete required SSO or social re-authentication, and the desktop auth page reads that param to say why. Redirected, the reason is gone and the user lands on a sign-in screen that cannot explain itself, in an app with no screen that consumes the param. Same exception the one-time token already gets, and for the same reason: the query IS the payload. --- .../agenta-shared/src/utils/mobileGate/index.ts | 14 ++++++++++++++ .../agenta-shared/tests/unit/mobileGate.test.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/web/packages/agenta-shared/src/utils/mobileGate/index.ts b/web/packages/agenta-shared/src/utils/mobileGate/index.ts index 1a5e7507a5..ab7757bcf1 100644 --- a/web/packages/agenta-shared/src/utils/mobileGate/index.ts +++ b/web/packages/agenta-shared/src/utils/mobileGate/index.ts @@ -78,6 +78,18 @@ export function isTokenBearingAuthLink(pathname: string, search: string): boolea return Boolean(new URLSearchParams(search).get("token")) } +/** + * An `/auth` link carrying `auth_error` completes on desktop, for the same reason as a token: the + * param IS the payload. `web/oss/src/lib/api/assets/axiosConfig.ts` redirects a 403 here so the + * user can finish required SSO or social re-authentication, and the desktop auth page reads it. + * `mapDesktopToMobile` returns a bare `/m/auth`, so redirecting would drop the reason and leave + * the user on a sign-in screen that cannot explain why it appeared. + */ +export function isPolicyAuthLink(pathname: string, search: string): boolean { + if (!AUTH_RE.test(pathname)) return false + return Boolean(new URLSearchParams(search).get("auth_error")) +} + const DESKTOP_EXCEPTIONS = [ /^\/auth\/callback(\/|$)/, /^\/post-signup(\/|$)/, @@ -147,6 +159,8 @@ export function decideDesktopGate(input: GateInput): GateDecision { if (DESKTOP_EXCEPTIONS.some((re) => re.test(input.pathname))) return {kind: "pass"} // A one-time token completes where it landed; see isTokenBearingAuthLink. if (isTokenBearingAuthLink(input.pathname, input.search)) return {kind: "pass"} + // Same reasoning for a policy error; see isPolicyAuthLink. + if (isPolicyAuthLink(input.pathname, input.search)) return {kind: "pass"} if (input.cookie(MOBILE_OPTOUT_COOKIE)) return {kind: "pass"} if (!isMobileDevice(input.header)) return {kind: "pass"} diff --git a/web/packages/agenta-shared/tests/unit/mobileGate.test.ts b/web/packages/agenta-shared/tests/unit/mobileGate.test.ts index e4ce071231..aa1404eba7 100644 --- a/web/packages/agenta-shared/tests/unit/mobileGate.test.ts +++ b/web/packages/agenta-shared/tests/unit/mobileGate.test.ts @@ -52,6 +52,22 @@ describe("isMobileDevice", () => { } }) + // axiosConfig's 403 handler sends the user to /auth?auth_error=… so they can complete + // required SSO; mapDesktopToMobile returns a bare /m/auth and would drop the reason. + it("leaves a policy auth_error link on desktop", () => { + for (const err of ["upgrade_required", "sso_denied"]) { + expect( + decideDesktopGate( + input({ + pathname: "/auth", + search: `?auth_error=${err}`, + headers: docHeaders(MOBILE_UA), + }), + ), + ).toEqual({kind: "pass"}) + } + }) + it("still redirects a plain auth link with no token", () => { expect( decideDesktopGate(input({pathname: "/auth", headers: docHeaders(MOBILE_UA)})),