Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
5 changes: 5 additions & 0 deletions web/mobile/src/features/app/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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]])
Expand Down
89 changes: 89 additions & 0 deletions web/mobile/src/features/auth/SignInScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<EmailSignInMode | null>(null)
useEffect(() => setMode(getEmailSignInMode()), [])

const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)

const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
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 = <p className="text-muted-foreground text-center text-xs">Loading…</p>
} else if (mode !== "password") {
body = (
<p className="text-muted-foreground text-center text-xs">
{mode === "otp"
? "Email code sign-in is not available here."
: "Email sign-in is disabled on this deployment."}
</p>
)
} else {
body = (
<form className="flex w-full flex-col gap-3" onSubmit={onSubmit}>
<input
type="email"
autoComplete="email"
required
placeholder="Email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className="border-border bg-background rounded-md border px-3 py-2 text-sm"
/>
<input
type="password"
autoComplete="current-password"
required
placeholder="Password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="border-border bg-background rounded-md border px-3 py-2 text-sm"
/>
{error ? <p className="text-destructive text-xs">{error}</p> : null}
<button
type="submit"
disabled={pending}
className="border-border rounded-md border px-3 py-2 text-sm disabled:opacity-50"
>
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
)
}

return (
<div className="bg-background text-foreground flex min-h-dvh flex-col items-center justify-center gap-4 p-6">
<p className="text-sm font-medium">Sign in to Agenta</p>
{body}
<p className="text-muted-foreground text-center text-xs">
For SSO or social sign-in, use the desktop app.
</p>
</div>
)
}
7 changes: 6 additions & 1 deletion web/mobile/src/features/context/states/SignedOutNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import Link from "next/link"

export const SignedOutNotice = () => (
<div className="flex grow flex-col items-center justify-center gap-2 p-6 text-center">
<p className="text-sm font-medium">You are signed out</p>
<Link href="/auth" className="text-xs underline underline-offset-4">
Sign in
</Link>
<p className="text-muted-foreground text-xs">
Sign in on the desktop app first, then reload this page.
Or sign in on the desktop app, then reload this page.
</p>
</div>
)
103 changes: 103 additions & 0 deletions web/mobile/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import SuperTokens from "supertokens-web-js"
import EmailPassword from "supertokens-web-js/recipe/emailpassword"
import Session from "supertokens-web-js/recipe/session"

import {getApiUrl, getEnv} 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(), 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<SignInOutcome> {
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
* stands. Never throws (network failure counts as "not refreshed").
*/
export async function tryRefreshSession(): Promise<boolean> {
if (typeof window === "undefined") return false
ensureAuthInit()
try {
return await Session.attemptRefreshingSession()
} catch {
return false
}
}
13 changes: 12 additions & 1 deletion web/mobile/src/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -83,7 +85,7 @@ export type ProjectsResult =
| {kind: "unauthenticated"}
| {kind: "error"}

export async function fetchProjects(): Promise<ProjectsResult> {
async function fetchProjectsOnce(): Promise<ProjectsResult> {
try {
const data = await getProjectsClient().getProjects()
const projects = safeParseWithLogging(z.array(projectRowSchema), data, "[fetchProjects]")
Expand All @@ -95,3 +97,12 @@ export async function fetchProjects(): Promise<ProjectsResult> {
return {kind: "error"}
}
}

export async function fetchProjects(): Promise<ProjectsResult> {
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()
}
16 changes: 16 additions & 0 deletions web/mobile/src/pages/auth.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Head>
<title>Sign in — Agenta</title>
</Head>
<SignInScreen />
</>
)
}
30 changes: 27 additions & 3 deletions web/packages/agenta-shared/src/utils/mobileGate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,38 @@ export function isDocumentNavigation(input: Pick<GateInput, "method" | "header">

/**
* 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"
Comment on lines 89 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Password-reset token is lost when routing mobile /auth/reset-password links. mapDesktopToMobile discards the search string for every /auth* path, and the test suite only verifies this with an empty search, so the realistic ?token=... case from SuperTokens' reset-password email link is never exercised and the resulting data loss goes unnoticed.

  • web/packages/agenta-shared/src/utils/mobileGate/index.ts#L74-L77: preserve token/tenantId when mapping /auth/reset-password to a mobile destination, or exclude it from mobile mapping like /auth/callback.
  • web/packages/agenta-shared/tests/unit/mobileGate.test.ts#L90-L93: add a case asserting mapDesktopToMobile("/auth/reset-password", "?token=abc") preserves the token once the implementation is fixed.
📍 Affects 2 files
  • web/packages/agenta-shared/src/utils/mobileGate/index.ts#L74-L77 (this comment)
  • web/packages/agenta-shared/tests/unit/mobileGate.test.ts#L90-L93

const m = pathname.match(PROJECT_PATH_RE)
if (m) {
const [, ws, proj] = m
Expand Down Expand Up @@ -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"}

Expand Down
30 changes: 29 additions & 1 deletion web/packages/agenta-shared/tests/unit/mobileGate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ const docHeaders = (ua: string, extra: Record<string, string> = {}) => ({
})

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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading