From 4fafa855e87cf4effc3dee9366e04b9fe04d618d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:41:29 -0700 Subject: [PATCH 1/4] chore: bump @clickhouse/click-ui to 0.9.1 Inline @clickhouse/click-ui in vitest server deps and exclude .claude/** from test discovery. click-ui 0.9.1 ships ESM that imports .css files, which Node's loader cannot parse, so Vitest must process the package through Vite. --- bun.lock | 22 +++++----------------- package.json | 2 +- vitest.config.ts | 7 ++++++- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 08d047a6..ec864745 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "tanstack-start-app", "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", @@ -160,7 +160,7 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@clickhouse/click-ui": ["@clickhouse/click-ui@0.2.0-rc.4", "", { "dependencies": { "@h6s/calendar": "2.0.1", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-WcPjvS7W/InvHP/JVeMIHA/30jl3Ks6CN+zch7kfqogjcbTc4byOzSUSkOJlYblzLxfdins0z0oeBF+jVsBMDA=="], + "@clickhouse/click-ui": ["@clickhouse/click-ui@0.9.1", "", { "dependencies": { "@h6s/calendar": "2.2.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-gAyIlzIDYqnXcmHM2rOWNpGAoC0UAeacT5yJpDgu09I+TjmuOdzh9tYaJlS938SmUyLsg2U6t8oMA7do108Hgg=="], "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], @@ -188,8 +188,6 @@ "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], - "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -270,7 +268,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@h6s/calendar": ["@h6s/calendar@2.0.1", "", { "peerDependencies": { "date-fns": ">= 2", "react": ">= 18" } }, "sha512-9q5ksdnUsLDeuSm5arXzkxHyS22u7yi5TtVr4DZgW514AjdL+76kx7d+ScX9sKusasRQVxrhM2LTVmd8A/u42Q=="], + "@h6s/calendar": ["@h6s/calendar@2.2.0", "", { "peerDependencies": { "react": ">= 18" } }, "sha512-o1fb4RlMWDRIOiMEX65vN5qDTiD/OyC++EkJzmoz9l1vUOkN89gTE+wNE83XZBi3hbYqMV3CNhgKf51n/0hcBw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -644,8 +642,6 @@ "@types/sortablejs": ["@types/sortablejs@1.15.9", "", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="], - "@types/stylis": ["@types/stylis@4.2.7", "", {}, "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA=="], - "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -824,6 +820,8 @@ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.3.1", "", {}, "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -1454,8 +1452,6 @@ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1654,10 +1650,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@clickhouse/click-ui/dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - - "@clickhouse/click-ui/styled-components": ["styled-components@6.3.11", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", "@types/stylis": "4.2.7", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.6", "tslib": "2.8.1" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["react-dom"] }, "sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], @@ -2020,8 +2012,6 @@ "xmlbuilder2/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@clickhouse/click-ui/styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -2136,8 +2126,6 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@clickhouse/click-ui/styled-components/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@humanwhocodes/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 78d3e020..f46add31 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx,css,json}'" }, "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", diff --git a/vitest.config.ts b/vitest.config.ts index c7e8e233..25a36ac3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,11 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - exclude: ['e2e/**', 'node_modules/**', 'tools/**'], + exclude: ['e2e/**', 'node_modules/**', 'tools/**', '.claude/**'], + server: { + deps: { + inline: ['@clickhouse/click-ui'], + }, + }, }, }) From f7d882db483b5c5d4b7aa6ed465633223bf43a97 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:23:58 -0700 Subject: [PATCH 2/4] fix: restore login card width and show SSO loading state click-ui 0.9.1 ships unlayered CSS while Tailwind utilities live in @layer utilities, so Panel's new width: var(--panel-width, auto) rule beat our w-full class and the login card collapsed to content width. Both Panels now use the fillWidth prop, which sets an inline --panel-width: 100% that wins the cascade, and the dead w-full class is replaced with min-w-70 so the card can never collapse below 17.5rem again even if the component CSS changes. The SSO button never showed a loading indicator because handleSsoLogin reset ssoLoading in a finally block that ran immediately after the window.location.href assignment, which does not block, so the state reverted before the redirect painted. The success path now returns with ssoLoading still true and only error paths reset it, and the button uses click-ui's loading prop for the spinner, disabled state, and aria-busy. The same finally reset is removed from the auto-redirect effect. Adds AuthCard tests covering the pending, redirecting, and error states of the SSO button plus a regression guard asserting both Panels carry the fillWidth inline style and min width class. --- src/components/AuthCard.test.tsx | 136 +++++++++++++++++++++++++++++++ src/components/AuthCard.tsx | 21 ++--- 2 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 src/components/AuthCard.test.tsx diff --git a/src/components/AuthCard.test.tsx b/src/components/AuthCard.test.tsx new file mode 100644 index 00000000..a1fce853 --- /dev/null +++ b/src/components/AuthCard.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type * as t from '@/types'; +import { openidLoginFn } from '@/server'; +import { AuthCard } from './AuthCard'; + +vi.mock('@/server', () => ({ + adminLoginFn: vi.fn(), + adminVerify2FAFn: vi.fn(), + openidLoginFn: vi.fn(), + openIdCheckOptions: { queryKey: ['openIdCheck'], queryFn: vi.fn() }, +})); + +vi.mock('@tanstack/react-router', () => ({ + useRouter: () => ({ invalidate: vi.fn(), navigate: vi.fn() }), +})); + +vi.mock('@/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +type SsoLoginResult = Awaited>; + +const openidLoginFnMock = vi.mocked(openidLoginFn); + +function renderAuthCard(props: Partial = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +function getSsoButton() { + return screen.getByRole('button', { name: 'com_auth_sso_sign_in' }); +} + +function getSsoRedirectingButton() { + return screen.getByRole('button', { name: 'com_auth_sso_redirecting' }); +} + +describe('AuthCard SSO login', () => { + const locationStub = { href: 'http://localhost:3000/' }; + + beforeEach(() => { + locationStub.href = 'http://localhost:3000/'; + vi.stubGlobal('location', locationStub); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('shows the loading state while the SSO login request is pending', async () => { + openidLoginFnMock.mockImplementation(() => new Promise(() => {})); + renderAuthCard(); + + fireEvent.click(getSsoButton()); + + await waitFor(() => { + const button = getSsoRedirectingButton(); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toBeDisabled(); + }); + }); + + it('keeps the button loading after resolving and navigates to the auth URL', async () => { + const authUrl = 'https://idp.example.com/authorize'; + openidLoginFnMock.mockResolvedValue({ error: false, authUrl }); + renderAuthCard(); + + fireEvent.click(getSsoButton()); + + await waitFor(() => expect(locationStub.href).toBe(authUrl)); + const button = getSsoRedirectingButton(); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toBeDisabled(); + }); + + it('returns the button to non-loading when the request resolves with an error', async () => { + openidLoginFnMock.mockResolvedValue({ error: true, message: 'Failed to initiate SSO login' }); + renderAuthCard(); + + fireEvent.click(getSsoButton()); + + await waitFor(() => + expect(screen.getByText('Failed to initiate SSO login')).toBeInTheDocument(), + ); + const button = getSsoButton(); + expect(button).not.toHaveAttribute('aria-busy'); + expect(button).toBeEnabled(); + expect(locationStub.href).toBe('http://localhost:3000/'); + }); + + it('returns the button to non-loading when the request rejects', async () => { + openidLoginFnMock.mockRejectedValue(new Error('network down')); + renderAuthCard(); + + fireEvent.click(getSsoButton()); + + await waitFor(() => expect(screen.getByText('com_auth_unable_connect')).toBeInTheDocument()); + const button = getSsoButton(); + expect(button).not.toHaveAttribute('aria-busy'); + expect(button).toBeEnabled(); + }); +}); + +describe('AuthCard panel width', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the login panel with fillWidth and a min width', () => { + const { container } = renderAuthCard(); + + const panel = container.querySelector('.auth-card'); + expect(panel).not.toBeNull(); + expect(panel?.style.getPropertyValue('--panel-width')).toBe('100%'); + expect(panel?.classList.contains('min-w-70')).toBe(true); + expect(panel?.classList.contains('max-w-md')).toBe(true); + }); + + it('renders the auto-redirect panel with fillWidth and a min width', () => { + openidLoginFnMock.mockImplementation(() => new Promise(() => {})); + const { container } = renderAuthCard({ autoRedirectSso: true }); + + const panel = container.querySelector('.auth-card'); + expect(panel).not.toBeNull(); + expect(panel?.style.getPropertyValue('--panel-width')).toBe('100%'); + expect(panel?.classList.contains('min-w-70')).toBe(true); + expect(panel?.classList.contains('max-w-md')).toBe(true); + }); +}); diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx index 756dd65c..f15b4d4d 100644 --- a/src/components/AuthCard.tsx +++ b/src/components/AuthCard.tsx @@ -60,6 +60,7 @@ export function AuthCard({ if (result.error || !result.authUrl) { setAutoRedirectFailed(true); setGeneralError(result.message || localize('com_auth_sso_redirect_failed')); + setSsoLoading(false); return; } const authUrl = new URL(result.authUrl); @@ -71,8 +72,8 @@ export function AuthCard({ .catch(() => { setAutoRedirectFailed(true); setGeneralError(localize('com_auth_sso_redirect_failed')); - }) - .finally(() => setSsoLoading(false)); + setSsoLoading(false); + }); }, [autoRedirectSso, localize, redirectTo]); const emailSchema = useMemo( @@ -196,16 +197,14 @@ export function AuthCard({ setSsoLoading(true); try { const result = await openidLoginFn(); - if (result.error) { + if (result.error || !result.authUrl) { setGeneralError(result.message || localize('com_auth_login_failed')); + setSsoLoading(false); return; } - if (result.authUrl) { - window.location.href = result.authUrl; - } + window.location.href = result.authUrl; } catch { setGeneralError(localize('com_auth_unable_connect')); - } finally { setSsoLoading(false); } }; @@ -213,7 +212,8 @@ export function AuthCard({ if (showAutoRedirect) { return ( )} From 3b1a2a26c73c321c988135b730f2fc545564b2e8 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:27:04 -0700 Subject: [PATCH 3/4] fix: reset sso loading state when restored from the back-forward cache The successful SSO path intentionally leaves ssoLoading true while the browser navigates to the identity provider, but browsers that restore the login page from the back-forward cache (for example after cancelling at the IdP via the Back button) also restore that React state, leaving the button stuck loading and disabled until a manual reload. A pageshow listener now clears the loading flag when the event reports a persisted page, keeping the loading state intact during the outbound navigation while recovering it on bfcache restores. --- src/components/AuthCard.test.tsx | 17 +++++++++++++++++ src/components/AuthCard.tsx | 10 ++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/components/AuthCard.test.tsx b/src/components/AuthCard.test.tsx index a1fce853..bc5cf029 100644 --- a/src/components/AuthCard.test.tsx +++ b/src/components/AuthCard.test.tsx @@ -80,6 +80,23 @@ describe('AuthCard SSO login', () => { expect(button).toBeDisabled(); }); + it('resets the loading state when the page is restored from the back-forward cache', async () => { + const authUrl = 'https://idp.example.com/authorize'; + openidLoginFnMock.mockResolvedValue({ error: false, authUrl }); + renderAuthCard(); + + fireEvent.click(getSsoButton()); + await waitFor(() => expect(locationStub.href).toBe(authUrl)); + + fireEvent(window, Object.assign(new Event('pageshow'), { persisted: false })); + expect(getSsoRedirectingButton()).toBeDisabled(); + + fireEvent(window, Object.assign(new Event('pageshow'), { persisted: true })); + const button = getSsoButton(); + expect(button).not.toHaveAttribute('aria-busy'); + expect(button).toBeEnabled(); + }); + it('returns the button to non-loading when the request resolves with an error', async () => { openidLoginFnMock.mockResolvedValue({ error: true, message: 'Failed to initiate SSO login' }); renderAuthCard(); diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx index f15b4d4d..f3712f74 100644 --- a/src/components/AuthCard.tsx +++ b/src/components/AuthCard.tsx @@ -76,6 +76,16 @@ export function AuthCard({ }); }, [autoRedirectSso, localize, redirectTo]); + useEffect(() => { + // A bfcache restore (browser Back from the IdP) revives the pre-navigation + // React state, so reset the loading flag left true by the outbound redirect. + const handlePageShow = (event: PageTransitionEvent) => { + if (event.persisted) setSsoLoading(false); + }; + window.addEventListener('pageshow', handlePageShow); + return () => window.removeEventListener('pageshow', handlePageShow); + }, []); + const emailSchema = useMemo( () => z.string().email(localize('com_auth_email_invalid')), [localize], From 567ddb0dd846c00c7c52fb7318cc21476ddf4d9f Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:28:52 -0700 Subject: [PATCH 4/4] test: move the auth card test under __tests__ Frontend component tests belong in an adjacent __tests__ directory per the repo convention in AGENTS.md, and this file was added directly under src/components in this branch. --- src/components/{ => __tests__}/AuthCard.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/components/{ => __tests__}/AuthCard.test.tsx (99%) diff --git a/src/components/AuthCard.test.tsx b/src/components/__tests__/AuthCard.test.tsx similarity index 99% rename from src/components/AuthCard.test.tsx rename to src/components/__tests__/AuthCard.test.tsx index bc5cf029..e017cd14 100644 --- a/src/components/AuthCard.test.tsx +++ b/src/components/__tests__/AuthCard.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type * as t from '@/types'; import { openidLoginFn } from '@/server'; -import { AuthCard } from './AuthCard'; +import { AuthCard } from '../AuthCard'; vi.mock('@/server', () => ({ adminLoginFn: vi.fn(),