From 2ed36a4cd7186af4a7bd8bf27022113b40f082bf Mon Sep 17 00:00:00 2001 From: Sol Date: Sun, 12 Apr 2026 10:04:38 +0000 Subject: [PATCH 1/2] fix(privy): provide mock context when Steward auth replaces Privy When NEXT_PUBLIC_STEWARD_AUTH_ENABLED is true and no valid Privy app ID is configured, render PrivyProviderReactAuth with a dummy app ID instead of bare fragments. This gives child components calling usePrivy() a valid context (returning unauthenticated defaults) rather than throwing due to missing provider. Co-authored-by: wakesync --- packages/lib/providers/PrivyProvider.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/lib/providers/PrivyProvider.tsx b/packages/lib/providers/PrivyProvider.tsx index 23f47e1bc..e8f223486 100644 --- a/packages/lib/providers/PrivyProvider.tsx +++ b/packages/lib/providers/PrivyProvider.tsx @@ -190,9 +190,16 @@ export default function PrivyProvider({ children }: { children: React.ReactNode if (!hasValidAppId) { // When Steward auth is enabled and Privy isn't configured, - // pass children through instead of blocking the whole app. + // provide a minimal Privy context with a dummy app ID so that + // child components calling usePrivy() get a valid context instead + // of throwing. Auth is fully handled by Steward; Privy hooks will + // simply report unauthenticated. if (process.env.NEXT_PUBLIC_STEWARD_AUTH_ENABLED === "true") { - return <>{children}; + return ( + + {children} + + ); } return (
From 201bee532fe0fa46dc40ceb5a49131c4ab15b82a Mon Sep 17 00:00:00 2001 From: Sol Date: Sun, 12 Apr 2026 10:05:31 +0000 Subject: [PATCH 2/2] fix: split login page so Privy hooks don't block Steward-only auth When NEXT_PUBLIC_STEWARD_AUTH_ENABLED=true and no Privy app ID is set, the login page now loads only the Steward login section via next/dynamic. Privy hooks (usePrivy, useLogin, etc.) are never imported or executed. - Created steward-login-section.tsx: self-contained StewardProvider + StewardLogin - Created privy-login-section.tsx: all Privy hooks and OAuth/email/wallet logic - Rewrote page.tsx: env-based conditional rendering with dynamic(() => import(), { ssr: false }) - Three modes: steward-only, both (steward first + divider + privy), privy-only (default) Co-authored-by: wakesync --- app/login/page.tsx | 837 ++++------------------------ app/login/privy-login-section.tsx | 583 +++++++++++++++++++ app/login/steward-login-section.tsx | 48 ++ 3 files changed, 728 insertions(+), 740 deletions(-) create mode 100644 app/login/privy-login-section.tsx create mode 100644 app/login/steward-login-section.tsx diff --git a/app/login/page.tsx b/app/login/page.tsx index 7a9717bf3..8753d6083 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,473 +1,46 @@ "use client"; -import { BrandButton, Input } from "@elizaos/cloud-ui"; -import { useLogin, useLoginWithEmail, useLoginWithOAuth, usePrivy } from "@privy-io/react-auth"; -import { StewardLogin } from "@stwd/react"; -import { ArrowLeft, Chrome, Github, Loader2, Mail, Wallet } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { StewardAuthProvider } from "@/packages/lib/providers/StewardProvider"; +import { Loader2 } from "lucide-react"; +import dynamic from "next/dynamic"; +import { Suspense } from "react"; import LandingHeader from "@/packages/ui/src/components/layout/landing-header"; const STEWARD_AUTH_ENABLED = process.env.NEXT_PUBLIC_STEWARD_AUTH_ENABLED === "true"; +const PRIVY_CONFIGURED = !!process.env.NEXT_PUBLIC_PRIVY_APP_ID; -// Discord SVG Icon Component -const DiscordIcon = ({ className }: { className?: string }) => ( - - - -); +// Dynamic imports: only load the section that's needed. +// ssr: false prevents Privy hooks from executing during SSR or when Privy is unconfigured. +const PrivyLoginSection = dynamic(() => import("./privy-login-section"), { + ssr: false, + loading: () => , +}); -const SIGNUP_ATTRIBUTION_STORAGE_KEYS = { - affiliate: "pending_affiliate_code", - referral: "pending_referral_code", -} as const; -const POST_LOGIN_SESSION_SYNC_DELAYS_MS = [250, 500, 1000, 1500, 2000] as const; +const StewardLoginSection = dynamic(() => import("./steward-login-section"), { + ssr: false, + loading: () => , +}); -function isLegacyAffiliateCode(code: string | null): boolean { - return !!code && /^AFF-[A-Z0-9]+$/i.test(code.trim()); -} - -function getPendingSignupAttribution(searchParams: { - get(name: string): string | null; - has(name: string): boolean; -}) { - const hasOAuthState = searchParams.has("state") || searchParams.has("privy_oauth_state"); - const affiliateCode = searchParams.get("affiliate"); - const referralCode = searchParams.get("ref") || searchParams.get("referral_code"); - const legacyCode = searchParams.get("code"); - - return { - affiliateCode: - affiliateCode || - (!hasOAuthState && isLegacyAffiliateCode(legacyCode) - ? (legacyCode?.trim().toUpperCase() ?? null) - : null), - referralCode: referralCode ? referralCode.trim().toUpperCase() : null, - }; -} - -function getSafeReturnTo(searchParams: { get(name: string): string | null }): string { - const returnTo = searchParams.get("returnTo"); - return returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//") - ? returnTo - : "/dashboard/milady"; -} - -const delay = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -function LoginPageContent() { - const { ready, authenticated, getAccessToken } = usePrivy(); - const { login } = useLogin(); - const { sendCode, loginWithCode, state: emailState } = useLoginWithEmail(); - const { initOAuth } = useLoginWithOAuth(); - const router = useRouter(); - const searchParams = useSearchParams(); - - const [email, setEmail] = useState(""); - const [code, setCode] = useState(""); - const [showCodeInput, setShowCodeInput] = useState(false); - const [loadingButton, setLoadingButton] = useState(null); - const [isSyncing, setIsSyncing] = useState(false); - const [isProcessingOAuth, setIsProcessingOAuth] = useState(() => { - // Initialize OAuth processing state on client-side only to prevent SSR hydration mismatch - if (typeof window === "undefined") return false; - const urlParams = new URLSearchParams(window.location.search); - const hasOAuthParams = - urlParams.has("privy_oauth_code") || - urlParams.has("privy_oauth_state") || - (urlParams.has("code") && (urlParams.has("state") || urlParams.has("privy_oauth_state"))); - const sessionFlag = sessionStorage.getItem("oauth_login_pending"); - return hasOAuthParams || sessionFlag === "true"; - }); - - // Check if this is a signup intent (from "Get Started" button) - const isSignupIntent = searchParams.get("intent") === "signup"; - const isAuthenticated = authenticated; - const isAuthReady = ready; - - const loginInProgressRef = useRef(false); - const lastLoginAttemptRef = useRef(0); - const postLoginProcessingRef = useRef(false); - - useEffect(() => { - const { affiliateCode, referralCode } = getPendingSignupAttribution(searchParams); - - if (affiliateCode) { - sessionStorage.setItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.affiliate, affiliateCode); - } - - if (referralCode) { - sessionStorage.setItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.referral, referralCode); - } - }, [searchParams]); - - // Redirect to dashboard if already authenticated - useEffect(() => { - if (!isAuthReady || !isAuthenticated || postLoginProcessingRef.current) { - return; - } - - postLoginProcessingRef.current = true; - let cancelled = false; - const redirectUrl = getSafeReturnTo(searchParams); - - const waitForServerSession = async () => { - for (const waitMs of POST_LOGIN_SESSION_SYNC_DELAYS_MS) { - if (cancelled) { - return false; - } - - if (waitMs > 0) { - await delay(waitMs); - } - - await getAccessToken().catch(() => null); - - if (cancelled) { - return false; - } - - try { - const response = await fetch("/api/v1/user", { - cache: "no-store", - credentials: "include", - headers: { - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - }, - }); - - if (response.ok || response.status === 403) { - return true; - } - } catch (error) { - if (cancelled) { - return false; - } - console.warn("Waiting for authenticated session to sync", error); - } - } - - return false; - }; - - const applyStoredSignupAttribution = async () => { - const affiliateCode = sessionStorage.getItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.affiliate); - const referralCode = sessionStorage.getItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.referral); - - const postAttribution = async (url: string, codeToApply: string, storageKey: string) => { - try { - for (let attempt = 0; attempt < 3; attempt++) { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: codeToApply }), - }); - - if ( - response.ok || - response.status === 400 || - response.status === 404 || - response.status === 409 - ) { - sessionStorage.removeItem(storageKey); - return; - } - - if (response.status !== 401) { - sessionStorage.removeItem(storageKey); - return; - } - - await delay(300 * (attempt + 1)); - } - } catch (error) { - console.error("Failed to apply signup attribution", error); - } - }; - - if (affiliateCode) { - await postAttribution( - "/api/v1/affiliates/link", - affiliateCode, - SIGNUP_ATTRIBUTION_STORAGE_KEYS.affiliate, - ); - } - - if (referralCode) { - await postAttribution( - "/api/v1/referrals/apply", - referralCode, - SIGNUP_ATTRIBUTION_STORAGE_KEYS.referral, - ); - } - }; - - void (async () => { - sessionStorage.removeItem("oauth_login_pending"); - loginInProgressRef.current = false; - setLoadingButton(null); - setIsProcessingOAuth(false); - setIsSyncing(true); - - await waitForServerSession(); - await applyStoredSignupAttribution(); - await delay(100); - - if (!cancelled) { - router.replace(redirectUrl); - } - })(); - - return () => { - cancelled = true; - }; - }, [getAccessToken, isAuthenticated, isAuthReady, router, searchParams]); - - useEffect(() => { - if (!isAuthReady || isAuthenticated) { - return; - } - - // If we're ready but not authenticated, ensure guard is cleared - // (handles case where user closes modal without connecting) - if (loginInProgressRef.current && !loadingButton) { - loginInProgressRef.current = false; - } - - // If OAuth processing timed out (ready but not authenticated after callback) - // clear the flag after a small delay to allow Privy to finish - if (isProcessingOAuth) { - const timeout = setTimeout(() => { - setIsProcessingOAuth(false); - sessionStorage.removeItem("oauth_login_pending"); - }, 3000); - return () => clearTimeout(timeout); - } - }, [isAuthReady, isAuthenticated, isProcessingOAuth, loadingButton]); - - // Monitor email state to show code input - useEffect(() => { - if (emailState.status === "awaiting-code-input") { - // Use setTimeout to avoid synchronous setState in effect - setTimeout(() => { - setShowCodeInput(true); - }, 0); - } - }, [emailState.status]); - - const handleSendCode = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!email || !email.includes("@")) { - toast.error("Please enter a valid email address"); - return; - } - - setLoadingButton("email"); - await sendCode({ email }); - toast.success("Verification code sent to your email"); - setShowCodeInput(true); - setLoadingButton(null); - }; - - const handleVerifyCode = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!code || code.length !== 6) { - toast.error("Please enter a valid 6-digit code"); - return; - } - - setLoadingButton("verify"); - await loginWithCode({ code }); - toast.success("Email verified! Setting up your account..."); - // Privy will auto-redirect to dashboard via our useEffect - setLoadingButton(null); - }; - - const handleOAuthLogin = async (provider: "google" | "discord" | "github") => { - setLoadingButton(provider); - // Set session flag to detect OAuth callback when returning - sessionStorage.setItem("oauth_login_pending", "true"); - toast.loading(`Redirecting to ${provider}...`); - await initOAuth({ provider }); - // This will redirect to OAuth provider - }; - - const handleWalletConnect = async () => { - // Guard: Prevent multiple simultaneous login attempts (macOS/Brave issue) - if (loginInProgressRef.current) { - return; - } - - // Debounce: Prevent rapid successive calls (500ms cooldown) - const now = Date.now(); - if (now - lastLoginAttemptRef.current < 500) { - return; - } - - // Guard: Don't open login if already authenticated - if (authenticated) { - return; - } - - // Set guards - loginInProgressRef.current = true; - lastLoginAttemptRef.current = now; - setLoadingButton("wallet"); - - // Use login() with loginMethods restricted to 'wallet' to directly show wallet options - // Authentication state changes are handled via the authenticated state in useEffect - login({ loginMethods: ["wallet"] }); - - // Reset the guard after a short delay to allow modal to open - // If authentication succeeds, the useEffect will handle redirect - // If user closes modal, this timeout resets the guard for retry - setTimeout(() => { - // Only reset if still in progress (not authenticated yet) - if (loginInProgressRef.current) { - loginInProgressRef.current = false; - setLoadingButton(null); - } - }, 2000); // 2 second timeout - }; - - const handleBackToEmail = () => { - setShowCodeInput(false); - setCode(""); - }; - - // Show loading state while checking authentication or processing OAuth callback - if (!isAuthReady || isProcessingOAuth) { - return ( -
- {/* Header */} - - - {/* Gradient background */} -
- {/* Noise overlay */} -
-
- -
-
-
-
- -
-
-
-

- {isProcessingOAuth ? "Completing sign in..." : "Loading..."} -

-

- {isProcessingOAuth ? "Processing your authentication" : "Initializing..."} -

-
-
-
-
-
-
-
-
-
+function LoginSectionSpinner() { + return ( +
+
+ +
- ); - } - - // Don't render login page if already authenticated (redirecting) - if (isAuthenticated || isSyncing) { - return ( -
- {/* Header */} - - - {/* Gradient background */} -
- {/* Noise overlay */} -
-
- -
-
-
-
- -
-
-
-

Signing you in

-

Taking you to your dashboard...

-
-
-
-
-
-
-
-
-
+
+

Loading...

+

Initializing...

- ); - } +
+ ); +} +// Shared gradient background used by all login states +function GradientBackground({ children }: { children: React.ReactNode }) { return (
- {/* Header */} - {/* Gradient background */}
- {/* Noise overlay */}
-
- {/* Header */} -
-

