From a93bc4153d49fa2567d54dea3c1af4a70bebb8dc Mon Sep 17 00:00:00 2001 From: Nasr Date: Thu, 5 Feb 2026 14:00:11 -0600 Subject: [PATCH 1/2] feat(keychain): request storage access on auth --- .../connect/create/ChooseSignupMethodForm.tsx | 15 ++++- .../create/CreateController.stories.tsx | 6 ++ .../connect/create/CreateController.test.tsx | 60 +++++++++++++++++++ .../connect/create/CreateController.tsx | 28 +++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx b/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx index 7d4905420b..7e7ac42f5f 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: () => void; authOptions: AuthOption[]; isOpen?: boolean; } @@ -18,6 +19,7 @@ export function ChooseSignupMethodForm({ isLoading, validation, onSubmit, + onStorageAccessRequest, authOptions, isOpen = true, }: ChooseSignupMethodProps) { @@ -123,6 +125,7 @@ export function ChooseSignupMethodForm({ setSelectedAuth(option); } else { setSelectedAuth(option); + onStorageAccessRequest(); onSubmit(option); } } @@ -134,7 +137,15 @@ export function ChooseSignupMethodForm({ break; } }, - [isOpen, showPasswordInput, isLoading, options, highlightedIndex, onSubmit], + [ + isOpen, + showPasswordInput, + isLoading, + options, + highlightedIndex, + onSubmit, + onStorageAccessRequest, + ], ); useEffect(() => { @@ -168,11 +179,13 @@ export function ChooseSignupMethodForm({ setSelectedAuth(option); } else { setSelectedAuth(option); + onStorageAccessRequest(); onSubmit(option); } }; const handlePasswordSubmit = (password: string) => { + onStorageAccessRequest(); onSubmit("password", password); }; diff --git a/packages/keychain/src/components/connect/create/CreateController.stories.tsx b/packages/keychain/src/components/connect/create/CreateController.stories.tsx index 29b295c4ff..a31f02897f 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: () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -59,6 +60,7 @@ export const WithLightMode: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -85,6 +87,7 @@ export const WithTheme: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -110,6 +113,7 @@ export const WithTimeoutError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -134,6 +138,7 @@ export const WithValidationError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -159,6 +164,7 @@ export const WithGenericError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, + onStorageAccessRequest: () => {}, 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 ec80bfa25a..625a78a9c3 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 dfa03c97d2..6d3fe53496 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: () => void; onKeyDown: (e: React.KeyboardEvent) => void; isSlot?: boolean; authenticationStep: AuthenticationStep; @@ -81,6 +84,7 @@ function CreateControllerForm({ onUsernameChange, onUsernameFocus, onUsernameClear, + onStorageAccessRequest, onKeyDown, onSubmit, waitingForConfirmation, @@ -197,6 +201,7 @@ function CreateControllerForm({ ref={layoutRef} onSubmit={(e) => { e.preventDefault(); + onStorageAccessRequest(); // Don't submit if dropdown is open if (isDropdownOpen) { return; @@ -295,6 +300,7 @@ export function CreateControllerView({ onUsernameFocus, onUsernameClear, onSubmit, + onStorageAccessRequest, onKeyDown, authenticationStep, setAuthenticationStep, @@ -342,6 +348,7 @@ export function CreateControllerView({ onUsernameChange={onUsernameChange} onUsernameFocus={onUsernameFocus} onUsernameClear={onUsernameClear} + onStorageAccessRequest={onStorageAccessRequest} onSubmit={onSubmit} onKeyDown={onKeyDown} waitingForConfirmation={waitingForConfirmation} @@ -359,6 +366,7 @@ export function CreateControllerView({ isLoading={isLoading} validation={validation} onSubmit={onSubmit} + onStorageAccessRequest={onStorageAccessRequest} authOptions={authOptions} isOpen={authenticationStep === AuthenticationStep.ChooseMethod} /> @@ -495,6 +503,23 @@ export function CreateController({ ], ); + const handleStorageAccessRequest = useCallback(() => { + if (!isIframe()) { + return; + } + + void (async () => { + try { + await requestStorageAccess(); + } catch (error) { + console.error( + "[CreateController] Storage access request failed:", + error, + ); + } + })(); + }, []); + useEffect(() => { if ( pendingSubmitRef.current && @@ -577,6 +602,7 @@ export function CreateController({ if ((e.key === "Enter" || e.key === " ") && canSubmit) { e.preventDefault(); + handleStorageAccessRequest(); submitButtonRef.current?.click(); } }; @@ -589,6 +615,7 @@ export function CreateController({ isDropdownOpen, setAuthMethod, handleFormSubmit, + handleStorageAccessRequest, ]); // Reset authMethod and pendingSubmit when sheet is closed @@ -612,6 +639,7 @@ export function CreateController({ onUsernameFocus={handleUsernameFocus} onUsernameClear={handleUsernameClear} onSubmit={handleFormSubmit} + onStorageAccessRequest={handleStorageAccessRequest} onKeyDown={handleKeyDown} authenticationStep={authenticationStep} setAuthenticationStep={setAuthenticationStep} From 068bd7c3f42fcb7b9786944ca6e286cb8d63ce96 Mon Sep 17 00:00:00 2001 From: Nasr Date: Thu, 5 Feb 2026 14:34:50 -0600 Subject: [PATCH 2/2] fix(keychain): await storage access before auth --- .../connect/create/ChooseSignupMethodForm.tsx | 20 +++++++----- .../create/CreateController.stories.tsx | 12 +++---- .../connect/create/CreateController.tsx | 31 ++++++++----------- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx b/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx index 7e7ac42f5f..60100e0f18 100644 --- a/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx +++ b/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx @@ -10,7 +10,7 @@ interface ChooseSignupMethodProps { isLoading: boolean; validation: ReturnType; onSubmit: (authenticationMode?: AuthOption, password?: string) => void; - onStorageAccessRequest: () => void; + onStorageAccessRequest: () => Promise; authOptions: AuthOption[]; isOpen?: boolean; } @@ -125,8 +125,10 @@ export function ChooseSignupMethodForm({ setSelectedAuth(option); } else { setSelectedAuth(option); - onStorageAccessRequest(); - onSubmit(option); + void (async () => { + await onStorageAccessRequest(); + onSubmit(option); + })(); } } break; @@ -179,14 +181,18 @@ export function ChooseSignupMethodForm({ setSelectedAuth(option); } else { setSelectedAuth(option); - onStorageAccessRequest(); - onSubmit(option); + void (async () => { + await onStorageAccessRequest(); + onSubmit(option); + })(); } }; const handlePasswordSubmit = (password: string) => { - onStorageAccessRequest(); - 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 a31f02897f..091a9aae77 100644 --- a/packages/keychain/src/components/connect/create/CreateController.stories.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, - onStorageAccessRequest: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -60,7 +60,7 @@ export const WithLightMode: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, - onStorageAccessRequest: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -87,7 +87,7 @@ export const WithTheme: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, - onStorageAccessRequest: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -113,7 +113,7 @@ export const WithTimeoutError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, - onStorageAccessRequest: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -138,7 +138,7 @@ export const WithValidationError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, - onStorageAccessRequest: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, @@ -164,7 +164,7 @@ export const WithGenericError: Story = { onUsernameChange: () => {}, onUsernameFocus: () => {}, onUsernameClear: () => {}, - onStorageAccessRequest: () => {}, + onStorageAccessRequest: async () => {}, setChangeWallet: () => {}, onSubmit: () => {}, }, diff --git a/packages/keychain/src/components/connect/create/CreateController.tsx b/packages/keychain/src/components/connect/create/CreateController.tsx index 6d3fe53496..9e01d28d3e 100644 --- a/packages/keychain/src/components/connect/create/CreateController.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.tsx @@ -47,7 +47,7 @@ interface CreateControllerViewProps { onUsernameFocus: () => void; onUsernameClear: () => void; onSubmit: (authenticationMode?: AuthOption) => void; - onStorageAccessRequest: () => void; + onStorageAccessRequest: () => Promise; onKeyDown: (e: React.KeyboardEvent) => void; isSlot?: boolean; authenticationStep: AuthenticationStep; @@ -199,14 +199,15 @@ function CreateControllerForm({ height: layoutHeight, }} ref={layoutRef} - onSubmit={(e) => { + onSubmit={async (e) => { e.preventDefault(); - onStorageAccessRequest(); // 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); @@ -503,21 +504,19 @@ export function CreateController({ ], ); - const handleStorageAccessRequest = useCallback(() => { + const handleStorageAccessRequest = useCallback(async () => { if (!isIframe()) { return; } - void (async () => { - try { - await requestStorageAccess(); - } catch (error) { - console.error( - "[CreateController] Storage access request failed:", - error, - ); - } - })(); + try { + await requestStorageAccess(); + } catch (error) { + console.error( + "[CreateController] Storage access request failed:", + error, + ); + } }, []); useEffect(() => { @@ -602,7 +601,6 @@ export function CreateController({ if ((e.key === "Enter" || e.key === " ") && canSubmit) { e.preventDefault(); - handleStorageAccessRequest(); submitButtonRef.current?.click(); } }; @@ -613,9 +611,6 @@ export function CreateController({ canSubmit, authenticationStep, isDropdownOpen, - setAuthMethod, - handleFormSubmit, - handleStorageAccessRequest, ]); // Reset authMethod and pendingSubmit when sheet is closed