diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx
index 94eb1e6b7..3146f804f 100644
--- a/app/app/_layout.tsx
+++ b/app/app/_layout.tsx
@@ -13,8 +13,7 @@ function AuthGate({ children }: { children: React.ReactNode }) {
const { isSignedIn, isLoaded } = useAuth();
const segments = useSegments();
const router = useRouter();
- const onAuthScreen =
- segments[0] === "sign-in" || segments[0] === "sign-up" || segments[0] === "landing";
+ const onAuthScreen = segments[0] === "auth";
const onPublicScreen = onAuthScreen || segments[0] === "shares";
// cli-auth manages its own signed-in/out states — never auto-redirect it.
const onCliAuth = segments[0] === "cli-auth";
@@ -24,7 +23,7 @@ function AuthGate({ children }: { children: React.ReactNode }) {
if (isSignedIn && onAuthScreen) {
router.replace("/");
} else if (!isSignedIn && !LOCAL_MODE && !onPublicScreen) {
- router.replace("/landing");
+ router.replace("/auth");
}
}, [isSignedIn, isLoaded, onAuthScreen, onPublicScreen, onCliAuth, router]);
@@ -45,20 +44,12 @@ export default function RootLayout() {
-
+
-
-
diff --git a/app/app/auth.tsx b/app/app/auth.tsx
new file mode 100644
index 000000000..ee9a9d6d2
--- /dev/null
+++ b/app/app/auth.tsx
@@ -0,0 +1,359 @@
+import React, { useState } from "react";
+import {
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ View,
+ useWindowDimensions,
+} from "react-native";
+import { useSignIn, useSignUp } from "@clerk/clerk-expo";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { BrandPanel } from "@/components/auth/BrandPanel";
+import { EmailPasswordForm } from "@/components/auth/EmailPasswordForm";
+import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
+import { OAuthRow } from "@/components/auth/OAuthRow";
+import { VerificationForm } from "@/components/auth/VerificationForm";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import { Text } from "@/components/ui/text";
+import { clerkErrorMessage, sanitizeRedirect } from "@/lib/clerk-errors";
+
+type AuthMode = "sign-in" | "sign-up";
+type Phase = "form" | "verify" | "forgot" | "forgot-verify";
+
+const SIDE_PANEL_MIN_WIDTH = 880;
+
+export default function AuthScreen() {
+ const router = useRouter();
+ const params = useLocalSearchParams<{ mode?: string | string[]; redirect?: string | string[] }>();
+ const { width } = useWindowDimensions();
+
+ const initialMode = readFirstParam(params.mode) === "sign-up" ? "sign-up" : "sign-in";
+ const redirect = sanitizeRedirect(readFirstParam(params.redirect));
+
+ const [mode, setMode] = useState(initialMode);
+ const [phase, setPhase] = useState("form");
+ const [pendingEmail, setPendingEmail] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const showSidePanel = Platform.OS === "web" && width >= SIDE_PANEL_MIN_WIDTH;
+
+ const { signIn, setActive: setActiveSignIn, isLoaded: signInLoaded } = useSignIn();
+ const { signUp, setActive: setActiveSignUp, isLoaded: signUpLoaded } = useSignUp();
+
+ const isLoaded = signInLoaded && signUpLoaded;
+
+ function reset() {
+ setError("");
+ setNewPassword("");
+ }
+
+ function switchMode(next: AuthMode) {
+ if (loading) return;
+ setMode(next);
+ setPhase("form");
+ reset();
+ }
+
+ async function handleSignIn({ email, password }: { email: string; password: string }) {
+ if (!isLoaded) return;
+ setError("");
+ setLoading(true);
+ try {
+ const result = await signIn.create({ identifier: email, password });
+ if (result.status === "complete") {
+ await setActiveSignIn({ session: result.createdSessionId });
+ router.replace(redirect ?? "/");
+ } else if (
+ result.status === "needs_second_factor" ||
+ (result.status as string) === "needs_client_trust"
+ ) {
+ await signIn.prepareSecondFactor({ strategy: "email_code" });
+ setPendingEmail(email);
+ setPhase("verify");
+ } else {
+ setError(`Sign in requires additional steps: ${result.status}`);
+ }
+ } catch (err) {
+ setError(clerkErrorMessage(err, "Sign in failed"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleSignUp({ email, password }: { email: string; password: string }) {
+ if (!isLoaded) return;
+ setError("");
+ setLoading(true);
+ try {
+ await signUp.create({ emailAddress: email, password });
+ await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
+ setPendingEmail(email);
+ setPhase("verify");
+ } catch (err) {
+ setError(clerkErrorMessage(err, "Sign up failed"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleVerify(code: string) {
+ if (!isLoaded) return;
+ setError("");
+ setLoading(true);
+ try {
+ if (mode === "sign-in") {
+ const result = await signIn.attemptSecondFactor({ strategy: "email_code", code });
+ if (result.status === "complete") {
+ await setActiveSignIn({ session: result.createdSessionId });
+ router.replace(redirect ?? "/");
+ } else {
+ setError(`Verification incomplete: ${result.status}`);
+ }
+ } else {
+ const result = await signUp.attemptEmailAddressVerification({ code });
+ if (result.status === "complete") {
+ await setActiveSignUp({ session: result.createdSessionId });
+ router.replace(redirect ?? "/");
+ } else {
+ setError(`Verification incomplete: ${result.status}`);
+ }
+ }
+ } catch (err) {
+ setError(clerkErrorMessage(err, "Verification failed"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleResendVerify() {
+ if (!isLoaded) return;
+ setError("");
+ setLoading(true);
+ try {
+ if (mode === "sign-in") {
+ await signIn.prepareSecondFactor({ strategy: "email_code" });
+ } else {
+ await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
+ }
+ } catch (err) {
+ setError(clerkErrorMessage(err, "Failed to resend code"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleForgotStart(email: string) {
+ if (!isLoaded) return;
+ setError("");
+ setLoading(true);
+ try {
+ await signIn.create({
+ strategy: "reset_password_email_code",
+ identifier: email,
+ });
+ setPendingEmail(email);
+ setPhase("forgot-verify");
+ } catch (err) {
+ setError(clerkErrorMessage(err, "Could not start password reset"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleForgotVerify(code: string) {
+ if (!isLoaded) return;
+ setError("");
+ if (!newPassword || newPassword.length < 8) {
+ setError("New password must be at least 8 characters.");
+ return;
+ }
+ setLoading(true);
+ try {
+ const result = await signIn.attemptFirstFactor({
+ strategy: "reset_password_email_code",
+ code,
+ password: newPassword,
+ });
+ if (result.status === "complete") {
+ await setActiveSignIn({ session: result.createdSessionId });
+ router.replace(redirect ?? "/");
+ } else {
+ setError(`Reset incomplete: ${result.status}`);
+ }
+ } catch (err) {
+ setError(clerkErrorMessage(err, "Password reset failed"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleResendForgot() {
+ if (!isLoaded || !pendingEmail) return;
+ setError("");
+ setLoading(true);
+ try {
+ await signIn.create({
+ strategy: "reset_password_email_code",
+ identifier: pendingEmail,
+ });
+ } catch (err) {
+ setError(clerkErrorMessage(err, "Failed to resend code"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const card = (() => {
+ if (phase === "verify") {
+ return (
+ {
+ setPhase("form");
+ reset();
+ }}
+ />
+ );
+ }
+
+ if (phase === "forgot") {
+ return (
+ {
+ setPhase("form");
+ reset();
+ }}
+ />
+ );
+ }
+
+ if (phase === "forgot-verify") {
+ return (
+ {
+ setPhase("forgot");
+ reset();
+ }}
+ extra={
+
+
+ New password
+
+
+
+ }
+ />
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ or email
+
+
+
+
+ {
+ setPhase("forgot");
+ reset();
+ }}
+ />
+
+ {mode === "sign-up" ? (
+
+ By creating an account you agree to receive transactional email related to your aimux
+ sessions.
+
+ ) : null}
+
+ );
+ })();
+
+ return (
+
+
+ {showSidePanel ? : null}
+
+
+
+ {!showSidePanel ? : null}
+
+ {card}
+
+ {phase === "form" ? (
+
+
+
+
+ );
+}
+
+function readFirstParam(value: string | string[] | undefined): string | undefined {
+ if (Array.isArray(value)) return value[0];
+ return value;
+}
diff --git a/app/app/cli-auth.tsx b/app/app/cli-auth.tsx
index 5f01a859f..098e978ad 100644
--- a/app/app/cli-auth.tsx
+++ b/app/app/cli-auth.tsx
@@ -160,7 +160,7 @@ export default function CliAuthScreen() {
onPress={() => {
if (Platform.OS === "web") {
const here = window.location.pathname + window.location.search;
- window.location.href = `/sign-in?redirect=${encodeURIComponent(here)}`;
+ window.location.href = `/auth?mode=sign-in&redirect=${encodeURIComponent(here)}`;
}
}}
/>
diff --git a/app/app/landing.tsx b/app/app/landing.tsx
deleted file mode 100644
index e8ad4fe10..000000000
--- a/app/app/landing.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from "react";
-import { Platform, ScrollView, View } from "react-native";
-import { useRouter } from "expo-router";
-import { Button } from "@/components/ui/button";
-import { Text } from "@/components/ui/text";
-
-export default function LandingScreen() {
- const router = useRouter();
-
- return (
-
-
-
- aimux
-
-
- Manage your AI agents from anywhere.{"\n"}One dashboard for Claude, Codex, and shell
- sessions.
-
-
-
-
-
-
-
-
-
-
-
- {Platform.OS === "web" ? (
-
- Run `aimux serve` locally to connect your agents.
-
- ) : null}
-
-
- );
-}
-
-function FeatureRow({ title, description }: { title: string; description: string }) {
- return (
-
-
-
- {title}
- {description}
-
-
- );
-}
diff --git a/app/app/shares/invite/[ownerUserId]/[token]/accept.tsx b/app/app/shares/invite/[ownerUserId]/[token]/accept.tsx
index 8812aa4ce..3543c1dda 100644
--- a/app/app/shares/invite/[ownerUserId]/[token]/accept.tsx
+++ b/app/app/shares/invite/[ownerUserId]/[token]/accept.tsx
@@ -81,8 +81,10 @@ export default function AcceptShareInviteScreen() {
label="Sign in"
onPress={() =>
router.push({
- pathname: "/sign-in",
- params: invitePath ? { redirect: invitePath } : {},
+ pathname: "/auth",
+ params: invitePath
+ ? { mode: "sign-in", redirect: invitePath }
+ : { mode: "sign-in" },
})
}
/>
diff --git a/app/app/sign-in.tsx b/app/app/sign-in.tsx
deleted file mode 100644
index a69cb00a5..000000000
--- a/app/app/sign-in.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import React, { useRef, useState } from "react";
-import { View, TextInput, KeyboardAvoidingView, Platform } from "react-native";
-import { useSignIn } from "@clerk/clerk-expo";
-import { useLocalSearchParams, useRouter, type Href } from "expo-router";
-import { Text } from "@/components/ui/text";
-import { Button } from "@/components/ui/button";
-
-export default function SignInScreen() {
- const { signIn, setActive, isLoaded } = useSignIn();
- const router = useRouter();
- const params = useLocalSearchParams<{ redirect?: string | string[] }>();
- const redirect = sanitizeRedirect(
- Array.isArray(params.redirect) ? params.redirect[0] : params.redirect,
- );
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [error, setError] = useState("");
- const [loading, setLoading] = useState(false);
- const [needsVerification, setNeedsVerification] = useState(false);
- const [code, setCode] = useState("");
- const passwordRef = useRef(null);
-
- async function handleSignIn() {
- if (!isLoaded) return;
- setError("");
- setLoading(true);
- try {
- const result = await signIn.create({
- identifier: email,
- password,
- });
- if (result.status === "complete") {
- await setActive({ session: result.createdSessionId });
- router.replace(redirect ?? "/");
- } else if (
- result.status === "needs_second_factor" ||
- (result.status as string) === "needs_client_trust"
- ) {
- // Client trust (new device) or MFA — send email verification code
- await signIn.prepareSecondFactor({
- strategy: "email_code",
- });
- setNeedsVerification(true);
- } else {
- setError(`Sign in requires additional steps: ${result.status}`);
- }
- } catch (err: unknown) {
- setError(clerkErrorMessage(err, "Sign in failed"));
- } finally {
- setLoading(false);
- }
- }
-
- async function handleVerify() {
- if (!isLoaded) return;
- setError("");
- setLoading(true);
- try {
- const result = await signIn.attemptSecondFactor({
- strategy: "email_code",
- code,
- });
- if (result.status === "complete") {
- await setActive({ session: result.createdSessionId });
- router.replace(redirect ?? "/");
- }
- } catch (err: unknown) {
- setError(clerkErrorMessage(err, "Verification failed"));
- } finally {
- setLoading(false);
- }
- }
-
- async function handleResendCode() {
- if (!isLoaded) return;
- setError("");
- setLoading(true);
- try {
- await signIn.prepareSecondFactor({ strategy: "email_code" });
- setCode("");
- } catch (err: unknown) {
- setError(clerkErrorMessage(err, "Failed to resend code"));
- } finally {
- setLoading(false);
- }
- }
-
- return (
-
-
- Aimux
-
- {!needsVerification ? (
- <>
- passwordRef.current?.focus()}
- blurOnSubmit={false}
- />
-
-
-
- {error ? {error} : null}
-
-
-
- router.push("/sign-up")}
- />
- >
- ) : (
- <>
-
- We sent a verification code to {email}
-
-
-
-
- {error ? {error} : null}
-
-
-
-
-
- {
- setNeedsVerification(false);
- setCode("");
- setError("");
- }}
- />
- >
- )}
-
-
- );
-}
-
-function sanitizeRedirect(value: string | undefined): Href | null {
- if (!value) return null;
- if (!value.startsWith("/") || value.startsWith("//")) return null;
- return value as Href;
-}
-
-function clerkErrorMessage(err: unknown, fallback: string) {
- if (typeof err === "object" && err && "errors" in err) {
- const errors = (err as { errors?: Array<{ longMessage?: string; message?: string }> }).errors;
- const first = errors?.[0];
- return first?.longMessage ?? first?.message ?? fallback;
- }
- return fallback;
-}
diff --git a/app/app/sign-up.tsx b/app/app/sign-up.tsx
deleted file mode 100644
index 35088d298..000000000
--- a/app/app/sign-up.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-import React, { useRef, useState } from "react";
-import { View, TextInput, KeyboardAvoidingView, Platform } from "react-native";
-import { useSignUp } from "@clerk/clerk-expo";
-import { useRouter } from "expo-router";
-import { Text } from "@/components/ui/text";
-import { Button } from "@/components/ui/button";
-
-export default function SignUpScreen() {
- const { signUp, setActive, isLoaded } = useSignUp();
- const router = useRouter();
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [pendingVerification, setPendingVerification] = useState(false);
- const [code, setCode] = useState("");
- const [error, setError] = useState("");
- const [loading, setLoading] = useState(false);
- const passwordRef = useRef(null);
-
- async function handleSignUp() {
- if (!isLoaded) return;
- setError("");
- setLoading(true);
- try {
- await signUp.create({ emailAddress: email, password });
- await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
- setPendingVerification(true);
- } catch (err: any) {
- setError(err.errors?.[0]?.longMessage ?? "Sign up failed");
- } finally {
- setLoading(false);
- }
- }
-
- async function handleVerify() {
- if (!isLoaded) return;
- setError("");
- setLoading(true);
- try {
- const result = await signUp.attemptEmailAddressVerification({ code });
- if (result.status === "complete") {
- await setActive({ session: result.createdSessionId });
- }
- } catch (err: any) {
- setError(err.errors?.[0]?.longMessage ?? "Verification failed");
- } finally {
- setLoading(false);
- }
- }
-
- async function handleResendCode() {
- if (!isLoaded) return;
- setError("");
- setLoading(true);
- try {
- await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
- setCode("");
- } catch (err: any) {
- setError(err.errors?.[0]?.longMessage ?? "Failed to resend code");
- } finally {
- setLoading(false);
- }
- }
-
- function handleBack() {
- setPendingVerification(false);
- setCode("");
- setError("");
- }
-
- return (
-
-
-
- Create Account
-
-
- {!pendingVerification ? (
- <>
- passwordRef.current?.focus()}
- blurOnSubmit={false}
- />
-
-
-
- {error ? {error} : null}
-
-
-
- router.replace("/sign-in")}
- />
- >
- ) : (
- <>
-
- We sent a verification code to {email}
-
-
-
-
- {error ? {error} : null}
-
-
-
-
-
-
- >
- )}
-
-
- );
-}
diff --git a/app/components/AuthMenu.tsx b/app/components/AuthMenu.tsx
index 1492cefa3..e2bb5e093 100644
--- a/app/components/AuthMenu.tsx
+++ b/app/components/AuthMenu.tsx
@@ -30,7 +30,7 @@ export function AuthMenu() {
if (!isSignedIn) {
return (
- router.push("/sign-in")} />
+ router.push("/auth")} />
);
}
@@ -51,7 +51,7 @@ export function AuthMenu() {
className="flex-row items-center rounded-md px-3 py-2 active:bg-accent"
onPress={() => {
close();
- void signOut().then(() => router.replace("/sign-in"));
+ void signOut().then(() => router.replace("/auth"));
}}
>
Sign out
diff --git a/app/components/auth/BrandPanel.tsx b/app/components/auth/BrandPanel.tsx
new file mode 100644
index 000000000..1d06b4aa9
--- /dev/null
+++ b/app/components/auth/BrandPanel.tsx
@@ -0,0 +1,73 @@
+import React from "react";
+import { Platform, View } from "react-native";
+import { Text } from "@/components/ui/text";
+
+const FEATURES: Array<{ title: string; description: string }> = [
+ {
+ title: "Remote visibility",
+ description: "Monitor agent sessions from your phone or another machine.",
+ },
+ {
+ title: "Multi-project",
+ description: "Switch between projects. Each with its own worktrees, agents, and services.",
+ },
+ {
+ title: "Real-time",
+ description: "Live status updates via SSE. See what your agents are doing right now.",
+ },
+];
+
+interface BrandPanelProps {
+ variant: "side" | "compact";
+}
+
+export function BrandPanel({ variant }: BrandPanelProps) {
+ if (variant === "compact") {
+ return (
+
+
+ aimux
+
+
+ Manage your AI agents from anywhere.
+
+
+ );
+ }
+
+ return (
+
+
+
+ aimux
+
+
+ Manage your AI agents from anywhere. One dashboard for Claude, Codex, and shell sessions.
+
+
+
+ {FEATURES.map((feature) => (
+
+
+
+ {feature.title}
+
+ {feature.description}
+
+
+
+ ))}
+
+
+
+ {Platform.OS === "web" ? (
+
+ $ aimux serve
+
+ Run locally to connect your agents.
+
+
+ ) : null}
+
+ );
+}
diff --git a/app/components/auth/EmailPasswordForm.tsx b/app/components/auth/EmailPasswordForm.tsx
new file mode 100644
index 000000000..23ad2fea1
--- /dev/null
+++ b/app/components/auth/EmailPasswordForm.tsx
@@ -0,0 +1,102 @@
+import React, { useRef, useState } from "react";
+import { TextInput, View } from "react-native";
+import { Eye, EyeOff } from "lucide-react-native";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Text } from "@/components/ui/text";
+
+export type EmailPasswordMode = "sign-in" | "sign-up";
+
+interface EmailPasswordFormProps {
+ mode: EmailPasswordMode;
+ loading: boolean;
+ error: string;
+ onSubmit: (values: { email: string; password: string }) => void | Promise;
+ onForgotPassword?: () => void;
+}
+
+export function EmailPasswordForm({
+ mode,
+ loading,
+ error,
+ onSubmit,
+ onForgotPassword,
+}: EmailPasswordFormProps) {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+ const passwordRef = useRef(null);
+
+ const submit = () => onSubmit({ email, password });
+ const submitLabel = mode === "sign-in" ? "Sign in" : "Create account";
+ const loadingLabel = mode === "sign-in" ? "Signing in…" : "Creating account…";
+
+ return (
+
+ Email
+ passwordRef.current?.focus()}
+ blurOnSubmit={false}
+ />
+
+
+ Password
+ {mode === "sign-in" && onForgotPassword ? (
+
+ Forgot password?
+
+ ) : null}
+
+
+
+
+ setShowPassword((v) => !v)}
+ className="px-2 py-1"
+ accessibilityRole="button"
+ accessibilityLabel={showPassword ? "Hide password" : "Show password"}
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {error ? {error} : null}
+
+
+
+ );
+}
diff --git a/app/components/auth/ForgotPasswordForm.tsx b/app/components/auth/ForgotPasswordForm.tsx
new file mode 100644
index 000000000..407d8b6e1
--- /dev/null
+++ b/app/components/auth/ForgotPasswordForm.tsx
@@ -0,0 +1,49 @@
+import React, { useState } from "react";
+import { View } from "react-native";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Text } from "@/components/ui/text";
+
+interface ForgotPasswordFormProps {
+ loading: boolean;
+ error: string;
+ onSubmit: (email: string) => void | Promise;
+ onBack: () => void;
+}
+
+export function ForgotPasswordForm({ loading, error, onSubmit, onBack }: ForgotPasswordFormProps) {
+ const [email, setEmail] = useState("");
+
+ return (
+
+
+ Enter your email and we’ll send a code to reset your password.
+
+
+ Email
+ onSubmit(email)}
+ />
+
+ {error ? {error} : null}
+
+ onSubmit(email)}
+ disabled={loading}
+ />
+
+
+
+ );
+}
diff --git a/app/components/auth/OAuthRow.tsx b/app/components/auth/OAuthRow.tsx
new file mode 100644
index 000000000..65eaa77d5
--- /dev/null
+++ b/app/components/auth/OAuthRow.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { Pressable, View } from "react-native";
+import { Apple, Github, Mail } from "lucide-react-native";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+// Flip to true to enable OAuth. Day-one ships disabled with "Coming soon" copy.
+export const OAUTH_ENABLED = false;
+
+type Provider = {
+ key: "oauth_apple" | "oauth_google" | "oauth_github";
+ label: string;
+ Icon: typeof Apple;
+};
+
+const PROVIDERS: Provider[] = [
+ { key: "oauth_apple", label: "Continue with Apple", Icon: Apple },
+ { key: "oauth_google", label: "Continue with Google", Icon: Mail },
+ { key: "oauth_github", label: "Continue with GitHub", Icon: Github },
+];
+
+export function OAuthRow() {
+ return (
+
+ {PROVIDERS.map(({ key, label, Icon }) => (
+ {
+ // TODO: when OAUTH_ENABLED, call useSSO().startSSOFlow({ strategy: key }).
+ // WebBrowser.maybeCompleteAuthSession() must be called once at the route module top.
+ }}
+ className={cn(
+ "h-11 flex-row items-center justify-center rounded-lg border border-border bg-background",
+ !OAUTH_ENABLED && "opacity-50",
+ )}
+ >
+
+ {label}
+ {!OAUTH_ENABLED ? (
+
+ soon
+
+ ) : null}
+
+ ))}
+
+ );
+}
diff --git a/app/components/auth/VerificationForm.tsx b/app/components/auth/VerificationForm.tsx
new file mode 100644
index 000000000..fdd1528d5
--- /dev/null
+++ b/app/components/auth/VerificationForm.tsx
@@ -0,0 +1,80 @@
+import React, { useState } from "react";
+import { View } from "react-native";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Text } from "@/components/ui/text";
+
+interface VerificationFormProps {
+ email: string;
+ loading: boolean;
+ error: string;
+ submitLabel: string;
+ onSubmit: (code: string) => void | Promise;
+ onResend: () => void | Promise;
+ onBack: () => void;
+ /** Optional extra field (used by password reset to collect the new password) */
+ extra?: React.ReactNode;
+}
+
+export function VerificationForm({
+ email,
+ loading,
+ error,
+ submitLabel,
+ onSubmit,
+ onResend,
+ onBack,
+ extra,
+}: VerificationFormProps) {
+ const [code, setCode] = useState("");
+
+ return (
+
+
+ We sent a verification code to{"\n"}
+ {email}
+
+
+ onSubmit(code)}
+ />
+
+ {extra ? {extra} : null}
+
+ {error ? {error} : null}
+
+ onSubmit(code)}
+ disabled={loading}
+ />
+
+
+ Didn’t get it?
+ onResend()}
+ className="text-[13px] text-foreground underline decoration-muted-foreground"
+ >
+ Resend
+
+ ·
+
+ Back
+
+
+
+ );
+}
diff --git a/app/lib/clerk-errors.ts b/app/lib/clerk-errors.ts
new file mode 100644
index 000000000..afc976bce
--- /dev/null
+++ b/app/lib/clerk-errors.ts
@@ -0,0 +1,24 @@
+import type { Href } from "expo-router";
+
+export function clerkErrorMessage(err: unknown, fallback: string): string {
+ if (typeof err === "object" && err && "errors" in err) {
+ const errors = (
+ err as {
+ errors?: Array<{
+ longMessage?: string;
+ long_message?: string;
+ message?: string;
+ }>;
+ }
+ ).errors;
+ const first = errors?.[0];
+ return first?.longMessage ?? first?.long_message ?? first?.message ?? fallback;
+ }
+ return fallback;
+}
+
+export function sanitizeRedirect(value: string | undefined): Href | null {
+ if (!value) return null;
+ if (!value.startsWith("/") || value.startsWith("//")) return null;
+ return value as Href;
+}
diff --git a/app/lib/events.ts b/app/lib/events.ts
index 5341ca138..54df62d50 100644
--- a/app/lib/events.ts
+++ b/app/lib/events.ts
@@ -56,11 +56,7 @@ export interface StreamErrorEvent {
error: string;
}
-export type StreamEvent =
- | ReadyEvent
- | AlertEvent
- | AgentOutputEvent
- | StreamErrorEvent;
+export type StreamEvent = ReadyEvent | AlertEvent | AgentOutputEvent | StreamErrorEvent;
// Display-side representation of an image part as it appears in history.
export interface HistoryImagePart {
diff --git a/app/lib/heartbeat.ts b/app/lib/heartbeat.ts
index a6c8c166b..c491faf1a 100644
--- a/app/lib/heartbeat.ts
+++ b/app/lib/heartbeat.ts
@@ -103,10 +103,4 @@ export function startHeartbeat(options: HeartbeatOptions): HeartbeatHandle {
}
// Re-exports so callers can type-narrow without importing from events directly.
-export type {
- AgentOutputEvent,
- AlertEvent,
- ReadyEvent,
- StreamErrorEvent,
- StreamEvent,
-};
+export type { AgentOutputEvent, AlertEvent, ReadyEvent, StreamErrorEvent, StreamEvent };