- {isSignupIntent ? "Create Account" : "Welcome back"} -

-

- {isSignupIntent - ? "Sign up to get started with Eliza Cloud" - : "Sign in to your Eliza Cloud account"} -

-
- {/* Steward Auth Section (feature-flagged) */} - {STEWARD_AUTH_ENABLED && !showCodeInput && ( -
- - { - toast.success("Signed in with Steward!"); - const redirectUrl = getSafeReturnTo(searchParams); - router.replace(redirectUrl); - }} - onError={(err) => { - toast.error(err?.message || "Steward login failed"); - }} - /> - - - {/* Divider between Steward and Privy options */} -
-
-
-
-
- or -
-
-
- )} - - {/* Email/Code Login Section */} - {!showCodeInput ? ( - // Email Input -
-
- setEmail(e.target.value)} - disabled={loadingButton !== null} - className="h-11 rounded-xl border-white/10 bg-black/40 text-white placeholder:text-neutral-600 focus:ring-1 focus:ring-[#FF5800] focus:border-[#FF5800]" - autoFocus - /> -
- - {loadingButton === "email" ? ( - <> - - Sending code... - - ) : ( - <> - - Continue with Email - - )} - -
- ) : ( - // Code Input -
-
- - setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} - disabled={loadingButton !== null} - className="h-12 rounded-xl border-white/10 bg-black/40 text-white placeholder:text-neutral-600 focus:ring-1 focus:ring-[#FF5800] focus:border-[#FF5800] text-center text-xl tracking-[0.3em] font-mono" - maxLength={6} - autoFocus - /> -

