-
-
-
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 */}
+
-
-
+
+ );
+ }
+
+ // 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 ? (
+
+ ) : (
+
+ )}
+
+ {/* Only show other login options on the initial screen */}
+ {!showCodeInput && (
+ <>
+ {/* Divider */}
+
+
+ {/* OAuth Buttons */}
+
+
handleOAuthLogin("google")}
+ disabled={loadingButton !== null}
+ className="w-full flex items-center justify-center gap-2 h-11 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-colors disabled:opacity-50"
+ >
+ {loadingButton === "google" ? (
+
+ ) : (
+ <>
+
+ Continue with Google
+ >
+ )}
+
+
+
+ handleOAuthLogin("discord")}
+ disabled={loadingButton !== null}
+ className="flex items-center justify-center gap-2 h-11 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-colors disabled:opacity-50"
+ >
+ {loadingButton === "discord" ? (
+
+ ) : (
+ <>
+
+ Discord
+ >
+ )}
+
+
+ handleOAuthLogin("github")}
+ disabled={loadingButton !== null}
+ className="flex items-center justify-center gap-2 h-11 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-colors disabled:opacity-50"
+ >
+ {loadingButton === "github" ? (
+
+ ) : (
+ <>
+
+ GitHub
+ >
+ )}
+
+
+
+
+ {/* Wallet Connect */}
+
+ {loadingButton === "wallet" ? (
+
+ ) : (
+
+ )}
+ Connect Wallet
+
+ >
+ )}
+
+ {/* 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");
+ }}
+ />
+
+
+ );
+}
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 (