diff --git a/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx b/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx index 7d4905420..60100e0f1 100644 --- a/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx +++ b/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx @@ -10,6 +10,7 @@ interface ChooseSignupMethodProps { isLoading: boolean; validation: ReturnType; onSubmit: (authenticationMode?: AuthOption, password?: string) => void; + onStorageAccessRequest: () => Promise; authOptions: AuthOption[]; isOpen?: boolean; } @@ -18,6 +19,7 @@ export function ChooseSignupMethodForm({ isLoading, validation, onSubmit, + onStorageAccessRequest, authOptions, isOpen = true, }: ChooseSignupMethodProps) { @@ -123,7 +125,10 @@ export function ChooseSignupMethodForm({ setSelectedAuth(option); } else { setSelectedAuth(option); - onSubmit(option); + void (async () => { + await onStorageAccessRequest(); + onSubmit(option); + })(); } } break; @@ -134,7 +139,15 @@ export function ChooseSignupMethodForm({ break; } }, - [isOpen, showPasswordInput, isLoading, options, highlightedIndex, onSubmit], + [ + isOpen, + showPasswordInput, + isLoading, + options, + highlightedIndex, + onSubmit, + onStorageAccessRequest, + ], ); useEffect(() => { @@ -168,12 +181,18 @@ export function ChooseSignupMethodForm({ setSelectedAuth(option); } else { setSelectedAuth(option); - onSubmit(option); + void (async () => { + await onStorageAccessRequest(); + onSubmit(option); + })(); } }; const handlePasswordSubmit = (password: string) => { - onSubmit("password", password); + void (async () => { + await onStorageAccessRequest(); + onSubmit("password", password); + })(); }; const handlePasswordCancel = () => { diff --git a/packages/keychain/src/components/connect/create/CreateController.stories.tsx b/packages/keychain/src/components/connect/create/CreateController.stories.tsx index 29b295c4f..091a9aae7 100644 --- a/packages/keychain/src/components/connect/create/CreateController.stories.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.stories.tsx @@ -32,6 +32,7 @@ export const Default: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -59,6 +60,7 @@ export const WithLightMode: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -85,6 +87,7 @@ export const WithTheme: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -110,6 +113,7 @@ export const WithTimeoutError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -134,6 +138,7 @@ export const WithValidationError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -159,6 +164,7 @@ export const WithGenericError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, diff --git a/packages/keychain/src/components/connect/create/CreateController.test.tsx b/packages/keychain/src/components/connect/create/CreateController.test.tsx index ec80bfa25..625a78a9c 100644 --- a/packages/keychain/src/components/connect/create/CreateController.test.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.test.tsx @@ -10,6 +10,8 @@ const mockUseCreateController = vi.fn(); const mockUseUsernameValidation = vi.fn(); const mockUseControllerTheme = vi.fn(); const mockUseWallets = vi.fn().mockReturnValue({ wallets: [] }); +const mockRequestStorageAccess = vi.fn().mockResolvedValue(true); +const mockIsIframe = vi.fn(); // Mock the ResizeObserver const ResizeObserverMock = vi.fn(() => ({ @@ -76,6 +78,18 @@ vi.mock("./useCreateController", () => ({ vi.mock("@/hooks/debounce", () => ({ useDebounce: (value: T) => ({ debouncedValue: value }), })); +vi.mock("@/utils/connection/storage-access", () => ({ + requestStorageAccess: () => mockRequestStorageAccess(), +})); +vi.mock("@cartridge/ui/utils", async () => { + const actual = await vi.importActual( + "@cartridge/ui/utils", + ); + return { + ...actual, + isIframe: () => mockIsIframe(), + }; +}); describe("CreateController", () => { const defaultProps = { isSlot: false, @@ -83,6 +97,7 @@ describe("CreateController", () => { }; beforeEach(() => { vi.clearAllMocks(); + mockIsIframe.mockReturnValue(false); // Set default mock returns mockUseCreateController.mockReturnValue({ isLoading: false, @@ -185,6 +200,51 @@ describe("CreateController", () => { }); }); + it("requests storage access on submit in iframe", async () => { + mockIsIframe.mockReturnValue(true); + const handleSubmit = vi.fn().mockResolvedValue(undefined); + const setAuthenticationStep = vi.fn(); + mockUseCreateController.mockReturnValue({ + isLoading: false, + error: undefined, + setError: vi.fn(), + handleSubmit, + authenticationStep: AuthenticationStep.FillForm, + setAuthenticationStep, + waitingForConfirmation: false, + changeWallet: false, + setChangeWallet: vi.fn(), + overlay: null, + setOverlay: vi.fn(), + signupOptions: ["webauthn"], + authMethod: undefined, + setAuthMethod: vi.fn(), + }); + renderComponent(); + const input = screen.getByPlaceholderText("Username"); + fireEvent.change(input, { target: { value: "validuser" } }); + + // Ensure dropdown is closed by blurring input + fireEvent.blur(input); + + // Wait for validation to be applied + await waitFor(() => { + const submitButton = screen.getByTestId("submit-button"); + expect(submitButton).not.toBeDisabled(); + }); + + // Submit form + const submitButton = screen.getByTestId("submit-button"); + const form = submitButton.closest("form"); + if (form) { + fireEvent.submit(form); + } + + await waitFor(() => { + expect(mockRequestStorageAccess).toHaveBeenCalled(); + }); + }); + it("shows loading state during submission", async () => { mockUseCreateController.mockReturnValue({ isLoading: true, diff --git a/packages/keychain/src/components/connect/create/CreateController.tsx b/packages/keychain/src/components/connect/create/CreateController.tsx index dfa03c97d..9e01d28d3 100644 --- a/packages/keychain/src/components/connect/create/CreateController.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.tsx @@ -5,6 +5,7 @@ import { usePostHog } from "@/components/provider/posthog"; import { useControllerTheme } from "@/hooks/connection"; import { useDebounce } from "@/hooks/debounce"; import { allUseSameAuth } from "@/utils/controller"; +import { requestStorageAccess } from "@/utils/connection/storage-access"; import { AuthOption, AuthOptions } from "@cartridge/controller"; import { CartridgeLogo, @@ -31,6 +32,7 @@ import { } from "@/hooks/viewport"; import { useDevice } from "@/hooks/device"; import { AccountSearchResult } from "@/hooks/account"; +import { isIframe } from "@cartridge/ui/utils"; interface CreateControllerViewProps { theme: VerifiableControllerTheme; @@ -45,6 +47,7 @@ interface CreateControllerViewProps { onUsernameFocus: () => void; onUsernameClear: () => void; onSubmit: (authenticationMode?: AuthOption) => void; + onStorageAccessRequest: () => Promise; onKeyDown: (e: React.KeyboardEvent) => void; isSlot?: boolean; authenticationStep: AuthenticationStep; @@ -81,6 +84,7 @@ function CreateControllerForm({ onUsernameChange, onUsernameFocus, onUsernameClear, + onStorageAccessRequest, onKeyDown, onSubmit, waitingForConfirmation, @@ -195,13 +199,15 @@ function CreateControllerForm({ height: layoutHeight, }} ref={layoutRef} - onSubmit={(e) => { + onSubmit={async (e) => { e.preventDefault(); // Don't submit if dropdown is open if (isDropdownOpen) { return; } + await onStorageAccessRequest(); + if (keyboardIsOpen) { // If keyboard is open, mark for pending submit after it closes setPendingSubmitAfterKeyboardClose(true); @@ -295,6 +301,7 @@ export function CreateControllerView({ onUsernameFocus, onUsernameClear, onSubmit, + onStorageAccessRequest, onKeyDown, authenticationStep, setAuthenticationStep, @@ -342,6 +349,7 @@ export function CreateControllerView({ onUsernameChange={onUsernameChange} onUsernameFocus={onUsernameFocus} onUsernameClear={onUsernameClear} + onStorageAccessRequest={onStorageAccessRequest} onSubmit={onSubmit} onKeyDown={onKeyDown} waitingForConfirmation={waitingForConfirmation} @@ -359,6 +367,7 @@ export function CreateControllerView({ isLoading={isLoading} validation={validation} onSubmit={onSubmit} + onStorageAccessRequest={onStorageAccessRequest} authOptions={authOptions} isOpen={authenticationStep === AuthenticationStep.ChooseMethod} /> @@ -495,6 +504,21 @@ export function CreateController({ ], ); + const handleStorageAccessRequest = useCallback(async () => { + if (!isIframe()) { + return; + } + + try { + await requestStorageAccess(); + } catch (error) { + console.error( + "[CreateController] Storage access request failed:", + error, + ); + } + }, []); + useEffect(() => { if ( pendingSubmitRef.current && @@ -587,8 +611,6 @@ export function CreateController({ canSubmit, authenticationStep, isDropdownOpen, - setAuthMethod, - handleFormSubmit, ]); // Reset authMethod and pendingSubmit when sheet is closed @@ -612,6 +634,7 @@ export function CreateController({ onUsernameFocus={handleUsernameFocus} onUsernameClear={handleUsernameClear} onSubmit={handleFormSubmit} + onStorageAccessRequest={handleStorageAccessRequest} onKeyDown={handleKeyDown} authenticationStep={authenticationStep} setAuthenticationStep={setAuthenticationStep}