- Enter the 6-digit code sent to{" "} - {email} -

-
- - {loadingButton === "verify" ? ( - <> - - Verifying... - - ) : ( - "Verify & Sign In" - )} - - -
- )} - - {/* Only show other login options on the initial screen */} - {!showCodeInput && ( - <> - {/* Divider */} -
-
-
-
-
- or -
-
- - {/* OAuth Buttons */} -
- - -
- - - -
-
- - {/* Wallet Connect */} - - - )} - - {/* Footer */} -

- By signing in, you agree to our{" "} - - Terms - {" "} - and{" "} - - Privacy Policy - -

-
+ {children}
); } -// Loading fallback component - matches the styled loading state -function LoginPageFallback() { - return ( -
- {/* Gradient background */} -
- {/* Noise overlay */} -
-
+function LoginPageContent() { + const stewardOnly = STEWARD_AUTH_ENABLED && !PRIVY_CONFIGURED; + const both = STEWARD_AUTH_ENABLED && PRIVY_CONFIGURED; + const privyOnly = !STEWARD_AUTH_ENABLED; -
-
-
-
- -
-
-
-

Loading...

-

Initializing...

+ if (stewardOnly) { + return ( + +
+
+

Welcome back

+

Sign in to your Eliza Cloud account

+
+ +

+ By signing in, you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + +

+
+
+ ); + } + + if (both) { + return ( + +
+
+

Welcome back

+

Sign in to your Eliza Cloud account

+
+ + {/* Divider between Steward and Privy sections */} +
+
+
-
-
-
-
+
+ or
+
-
-
+ + ); + } + + // privyOnly (default / existing behavior) + return ( + + + ); } /** * Login page component with authentication options. - * Supports email verification, OAuth (Google, Discord, GitHub), and wallet connection. - * Wrapped in Suspense for client-side navigation. + * Conditionally loads Steward and/or Privy auth based on env configuration. + * Uses dynamic imports so Privy hooks are never loaded when Privy is unconfigured. */ export default function LoginPage() { return ( - }> + + + + } + > ); diff --git a/app/login/privy-login-section.tsx b/app/login/privy-login-section.tsx new file mode 100644 index 000000000..2d740ac83 --- /dev/null +++ b/app/login/privy-login-section.tsx @@ -0,0 +1,583 @@ +"use client"; + +import { BrandButton, Input } from "@elizaos/cloud-ui"; +import { + useLogin, + useLoginWithEmail, + useLoginWithOAuth, + usePrivy, +} from "@privy-io/react-auth"; +import { ArrowLeft, Chrome, Github, Loader2, Mail, Wallet } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import LandingHeader from "@/packages/ui/src/components/layout/landing-header"; + +// Discord SVG Icon Component +const DiscordIcon = ({ className }: { className?: string }) => ( + + + +); + +const SIGNUP_ATTRIBUTION_STORAGE_KEYS = { + affiliate: "pending_affiliate_code", + referral: "pending_referral_code", +} as const; +const POST_LOGIN_SESSION_SYNC_DELAYS_MS = [250, 500, 1000, 1500, 2000] as const; + +function isLegacyAffiliateCode(code: string | null): boolean { + return !!code && /^AFF-[A-Z0-9]+$/i.test(code.trim()); +} + +function getPendingSignupAttribution(searchParams: { + get(name: string): string | null; + has(name: string): boolean; +}) { + const hasOAuthState = searchParams.has("state") || searchParams.has("privy_oauth_state"); + const affiliateCode = searchParams.get("affiliate"); + const referralCode = searchParams.get("ref") || searchParams.get("referral_code"); + const legacyCode = searchParams.get("code"); + + return { + affiliateCode: + affiliateCode || + (!hasOAuthState && isLegacyAffiliateCode(legacyCode) + ? (legacyCode?.trim().toUpperCase() ?? null) + : null), + referralCode: referralCode ? referralCode.trim().toUpperCase() : null, + }; +} + +function getSafeReturnTo(searchParams: { get(name: string): string | null }): string { + const returnTo = searchParams.get("returnTo"); + return returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//") + ? returnTo + : "/dashboard/milady"; +} + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export default function PrivyLoginSection() { + const { ready, authenticated, getAccessToken } = usePrivy(); + const { login } = useLogin(); + const { sendCode, loginWithCode, state: emailState } = useLoginWithEmail(); + const { initOAuth } = useLoginWithOAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [showCodeInput, setShowCodeInput] = useState(false); + const [loadingButton, setLoadingButton] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); + const [isProcessingOAuth, setIsProcessingOAuth] = useState(() => { + if (typeof window === "undefined") return false; + const urlParams = new URLSearchParams(window.location.search); + const hasOAuthParams = + urlParams.has("privy_oauth_code") || + urlParams.has("privy_oauth_state") || + (urlParams.has("code") && (urlParams.has("state") || urlParams.has("privy_oauth_state"))); + const sessionFlag = sessionStorage.getItem("oauth_login_pending"); + return hasOAuthParams || sessionFlag === "true"; + }); + + const isSignupIntent = searchParams.get("intent") === "signup"; + const isAuthenticated = authenticated; + const isAuthReady = ready; + + const loginInProgressRef = useRef(false); + const lastLoginAttemptRef = useRef(0); + const postLoginProcessingRef = useRef(false); + + useEffect(() => { + const { affiliateCode, referralCode } = getPendingSignupAttribution(searchParams); + + if (affiliateCode) { + sessionStorage.setItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.affiliate, affiliateCode); + } + + if (referralCode) { + sessionStorage.setItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.referral, referralCode); + } + }, [searchParams]); + + // Redirect to dashboard if already authenticated + useEffect(() => { + if (!isAuthReady || !isAuthenticated || postLoginProcessingRef.current) { + return; + } + + postLoginProcessingRef.current = true; + let cancelled = false; + const redirectUrl = getSafeReturnTo(searchParams); + + const waitForServerSession = async () => { + for (const waitMs of POST_LOGIN_SESSION_SYNC_DELAYS_MS) { + if (cancelled) { + return false; + } + + if (waitMs > 0) { + await delay(waitMs); + } + + await getAccessToken().catch(() => null); + + if (cancelled) { + return false; + } + + try { + const response = await fetch("/api/v1/user", { + cache: "no-store", + credentials: "include", + headers: { + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + }, + }); + + if (response.ok || response.status === 403) { + return true; + } + } catch (error) { + if (cancelled) { + return false; + } + console.warn("Waiting for authenticated session to sync", error); + } + } + + return false; + }; + + const applyStoredSignupAttribution = async () => { + const affiliateCode = sessionStorage.getItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.affiliate); + const referralCode = sessionStorage.getItem(SIGNUP_ATTRIBUTION_STORAGE_KEYS.referral); + + const postAttribution = async (url: string, codeToApply: string, storageKey: string) => { + try { + for (let attempt = 0; attempt < 3; attempt++) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: codeToApply }), + }); + + if ( + response.ok || + response.status === 400 || + response.status === 404 || + response.status === 409 + ) { + sessionStorage.removeItem(storageKey); + return; + } + + if (response.status !== 401) { + sessionStorage.removeItem(storageKey); + return; + } + + await delay(300 * (attempt + 1)); + } + } catch (error) { + console.error("Failed to apply signup attribution", error); + } + }; + + if (affiliateCode) { + await postAttribution( + "/api/v1/affiliates/link", + affiliateCode, + SIGNUP_ATTRIBUTION_STORAGE_KEYS.affiliate, + ); + } + + if (referralCode) { + await postAttribution( + "/api/v1/referrals/apply", + referralCode, + SIGNUP_ATTRIBUTION_STORAGE_KEYS.referral, + ); + } + }; + + void (async () => { + sessionStorage.removeItem("oauth_login_pending"); + loginInProgressRef.current = false; + setLoadingButton(null); + setIsProcessingOAuth(false); + setIsSyncing(true); + + await waitForServerSession(); + await applyStoredSignupAttribution(); + await delay(100); + + if (!cancelled) { + router.replace(redirectUrl); + } + })(); + + return () => { + cancelled = true; + }; + }, [getAccessToken, isAuthenticated, isAuthReady, router, searchParams]); + + useEffect(() => { + if (!isAuthReady || isAuthenticated) { + return; + } + + if (loginInProgressRef.current && !loadingButton) { + loginInProgressRef.current = false; + } + + if (isProcessingOAuth) { + const timeout = setTimeout(() => { + setIsProcessingOAuth(false); + sessionStorage.removeItem("oauth_login_pending"); + }, 3000); + return () => clearTimeout(timeout); + } + }, [isAuthReady, isAuthenticated, isProcessingOAuth, loadingButton]); + + useEffect(() => { + if (emailState.status === "awaiting-code-input") { + setTimeout(() => { + setShowCodeInput(true); + }, 0); + } + }, [emailState.status]); + + const handleSendCode = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!email || !email.includes("@")) { + toast.error("Please enter a valid email address"); + return; + } + + setLoadingButton("email"); + await sendCode({ email }); + toast.success("Verification code sent to your email"); + setShowCodeInput(true); + setLoadingButton(null); + }; + + const handleVerifyCode = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!code || code.length !== 6) { + toast.error("Please enter a valid 6-digit code"); + return; + } + + setLoadingButton("verify"); + await loginWithCode({ code }); + toast.success("Email verified! Setting up your account..."); + setLoadingButton(null); + }; + + const handleOAuthLogin = async (provider: "google" | "discord" | "github") => { + setLoadingButton(provider); + sessionStorage.setItem("oauth_login_pending", "true"); + toast.loading(`Redirecting to ${provider}...`); + await initOAuth({ provider }); + }; + + const handleWalletConnect = async () => { + if (loginInProgressRef.current) { + return; + } + + const now = Date.now(); + if (now - lastLoginAttemptRef.current < 500) { + return; + } + + if (authenticated) { + return; + } + + loginInProgressRef.current = true; + lastLoginAttemptRef.current = now; + setLoadingButton("wallet"); + + login({ loginMethods: ["wallet"] }); + + setTimeout(() => { + if (loginInProgressRef.current) { + loginInProgressRef.current = false; + setLoadingButton(null); + } + }, 2000); + }; + + const handleBackToEmail = () => { + setShowCodeInput(false); + setCode(""); + }; + + // Show loading state while checking authentication or processing OAuth callback + if (!isAuthReady || isProcessingOAuth) { + return ( +
+
+ +
+
+
+

+ {isProcessingOAuth ? "Completing sign in..." : "Loading..."} +

+

+ {isProcessingOAuth ? "Processing your authentication" : "Initializing..."} +

+
+
+
+
+
+
+
+ ); + } + + // Redirecting state + if (isAuthenticated || isSyncing) { + return ( +
+
+ +
+
+
+

