From 4a9534f73338192300d1485ccb9c300aba261e8f Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 11:01:23 +0100 Subject: [PATCH 1/3] fix(web): tighten token recovery and retry CTA gating --- frontend/src/__tests__/App.test.tsx | 31 +++++++++++++++++++++++- frontend/src/components/App.tsx | 35 +++++++++++++++++++++++++-- frontend/src/components/AppPanels.tsx | 4 +-- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 5d935469..3575ebda 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -276,7 +276,7 @@ describe('App', () => { expect(screen.getByText('Preparing feed')).toBeInTheDocument(); expect( - screen.getByText('Creating the feed and loading its preview before showing the result.') + screen.getByText('Creating the feed now. The result appears first, then preview loading continues.') ).toBeInTheDocument(); }); @@ -497,6 +497,35 @@ describe('App', () => { expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument(); }); + it('does not treat non-token forbidden failures as token rejection or strategy-recovery UX', async () => { + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: null, + }); + mockConvertFeed.mockRejectedValueOnce( + Object.assign(new Error('URL not allowed for this account'), { + manualRetryStrategy: 'browserless', + }) + ); + + render(); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await screen.findByText('URL not allowed for this account'); + expect(mockClearToken).not.toHaveBeenCalled(); + expect(screen.queryByText('Add access token')).not.toBeInTheDocument(); + expect(screen.queryByText('Access token was rejected. Paste a valid token to continue.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument(); + }); + it('shows the utility links in a user-focused order', () => { window.history.replaceState({}, '', 'http://localhost:3000/#result'); render(); diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 80e6a009..065228fa 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -102,11 +102,40 @@ export function App() { const isAccessTokenError = (message: string) => { const normalized = message.toLowerCase(); + const mentionsAuthToken = + normalized.includes('access token') || + normalized.includes('token') || + normalized.includes('authentication') || + normalized.includes('bearer'); + return ( + normalized.includes('unauthorized') || + normalized.includes('invalid token') || + normalized.includes('token rejected') || + normalized.includes('authentication') || + (normalized.includes('forbidden') && mentionsAuthToken) + ); + }; + + const isActionableStrategySwitch = ( + message: string, + currentStrategy: string, + retryStrategy: string + ) => { + if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; + + const normalized = message.toLowerCase(); + return !( normalized.includes('unauthorized') || normalized.includes('forbidden') || + normalized.includes('not allowed') || + normalized.includes('disabled') || normalized.includes('access token') || - normalized.includes('authentication') + normalized.includes('token') || + normalized.includes('authentication') || + normalized.includes('bad request') || + normalized.includes('url') || + normalized.includes('unsupported strategy') ); }; @@ -150,7 +179,9 @@ export function App() { } catch (submitError) { const message = submitError instanceof Error ? submitError.message : 'Unable to start feed generation.'; const retryStrategy = (submitError as ConversionErrorWithMeta).manualRetryStrategy ?? ''; - setManualRetryStrategy(retryStrategy); + setManualRetryStrategy( + isActionableStrategySwitch(message, strategy, retryStrategy) ? retryStrategy : '' + ); if (feedCreation.access_token_required && isAccessTokenError(message)) { clearToken(); diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 08e1139c..eb2a3318 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -198,7 +198,7 @@ export function CreateFeedPanel({
Preparing feed
-

Creating the feed and loading its preview before showing the result.

+

Creating the feed now. The result appears first, then preview loading continues.

)} From 92116ba92b48b71f5183655637fa7b45446ecee4 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 11:33:51 +0100 Subject: [PATCH 2/3] Update frontend/src/components/AppPanels.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/src/components/AppPanels.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index eb2a3318..dc8be1ea 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -198,7 +198,7 @@ export function CreateFeedPanel({ Date: Sat, 28 Mar 2026 11:36:13 +0100 Subject: [PATCH 3/3] Fix frontend formatting for ready checks --- frontend/src/__tests__/App.test.tsx | 4 +++- frontend/src/components/App.tsx | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 3575ebda..3383146a 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -522,7 +522,9 @@ describe('App', () => { await screen.findByText('URL not allowed for this account'); expect(mockClearToken).not.toHaveBeenCalled(); expect(screen.queryByText('Add access token')).not.toBeInTheDocument(); - expect(screen.queryByText('Access token was rejected. Paste a valid token to continue.')).not.toBeInTheDocument(); + expect( + screen.queryByText('Access token was rejected. Paste a valid token to continue.') + ).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument(); }); diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 065228fa..981da692 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -117,11 +117,7 @@ export function App() { ); }; - const isActionableStrategySwitch = ( - message: string, - currentStrategy: string, - retryStrategy: string - ) => { + const isActionableStrategySwitch = (message: string, currentStrategy: string, retryStrategy: string) => { if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; const normalized = message.toLowerCase();