diff --git a/packages/keychain/src/components/ConnectRoute.test.tsx b/packages/keychain/src/components/ConnectRoute.test.tsx index b46146fc78..8474a9a033 100644 --- a/packages/keychain/src/components/ConnectRoute.test.tsx +++ b/packages/keychain/src/components/ConnectRoute.test.tsx @@ -87,6 +87,12 @@ describe("ConnectRoute", () => { name: "TestApp", verified: true, }, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); }); @@ -100,6 +106,12 @@ describe("ConnectRoute", () => { controller: mockController, policies: null, verified: true, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -122,6 +134,12 @@ describe("ConnectRoute", () => { messages: [], }, verified: true, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -148,6 +166,12 @@ describe("ConnectRoute", () => { messages: [], }, verified: false, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -165,6 +189,12 @@ describe("ConnectRoute", () => { messages: [], }, verified: true, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -198,6 +228,12 @@ describe("ConnectRoute", () => { name: "TestApp", verified: true, }, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -222,6 +258,12 @@ describe("ConnectRoute", () => { name: "TestApp", verified: true, }, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -243,6 +285,12 @@ describe("ConnectRoute", () => { name: "TestApp", verified: true, }, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -263,6 +311,12 @@ describe("ConnectRoute", () => { messages: [], }, verified: false, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -279,6 +333,12 @@ describe("ConnectRoute", () => { name: "TestApp", verified: false, }, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -294,6 +354,12 @@ describe("ConnectRoute", () => { controller: mockController, policies: null, verified: true, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -321,6 +387,12 @@ describe("ConnectRoute", () => { messages: [], }, verified: true, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -335,6 +407,12 @@ describe("ConnectRoute", () => { controller: null, policies: null, verified: false, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); const { container } = renderWithProviders(); @@ -356,6 +434,12 @@ describe("ConnectRoute", () => { name: "TestApp", verified: true, }, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -380,6 +464,12 @@ describe("ConnectRoute", () => { controller: mockController, policies: null, verified: true, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -406,6 +496,12 @@ describe("ConnectRoute", () => { messages: [{ id: "3", content: "Sign this", authorized: true }], }, verified: true, + isNewUser: false, + authMethod: undefined, + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), }); renderWithProviders(); @@ -423,4 +519,117 @@ describe("ConnectRoute", () => { }); }); }); + + describe("Success screen", () => { + it("shows success screen when showSuccessScreen is true in context", () => { + mockUseConnection.mockReturnValue({ + controller: mockController, + policies: null, + verified: true, + isNewUser: true, + authMethod: "webauthn", + showSuccessScreen: true, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), + }); + + renderWithProviders(); + + // Should show ConnectionSuccess component + expect(screen.getByText(/Success!/i)).toBeInTheDocument(); + }); + + it("hides success screen after 1 second timeout", async () => { + const mockSetShowSuccessScreen = vi.fn(); + + mockUseConnection.mockReturnValue({ + controller: mockController, + policies: null, + verified: true, + isNewUser: false, + authMethod: "webauthn", + showSuccessScreen: true, + setShowSuccessScreen: mockSetShowSuccessScreen, + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), + }); + + renderWithProviders(); + + // Should show success screen initially + expect(screen.getByText(/Success!/i)).toBeInTheDocument(); + + // setShowSuccessScreen should not be called immediately + expect(mockSetShowSuccessScreen).not.toHaveBeenCalled(); + + // Wait for the timeout (1 second) to complete + await waitFor( + () => { + expect(mockSetShowSuccessScreen).toHaveBeenCalledWith(false); + }, + { timeout: 1500 }, + ); + }); + + it("does not show success screen when controller is not available", () => { + mockUseConnection.mockReturnValue({ + controller: null, + policies: null, + verified: false, + isNewUser: true, + authMethod: "webauthn", + showSuccessScreen: true, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), + }); + + const { container } = renderWithProviders(); + + // Should return null when controller is not available + expect(container.firstChild).toBeNull(); + }); + + it("does not show success screen when showSuccessScreen is false", () => { + mockUseConnection.mockReturnValue({ + controller: mockController, + policies: null, + verified: true, + isNewUser: true, + authMethod: "webauthn", + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), + }); + + renderWithProviders(); + + // Should NOT show success screen + expect(screen.queryByText(/Success!/i)).not.toBeInTheDocument(); + }); + + it("prevents auto-connect when showing success screen", () => { + mockUseConnection.mockReturnValue({ + controller: mockController, + policies: null, + verified: true, + isNewUser: true, + authMethod: "webauthn", + showSuccessScreen: true, + setShowSuccessScreen: vi.fn(), + setIsNewUser: vi.fn(), + setAuthMethod: vi.fn(), + }); + + renderWithProviders(); + + // Should show success screen + expect(screen.getByText(/Success!/i)).toBeInTheDocument(); + + // Should NOT auto-connect while showing success screen + expect(mockParams.resolve).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/keychain/src/components/ConnectRoute.tsx b/packages/keychain/src/components/ConnectRoute.tsx index 2ff759ad48..8dc495f4be 100644 --- a/packages/keychain/src/components/ConnectRoute.tsx +++ b/packages/keychain/src/components/ConnectRoute.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { ResponseCodes } from "@cartridge/controller"; import { useConnection } from "@/hooks/connection"; import { hasApprovalPolicies } from "@/hooks/session"; @@ -14,6 +14,7 @@ import { } from "@/hooks/route"; import { isIframe } from "@cartridge/ui/utils"; import { safeRedirect } from "@/utils/url-validator"; +import { ConnectionSuccess } from "./connect/ConnectionSuccess"; const CANCEL_RESPONSE = { code: ResponseCodes.CANCELED, @@ -21,8 +22,34 @@ const CANCEL_RESPONSE = { }; export function ConnectRoute() { - const { controller, policies, verified } = useConnection(); - const [hasAutoConnected, setHasAutoConnected] = useState(false); + const { + controller, + policies, + verified, + authMethod, + isNewUser, + showSuccessScreen, + setShowSuccessScreen, + } = useConnection(); + + const timeoutRef = useRef(null); + + // Handle timeout to hide success screen after 1 second + useEffect(() => { + if (showSuccessScreen && controller && !timeoutRef.current) { + timeoutRef.current = setTimeout(() => { + setShowSuccessScreen(false); + timeoutRef.current = null; + }, 1000); + } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [showSuccessScreen, controller, setShowSuccessScreen]); // Parse params and set RPC URL immediately const params = useRouteParams((searchParams: URLSearchParams) => { @@ -97,8 +124,9 @@ export function ConnectRoute() { }, [params, controller, handleCompletion, isStandalone, redirectUrl]); // Handle cases where we can connect immediately (embedded mode only) + // Don't run if we're showing success screen useEffect(() => { - if (!params || !controller || hasAutoConnected) { + if (!params || !controller || showSuccessScreen) { return; } @@ -108,9 +136,6 @@ export function ConnectRoute() { return; } - // Mark as auto-connected immediately to prevent race conditions - setHasAutoConnected(true); - // if no policies, we can connect immediately if (!policies) { params.resolve?.({ @@ -126,7 +151,6 @@ export function ConnectRoute() { } // Bypass session approval screen for verified sessions in embedded mode - // Note: This is a fallback - main logic is handled in useCreateController if (policies.verified && !isStandalone) { if (hasTokenApprovals) { return; @@ -164,7 +188,8 @@ export function ConnectRoute() { handleCompletion, isStandalone, redirectUrl, - hasAutoConnected, + showSuccessScreen, + setShowSuccessScreen, hasTokenApprovals, ]); @@ -173,6 +198,11 @@ export function ConnectRoute() { return null; } + // Show success screen for 1 second when controller is first created + if (showSuccessScreen) { + return ; + } + // In standalone mode with redirect_url, show connect UI if (isStandalone && redirectUrl) { // If verified session without approvals, show simple connect screen diff --git a/packages/keychain/src/components/connect/ConnectionSuccess.tsx b/packages/keychain/src/components/connect/ConnectionSuccess.tsx index 85f1263cd6..b631b750cf 100644 --- a/packages/keychain/src/components/connect/ConnectionSuccess.tsx +++ b/packages/keychain/src/components/connect/ConnectionSuccess.tsx @@ -1,8 +1,8 @@ import { CheckIcon, + HeaderInner, LayoutContainer, LayoutContent, - LayoutHeader, } from "@cartridge/ui"; import { AuthOption } from "@cartridge/controller"; import { getAuthMethodDisplayName, getAuthMethodIcon } from "@/utils/auth"; @@ -20,12 +20,9 @@ export function ConnectionSuccess({ return ( - } - hideUsername - hideSettings - onBack={() => {}} /> diff --git a/packages/keychain/src/components/connect/create/CreateController.test.tsx b/packages/keychain/src/components/connect/create/CreateController.test.tsx index eaad3a5830..a505fb173d 100644 --- a/packages/keychain/src/components/connect/create/CreateController.test.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.test.tsx @@ -202,9 +202,18 @@ describe("CreateController", () => { authMethod: undefined, setAuthMethod: vi.fn(), }); + mockUseUsernameValidation.mockReturnValue({ + status: "valid", + exists: false, // This makes isNew=true in ConnectionLoading + error: undefined, + signers: undefined, + }); renderComponent(); - const submitButton = screen.getByTestId("submit-button"); - expect(submitButton).toBeDisabled(); + // When loading, ConnectionLoading component is shown instead of the form + // The submit button should not be visible + expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument(); + // Check for loading indicator - ConnectionLoading shows "Sign Up" or "Log In" in the title + expect(screen.getByText(/Sign Up|Log In/i)).toBeInTheDocument(); }); it("shows error message when validation fails", async () => { const errorMessage = diff --git a/packages/keychain/src/components/connect/create/CreateController.tsx b/packages/keychain/src/components/connect/create/CreateController.tsx index 390c1540df..e009ee22e6 100644 --- a/packages/keychain/src/components/connect/create/CreateController.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.tsx @@ -31,6 +31,7 @@ import { } from "@/hooks/viewport"; import { useDevice } from "@/hooks/device"; import { AccountSearchResult } from "@/hooks/account"; +import { ConnectionLoading } from "../ConnectionLoading"; interface CreateControllerViewProps { theme: VerifiableControllerTheme; @@ -593,7 +594,12 @@ export function CreateController({ } }, [authenticationStep, setAuthMethod]); - return ( + return isLoading ? ( + + ) : ( <> ; - handleCompletion: () => void; - closeModal?: () => void; - searchParams: URLSearchParams; -}) => { - // Handle no policies case - try to resolve connection, fallback to just closing modal - if (!policies) { - if (params) { - // Ideal case: resolve connection promise properly - params.resolve?.({ - code: ResponseCodes.SUCCESS, - address: controller.address(), - }); - if (params.params.id) { - cleanupCallbacks(params.params.id); - } - handleCompletion(); - } else { - // Fallback: just close modal if params not available (race condition) - console.warn( - "No params available for no-policies case, falling back to closeModal", - ); - closeModal?.(); - } - return; - } - - // For verified policies, we need params to properly notify parent - // Try to wait for params briefly if not available - let currentParams = params; - if (!currentParams) { - // Brief wait for params to be available (up to 500ms) - for (let i = 0; i < 5; i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - currentParams = parseConnectParams(searchParams); - if (currentParams) break; - } - } - - if (!currentParams) { - console.error( - "Params not available for verified policies, cannot resolve connection", - ); - // Don't close modal - let normal flow handle it - return; - } - - try { - // Use a default duration for verified sessions (24 hours) - const duration = BigInt(24 * 60 * 60); // 24 hours in seconds - const expiresAt = duration + now(); - - const processedPolicies = processPolicies(policies, false); - await controller.createSession(expiresAt, processedPolicies); - currentParams.resolve?.({ - code: ResponseCodes.SUCCESS, - address: controller.address(), - }); - if (currentParams.params.id) { - cleanupCallbacks(currentParams.params.id); - } - handleCompletion(); - } catch (e) { - console.error("Failed to create verified session:", e); - // Fall back to showing the UI if auto-creation fails - currentParams.reject?.(e); - } - return; -}; - export function useCreateController({ isSlot, signers, @@ -158,14 +74,19 @@ export function useCreateController({ const [authenticationStep, setAuthenticationStep] = useState(AuthenticationStep.FillForm); const [searchParams, setSearchParams] = useSearchParams(); - const { origin, rpcUrl, chainId, setController, policies, closeModal } = - useConnection(); + const { + origin, + rpcUrl, + chainId, + setController, + setAuthMethod: setContextAuthMethod, + setIsNewUser, + setShowSuccessScreen, + } = useConnection(); - // Import route params and completion for connection resolution const params = useMemo(() => { return parseConnectParams(searchParams); }, [searchParams]); - const handleCompletion = useRouteCompletion(); const { signup: signupWithWebauthn, login: loginWithWebauthn } = useWebauthnAuthentication(); @@ -318,43 +239,12 @@ export function useCreateController({ if (registerRet.register.username) { window.controller = controller; setController(controller); + setIsLoading(false); - // Handle session creation for auto-close cases (no policies or verified policies without token approvals) - const shouldAutoCreateSession = - !policies || (policies.verified && !hasApprovalPolicies(policies)); - - if (shouldAutoCreateSession) { - await createSession({ - controller, - policies, - params, - handleCompletion, - closeModal, - searchParams, - }); - } - - // Only redirect if we auto-created the session - // Otherwise, user needs to see consent screen or spending limit screen first - if (shouldAutoCreateSession) { - const urlSearchParams = new URLSearchParams(window.location.search); - const redirectUrl = urlSearchParams.get("redirect_url"); - if (redirectUrl) { - // Safely redirect to the specified URL with lastUsedConnector param - safeRedirect(redirectUrl, true); - } - } + setShowSuccessScreen(true); } }, - [ - setController, - origin, - policies, - handleCompletion, - params, - closeModal, - searchParams, - ], + [setController, origin, setIsLoading, setShowSuccessScreen], ); const handleSignup = useCallback( @@ -367,11 +257,16 @@ export function useCreateController({ throw new Error("Origin, chainId, or rpcUrl not found"); } + // Store auth info for ConnectRoute to show success screen + setContextAuthMethod(authenticationMode); + setIsNewUser(true); + let signupResponse: SignupResponse | undefined; let signer: SignerInput | undefined; switch (authenticationMode) { case "webauthn": await signupWithWebauthn(username, doPopupFlow); + setShowSuccessScreen(true); return; case "google": case "discord": @@ -456,6 +351,9 @@ export function useCreateController({ signupWithWalletConnect, passwordAuth, finishSignup, + setContextAuthMethod, + setIsNewUser, + setShowSuccessScreen, ], ); @@ -516,42 +414,11 @@ export function useCreateController({ window.controller = loginRet.controller; setController(loginRet.controller); + setIsLoading(false); - // Handle session creation for auto-close cases (no policies or verified policies without token approvals) - const shouldAutoCreateSession = - !policies || (policies.verified && !hasApprovalPolicies(policies)); - - if (shouldAutoCreateSession) { - await createSession({ - controller: loginRet.controller, - policies, - params, - handleCompletion, - closeModal, - searchParams, - }); - } - - // Only redirect if we auto-created the session - // Otherwise, user needs to see consent screen or spending limit screen first - if (shouldAutoCreateSession) { - const urlSearchParams = new URLSearchParams(window.location.search); - const redirectUrl = urlSearchParams.get("redirect_url"); - if (redirectUrl) { - // Safely redirect to the specified URL with lastUsedConnector param - safeRedirect(redirectUrl, true); - } - } + setShowSuccessScreen(true); }, - [ - origin, - setController, - policies, - handleCompletion, - params, - closeModal, - searchParams, - ], + [origin, setController, setIsLoading, setShowSuccessScreen], ); const handleLogin = useCallback( @@ -569,6 +436,10 @@ export function useCreateController({ throw new Error("Undefined controller"); } + // Store auth info for ConnectRoute to show success screen + setContextAuthMethod(authenticationMethod); + setIsNewUser(false); + let loginResponse: LoginResponse | undefined; switch (authenticationMethod) { case "webauthn": { @@ -594,6 +465,7 @@ export function useCreateController({ }, !!isSlot, ); + setShowSuccessScreen(true); return; } case "google": @@ -680,6 +552,9 @@ export function useCreateController({ finishLogin, passwordAuth, setWaitingForConfirmation, + setContextAuthMethod, + setIsNewUser, + setShowSuccessScreen, ], ); diff --git a/packages/keychain/src/components/provider/connection.tsx b/packages/keychain/src/components/provider/connection.tsx index 1e60a081d5..1e5917650e 100644 --- a/packages/keychain/src/components/provider/connection.tsx +++ b/packages/keychain/src/components/provider/connection.tsx @@ -2,6 +2,7 @@ import { ParentMethods } from "@/hooks/connection"; import { ParsedSessionPolicies } from "@/hooks/session"; import Controller from "@/utils/controller"; import { + AuthOption, ExternalWallet, ExternalWalletResponse, ExternalWalletType, @@ -65,6 +66,12 @@ export type ConnectionContextValue = { txHash: string, timeoutMs?: number, ) => Promise; + authMethod?: AuthOption; + setAuthMethod: (method: AuthOption) => void; + isNewUser: boolean; + setIsNewUser: (isNew: boolean) => void; + showSuccessScreen: boolean; + setShowSuccessScreen: (show: boolean) => void; }; export type VerifiableControllerTheme = ControllerTheme & { diff --git a/packages/keychain/src/hooks/connection.ts b/packages/keychain/src/hooks/connection.ts index 646879c545..959a09688a 100644 --- a/packages/keychain/src/hooks/connection.ts +++ b/packages/keychain/src/hooks/connection.ts @@ -9,6 +9,7 @@ import { connectToController } from "@/utils/connection"; import { TurnkeyWallet } from "@/wallets/social/turnkey"; import { WalletConnectWallet } from "@/wallets/wallet-connect"; import { + AuthOption, ExternalWallet, ExternalWalletResponse, ExternalWalletType, @@ -205,6 +206,11 @@ export function useConnectionValue() { const [onModalClose, setOnModalCloseInternal] = useState< (() => void) | undefined >(); + const [isNewUser, setIsNewUser] = useState(false); + const [authMethod, setAuthMethod] = useState( + undefined, + ); + const [showSuccessScreen, setShowSuccessScreen] = useState(false); const setOnModalClose = useCallback((fn: (() => void) | undefined) => { setOnModalCloseInternal(() => fn); @@ -779,6 +785,12 @@ export function useConnectionValue() { externalSendTransaction, externalGetBalance, externalWaitForTransaction, + authMethod, + setAuthMethod, + isNewUser, + setIsNewUser, + showSuccessScreen, + setShowSuccessScreen, }; } diff --git a/packages/keychain/src/test/mocks/connection.tsx b/packages/keychain/src/test/mocks/connection.tsx index 91222edf46..2785ec5bee 100644 --- a/packages/keychain/src/test/mocks/connection.tsx +++ b/packages/keychain/src/test/mocks/connection.tsx @@ -49,6 +49,12 @@ export const defaultMockConnection: ConnectionContextValue = { externalGetBalance: vi.fn(), externalWaitForTransaction: vi.fn(), controllerVersion: new SemVer("1.0.0"), + isNewUser: false, + setIsNewUser: vi.fn(), + showSuccessScreen: false, + setShowSuccessScreen: vi.fn(), + authMethod: undefined, + setAuthMethod: vi.fn(), }; export function createMockConnection(