Signing you in

+

Taking you to your dashboard...

+
+
+
+
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ {isSignupIntent ? "Create Account" : "Welcome back"} +

+

+ {isSignupIntent + ? "Sign up to get started with Eliza Cloud" + : "Sign in to your Eliza Cloud account"} +

+
+ + {/* Email/Code Login Section */} + {!showCodeInput ? ( +
+
+ setEmail(e.target.value)} + disabled={loadingButton !== null} + className="h-11 rounded-xl border-white/10 bg-black/40 text-white placeholder:text-neutral-600 focus:ring-1 focus:ring-[#FF5800] focus:border-[#FF5800]" + autoFocus + /> +
+ + {loadingButton === "email" ? ( + <> + + Sending code... + + ) : ( + <> + + Continue with Email + + )} + +
+ ) : ( +
+
+ + setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + disabled={loadingButton !== null} + className="h-12 rounded-xl border-white/10 bg-black/40 text-white placeholder:text-neutral-600 focus:ring-1 focus:ring-[#FF5800] focus:border-[#FF5800] text-center text-xl tracking-[0.3em] font-mono" + maxLength={6} + autoFocus + /> +

+ Enter the 6-digit code sent to{" "} + {email} +

+
+ + {loadingButton === "verify" ? ( + <> + + Verifying... + + ) : ( + "Verify & Sign In" + )} + + +
+ )} + + {/* Only show other login options on the initial screen */} + {!showCodeInput && ( + <> + {/* Divider */} +
+
+
+
+
+ or +
+
+ + {/* OAuth Buttons */} +
+ + +
+ + + +
+
+ + {/* Wallet Connect */} + + + )} + + {/* Footer */} +

+ By signing in, you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + +

+
+ ); +} diff --git a/app/login/steward-login-section.tsx b/app/login/steward-login-section.tsx new file mode 100644 index 000000000..1593f6566 --- /dev/null +++ b/app/login/steward-login-section.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { StewardLogin, StewardProvider } from "@stwd/react"; +import { StewardClient } from "@stwd/sdk"; +import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "sonner"; + +const STEWARD_AUTH_BASE_URL = + process.env.NEXT_PUBLIC_STEWARD_AUTH_BASE_URL || "https://api.steward.fi"; +const STEWARD_TENANT_ID = process.env.NEXT_PUBLIC_STEWARD_TENANT_ID || undefined; + +function getSafeReturnTo(searchParams: { get(name: string): string | null }): string { + const returnTo = searchParams.get("returnTo"); + return returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//") + ? returnTo + : "/dashboard/milady"; +} + +export default function StewardLoginSection() { + const router = useRouter(); + const searchParams = useSearchParams(); + + return ( +
+ + { + toast.success("Signed in with Steward!"); + const redirectUrl = getSafeReturnTo(searchParams); + router.replace(redirectUrl); + }} + onError={(err) => { + toast.error(err?.message || "Steward login failed"); + }} + /> + +
+ ); +}