diff --git a/.changeset/cursor-oauth-buttons.md b/.changeset/cursor-oauth-buttons.md new file mode 100644 index 00000000..a7e3d883 --- /dev/null +++ b/.changeset/cursor-oauth-buttons.md @@ -0,0 +1,5 @@ +--- +'@svforge/oauth': patch +'svforge': patch +--- +Give enabled OAuth provider buttons a pointer cursor and retain a not-allowed cursor while sign-in is pending. diff --git a/graphify-out/graph.json b/graphify-out/graph.json index 3440e164..a1a04161 100644 --- a/graphify-out/graph.json +++ b/graphify-out/graph.json @@ -16250,7 +16250,7 @@ }, { "id": "packages_svforge_templates_dashboard_src_lib_client_auth", - "label": "client/auth.ts", + "label": "src/lib/client/auth.ts", "file_type": "code", "source_file": "packages/svforge/templates/dashboard/src/lib/client/auth.ts", "source_location": "L1", @@ -24118,6 +24118,22 @@ "source_location": "L1", "_origin": "ast" }, + { + "id": "tests_oauth_buttons_test", + "label": "oauth-buttons.test.ts", + "file_type": "code", + "source_file": "tests/oauth-buttons.test.ts", + "source_location": "L1", + "_origin": "ast" + }, + { + "id": "tests_oauth_buttons_test_socialsignin", + "label": "{ socialSignIn }", + "file_type": "code", + "source_file": "tests/oauth-buttons.test.ts", + "source_location": "L5", + "_origin": "ast" + }, { "id": "tests_oauth_declarations_test", "label": "oauth-declarations.test.ts", @@ -24953,6 +24969,22 @@ "source_location": "L6", "_origin": "ast" }, + { + "id": "tests_stubs_lib_client_auth", + "label": "stubs/lib/client/auth.ts", + "file_type": "code", + "source_file": "tests/stubs/lib/client/auth.ts", + "source_location": "L1", + "_origin": "ast" + }, + { + "id": "tests_stubs_lib_client_auth_authclient", + "label": "authClient", + "file_type": "code", + "source_file": "tests/stubs/lib/client/auth.ts", + "source_location": "L1", + "_origin": "ast" + }, { "id": "tests_stubs_lib_components_svforge_primitives_index_js_tests_stubs_lib_components_svforge_primitives_index", "label": "lib/components/svforge/primitives/index.js", @@ -26396,6 +26428,15 @@ "source_location": "L1", "_origin": "ast" }, + { + "id": "changeset_cursor_oauth_buttons", + "label": "cursor-oauth-buttons.md", + "file_type": "document", + "node_kind": "page", + "source_file": ".changeset/cursor-oauth-buttons.md", + "source_location": "L1", + "_origin": "ast" + }, { "id": "changeset_plain_base_class_derivations", "label": "plain-base-class-derivations.md", @@ -81788,6 +81829,49 @@ "source_file": "tests/npm-consumer-smoke.test.ts", "_origin": "ast" }, + { + "source": "tests_oauth_buttons_test", + "target": "ref_vitest", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/oauth-buttons.test.ts", + "source_location": "L2", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_oauth_buttons_test", + "target": "ref_testing_library_svelte", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/oauth-buttons.test.ts", + "source_location": "L3", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_oauth_buttons_test", + "target": "tests_oauth_buttons_test_socialsignin", + "relation": "contains", + "confidence": "EXTRACTED", + "source_file": "tests/oauth-buttons.test.ts", + "source_location": "L5", + "weight": 1, + "_origin": "ast" + }, + { + "source": "tests_oauth_buttons_test", + "target": "packages_oauth_templates_src_lib_components_svforge_ui_oauthbuttons", + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": "tests/oauth-buttons.test.ts", + "source_location": "L8", + "weight": 1, + "_origin": "ast" + }, { "source": "tests_oauth_declarations_test", "target": "ref_vitest", @@ -84693,6 +84777,16 @@ "weight": 1, "_origin": "ast" }, + { + "source": "tests_stubs_lib_client_auth", + "target": "tests_stubs_lib_client_auth_authclient", + "relation": "contains", + "confidence": "EXTRACTED", + "source_file": "tests/stubs/lib/client/auth.ts", + "source_location": "L1", + "weight": 1, + "_origin": "ast" + }, { "source": "tests_stubs_lib_components_svforge_primitives_index_js_tests_stubs_lib_components_svforge_primitives_index", "target": "tests_stubs_lib_components_svforge_primitives_index_ts_tests_stubs_lib_components_svforge_primitives_index", diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index 25c0e808..e12892fd 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -356,11 +356,11 @@ "semantic_hash": "" }, "packages/oauth/src/templates.ts": { - "ast_hash": "b5e8f3f4f97840ec7d675f79c72128a3", + "ast_hash": "48fa5e2e35b61cda9c6ab1e257b375bc", "semantic_hash": "" }, "packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte": { - "ast_hash": "894977405199a0cfae8a47c3dd39a484", + "ast_hash": "b1cbc7d558697ad51d9033921bdd79dc", "semantic_hash": "" }, "packages/oauth/tsdown.config.ts": { @@ -468,7 +468,7 @@ "semantic_hash": "" }, "packages/svforge/src/module-recipes.ts": { - "ast_hash": "2ec6703fd3a12b827fae6f1e87678689", + "ast_hash": "540ac79110d5e3c5ef73bec5c547a070", "semantic_hash": "" }, "packages/svforge/src/recipe-version.ts": { @@ -2118,5 +2118,17 @@ ".changeset/clear-avatar-initial.md": { "ast_hash": "75e717999620fe7c44c536ab7aaf9e77", "semantic_hash": "" + }, + "tests/oauth-buttons.test.ts": { + "ast_hash": "083e1ee99d63564adbb1c1f36f91f190", + "semantic_hash": "" + }, + "tests/stubs/lib/client/auth.ts": { + "ast_hash": "ac05ca4c245974ed25e4e981658ed112", + "semantic_hash": "" + }, + ".changeset/cursor-oauth-buttons.md": { + "ast_hash": "9bf17e5f09ab95ae546e393c1fb7dec3", + "semantic_hash": "" } } diff --git a/packages/oauth/src/templates.ts b/packages/oauth/src/templates.ts index 743d4233..e11b161d 100644 --- a/packages/oauth/src/templates.ts +++ b/packages/oauth/src/templates.ts @@ -1,4 +1,4 @@ // AUTO-GENERATED - DO NOT EDIT export const files = { - "/lib/components/svforge/ui/OAuthButtons.svelte": "\n\n
\n signIn('google')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'google'}\n \n {:else}\n \n {/if}\n Google\n \n\n signIn('github')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n
\n" + "/lib/components/svforge/ui/OAuthButtons.svelte": "\n\n
\n signIn('google')}\n disabled={loading !== null}\n class:cursor-pointer={loading === null}\n class:cursor-not-allowed={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'google'}\n \n {:else}\n \n {/if}\n Google\n \n\n signIn('github')}\n disabled={loading !== null}\n class:cursor-pointer={loading === null}\n class:cursor-not-allowed={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n
\n" }; diff --git a/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte b/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte index a63ba293..c8513dc6 100644 --- a/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte +++ b/packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte @@ -3,6 +3,7 @@ let loading = $state<'google' | 'github' | null>(null); async function signIn(provider: 'google' | 'github') { + if (loading !== null) return; loading = provider; try { await authClient.signIn.social({ provider, callbackURL: '/admin' }); @@ -18,6 +19,8 @@ \n\n signIn('github')}\n disabled={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n\n" + "/lib/components/svforge/ui/OAuthButtons.svelte": "\n\n
\n signIn('google')}\n disabled={loading !== null}\n class:cursor-pointer={loading === null}\n class:cursor-not-allowed={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'google'}\n \n {:else}\n \n {/if}\n Google\n \n\n signIn('github')}\n disabled={loading !== null}\n class:cursor-pointer={loading === null}\n class:cursor-not-allowed={loading !== null}\n class=\"btn preset-filled-surface-400-600 w-full flex items-center justify-center gap-2\"\n >\n {#if loading === 'github'}\n \n {:else}\n \n {/if}\n GitHub\n \n
\n" } }, "realtime": { diff --git a/tests/oauth-buttons.test.ts b/tests/oauth-buttons.test.ts new file mode 100644 index 00000000..f87e7490 --- /dev/null +++ b/tests/oauth-buttons.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, waitFor } from '@testing-library/svelte'; + +const { socialSignIn } = vi.hoisted(() => ({ socialSignIn: vi.fn() })); +vi.mock('#lib/client/auth.js', () => ({ authClient: { signIn: { social: socialSignIn } } })); + +import OAuthButtons from '../packages/oauth/templates/src/lib/components/svforge/ui/OAuthButtons.svelte'; + +afterEach(cleanup); + +describe('OAuthButtons interaction state', () => { + it('marks enabled providers clickable and blocks competing sign-ins while loading', async () => { + socialSignIn.mockReset(); + let resolveSocialSignIn!: (value: unknown) => void; + socialSignIn.mockImplementation( + () => new Promise((resolve) => (resolveSocialSignIn = resolve)) + ); + const { getByRole } = render(OAuthButtons); + const google = getByRole('button', { name: 'Google' }) as HTMLButtonElement; + const github = getByRole('button', { name: 'GitHub' }) as HTMLButtonElement; + + expect(google.disabled).toBe(false); + expect(github.disabled).toBe(false); + expect(google.classList.contains('cursor-pointer')).toBe(true); + expect(github.classList.contains('cursor-pointer')).toBe(true); + + try { + await fireEvent.click(google); + + expect(socialSignIn).toHaveBeenCalledWith({ provider: 'google', callbackURL: '/admin' }); + expect(google.disabled).toBe(true); + expect(github.disabled).toBe(true); + expect(google.classList.contains('cursor-not-allowed')).toBe(true); + expect(github.classList.contains('cursor-not-allowed')).toBe(true); + expect(google.querySelector('.animate-spin')).not.toBeNull(); + + await fireEvent.click(github); + expect(socialSignIn).toHaveBeenCalledTimes(1); + } finally { + resolveSocialSignIn(undefined); + } + await waitFor(() => { + expect(google.disabled).toBe(false); + expect(github.disabled).toBe(false); + expect(google.classList.contains('cursor-pointer')).toBe(true); + expect(github.classList.contains('cursor-pointer')).toBe(true); + }); + }); + + it('restores enabled controls when provider sign-in rejects', async () => { + socialSignIn.mockReset(); + socialSignIn.mockRejectedValueOnce(new Error('provider unavailable')); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { getByRole } = render(OAuthButtons); + const google = getByRole('button', { name: 'Google' }) as HTMLButtonElement; + const github = getByRole('button', { name: 'GitHub' }) as HTMLButtonElement; + + try { + await fireEvent.click(github); + await waitFor(() => { + expect(google.disabled).toBe(false); + expect(github.disabled).toBe(false); + expect(github.classList.contains('cursor-pointer')).toBe(true); + }); + expect(consoleError).toHaveBeenCalledOnce(); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/tests/stubs/lib/client/auth.ts b/tests/stubs/lib/client/auth.ts new file mode 100644 index 00000000..421c3176 --- /dev/null +++ b/tests/stubs/lib/client/auth.ts @@ -0,0 +1 @@ +export const authClient = { signIn: { social: async () => {} } };