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" ? ( +