-
Notifications
You must be signed in to change notification settings - Fork 608
[feat] Sign in on mobile, and keep the session alive (7/12) #5686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ardaerzin
wants to merge
4
commits into
feat/mobile-sessions-and-transcript
Choose a base branch
from
feat/mobile-auth
base: feat/mobile-sessions-and-transcript
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
2577df5
feat(shared): route mobile devices to the mobile sign-in page
ardaerzin 182738a
feat(mobile): attempt session refresh before the signed-out verdict
ardaerzin 25a0c7c
feat(mobile): raw email sign-in page
ardaerzin b5c5e4d
fix(mobile): install the session refresh interceptor at provider scope
ardaerzin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 /> | ||
| </> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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-passwordlinks.mapDesktopToMobilediscards thesearchstring 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: preservetoken/tenantIdwhen mapping/auth/reset-passwordto 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 assertingmapDesktopToMobile("/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