diff --git a/.changeset/fix-anthropic-login-banner-mobile.md b/.changeset/fix-anthropic-login-banner-mobile.md new file mode 100644 index 0000000000..d342f64cb7 --- /dev/null +++ b/.changeset/fix-anthropic-login-banner-mobile.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep the Anthropic OAuth login error inside the Settings card on mobile. +category: fix +dev: Provider loginError is a wrapping block banner under the auth card header instead of an inline flex sibling, so a long expiry message cannot overflow a phone-width Settings card. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index b318311bfb..3a0bddbc33 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -2484,6 +2484,15 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0. color: var(--color-error); padding-right: 4px; } +.auth-provider-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-xs); + min-width: 0; + max-width: 100%; + margin-left: auto; +} .auth-provider-actions-row { display: flex; width: 100%; @@ -3020,10 +3029,25 @@ The header row wraps so the badge drops below the heading on narrow widths inste gap: var(--space-sm); } - .auth-provider-header > div:not(.auth-provider-info):not(.auth-apikey-section) { + .auth-provider-header > div:not(.auth-provider-info):not(.auth-apikey-section):not(.auth-provider-actions) { margin-left: auto; } + /* + FNXC:ProviderAuth 2026-08-15-22:08: + Login/Logout stay on their own header row. The actions column must take the + full card width and shrink below the button's min-content so a long OAuth + expiry banner rendered under the header cannot widen the flex item past the card. + */ + .auth-provider-header > .auth-provider-actions { + width: 100%; + flex-basis: 100%; + min-width: 0; + max-width: 100%; + align-items: flex-start; + margin-left: 0; + } + .auth-provider-actions-row { width: 100%; justify-content: flex-end; diff --git a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx index 2ea4670f7b..f67aa179e1 100644 --- a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx +++ b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { useState } from "react"; import { AuthenticationSection, type AuthenticationSectionData } from "../settings/sections/AuthenticationSection"; import type { AuthProvider } from "../../api"; +import { loadComponentCss } from "../../test/cssFixture"; vi.mock("../ProviderIcon", () => ({ ProviderIcon: ({ provider }: { provider: string }) => {provider}, @@ -209,10 +210,45 @@ describe("AuthenticationSection", () => { ]); const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; - expect(within(subscriptionCard).getByRole("alert")).toHaveTextContent("expired and could not be refreshed"); + const alert = within(subscriptionCard).getByRole("alert"); + const header = subscriptionCard.querySelector(".auth-provider-header"); + expect(alert).toHaveTextContent("expired and could not be refreshed"); + expect(alert).toHaveClass("auth-provider-login-error"); + expect(alert.tagName).toBe("P"); + expect(header).not.toContainElement(alert); + expect(header?.nextElementSibling).toBe(alert); expect(screen.getAllByRole("alert")).toHaveLength(1); }); + it("keeps a connected OAuth loginError as a wrapping card banner under the header", () => { + renderAuthSection([ + { + id: "anthropic-subscription", + name: "Anthropic Subscription", + authenticated: true, + type: "oauth", + expired: true, + loginError: "This OAuth session expired and could not be refreshed. Re-login to restore model access.", + }, + ]); + + const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + const alert = within(subscriptionCard).getByRole("alert"); + const header = subscriptionCard.querySelector(".auth-provider-header"); + expect(alert).toHaveClass("auth-provider-login-error"); + expect(header).not.toContainElement(alert); + expect(header?.nextElementSibling).toBe(alert); + }); + + it("wraps the provider loginError banner inside the card on a narrow Settings width", () => { + const css = loadComponentCss("settings/sections/AuthenticationSection.css"); + expect(css).toMatch(/\.auth-provider-login-error\s*\{[^}]*display:\s*block/); + expect(css).toMatch(/\.auth-provider-login-error\s*\{[^}]*max-width:\s*100%/); + expect(css).toMatch(/\.auth-provider-login-error\s*\{[^}]*overflow-wrap:\s*anywhere/); + expect(css).toMatch(/\.auth-provider-login-error\s*\{[^}]*word-break:\s*break-word/); + expect(css).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*\.auth-provider-login-error\s*\{[\s\S]*margin-inline:\s*var\(--space-sm\)/); + }); + it("keeps Anthropic OAuth logout separate from a stored API key clear action", () => { const { handleLogout, handleClearApiKey } = renderAuthSection([ { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: true, type: "oauth" }, diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 63bf886061..8a639a13e6 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -159,7 +159,7 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({ })), })); -import { fetchDashboardHealth, fetchSettings, loginProvider, saveApiKey, updateSettings } from "../../api"; +import { fetchAuthStatus, fetchDashboardHealth, fetchSettings, loginProvider, saveApiKey, updateSettings } from "../../api"; function setDocumentHidden(hidden: boolean): void { Object.defineProperty(document, "hidden", { configurable: true, value: hidden }); @@ -736,6 +736,41 @@ describe("SettingsModal mobile adaptations", () => { expect(saveApiKey).toHaveBeenCalledWith("anthropic-api-key", "sk-mobile"); }); + it.each([ + ["modal", (props: { onClose: () => void; addToast: () => void }) => ], + ["embedded", (props: { onClose: () => void; addToast: () => void }) => ], + ])("keeps the Anthropic OAuth loginError banner inside the card on mobile %s Settings", async (_surface, Surface) => { + mockSettingsViewport(true); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 }); + vi.mocked(fetchAuthStatus).mockResolvedValue({ + providers: [ + { + id: "anthropic-subscription", + name: "Anthropic Subscription", + authenticated: false, + type: "oauth", + expired: true, + loginError: "This OAuth session expired and could not be refreshed. Re-login to restore model access.", + }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + ], + } as Awaited>); + + const user = userEvent.setup(); + const { findByTestId, getByLabelText } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + await user.selectOptions(getByLabelText("Settings Section"), "authentication"); + + const subscriptionCard = (await findByTestId("auth-provider-icon-anthropic-subscription")).closest(".auth-provider-card") as HTMLElement; + const alert = within(subscriptionCard).getByRole("alert"); + const header = subscriptionCard.querySelector(".auth-provider-header"); + expect(alert).toHaveClass("auth-provider-login-error"); + expect(alert).toHaveTextContent("Re-login to restore model access"); + expect(header).not.toContainElement(alert); + expect(header?.nextElementSibling).toBe(alert); + expect(subscriptionCard.querySelector(".auth-provider-actions")).toBeTruthy(); + }); + it("renders notification provider cards responsively on mobile", async () => { mockSettingsViewport(true); Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 }); @@ -895,7 +930,12 @@ describe("SettingsModal mobile adaptations", () => { // Custom Provider cards render outside .auth-panel-body and retain their mobile gutter. expectMobileRule(css, ".auth-provider-card", "margin: 0 var(--space-sm) var(--space-sm);"); expectMobileRule(css, ".auth-provider-header", "padding: var(--space-sm);"); - expectMobileRule(css, ".auth-provider-header > div:not(.auth-provider-info):not(.auth-apikey-section)", "margin-left: auto;"); + expectMobileRule(css, ".auth-provider-header > div:not(.auth-provider-info):not(.auth-apikey-section):not(.auth-provider-actions)", "margin-left: auto;"); + expectMobileRule(css, ".auth-provider-header > .auth-provider-actions", "width: 100%;"); + expectMobileRule(css, ".auth-provider-header > .auth-provider-actions", "flex-basis: 100%;"); + expectMobileRule(css, ".auth-provider-header > .auth-provider-actions", "min-width: 0;"); + expectMobileRule(css, ".auth-provider-header > .auth-provider-actions", "max-width: 100%;"); + expectMobileRule(css, ".auth-provider-header > .auth-provider-actions", "margin-left: 0;"); expectMobileRule(css, ".auth-apikey-section", "align-items: flex-end;"); expectMobileRule(css, ".auth-apikey-input-row", "justify-content: flex-end;"); expectMobileRule(css, ".auth-apikey-input-row .btn", "margin-left: auto;"); diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.css b/packages/dashboard/app/components/settings/sections/AuthenticationSection.css index 2d4c0f995d..95534a9365 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.css +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.css @@ -10,8 +10,36 @@ Settings modal so account controls stay reachable without introducing a second c .auth-pending-instance-field { display: flex; flex: 1 1 var(--space-2xl); flex-direction: column; gap: var(--space-xs); } .auth-pending-instance-label { color: var(--text-muted); font-size: var(--font-size-xs); font-weight: 600; } .auth-instance-row > span { flex: 1; min-width: 0; } + +/* +FNXC:ProviderAuth 2026-08-15-22:08: +OAuth loginError is a durable card banner, not an inline hint. A wrapping block +with a defined width keeps the Anthropic expiry sentence inside the provider +card on a phone-width Settings modal; an inline sized the actions +flex item to min-content and painted a broken per-line border past the card. +Inset matches .auth-provider-header so Login and the banner share one edge. +*/ +.auth-provider-login-error { + display: block; + box-sizing: border-box; + width: auto; + max-width: 100%; + min-width: 0; + margin: 0 var(--space-md) var(--space-sm); + padding: var(--space-sm); + border: 1px solid var(--color-error); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-error) 10%, transparent); + color: var(--color-error); + font-size: var(--font-size-xs); + line-height: 1.4; + overflow-wrap: anywhere; + word-break: break-word; +} + @media (max-width: 768px) { .auth-instance-row, .auth-instance-pending { align-items: stretch; } .auth-instance-row > .btn, .auth-instance-pending > .btn { flex: 1; } .auth-pending-instance-field { flex-basis: 100%; } + .auth-provider-login-error { margin-inline: var(--space-sm); } } diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index 13e49ffdf9..bd5967724b 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -274,10 +274,13 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec }; /* FNXC:ProviderAuth 2026-07-14-15:54: - Provider authentication failures must remain visible on the affected card. Toasts are transient and can fire while Settings is closed, so render the server's loginError beside the provider actions as the durable re-auth remediation. + Provider authentication failures must remain visible on the affected card. Toasts are transient and can fire while Settings is closed, so render the server's loginError as the durable re-auth remediation. + + FNXC:ProviderAuth 2026-08-15-22:08: + The loginError used to sit inline beside Login as ``. On a narrow Settings card that flex item sized to the sentence's min-content width, so the Anthropic expiry copy overflowed the card and the leaked global `.form-error` border painted as a broken per-line box. Keep it a wrapping block under the header so the banner stays inside the card at every Settings width. */ const renderProviderAuthError = (provider: AuthProvider) => provider.loginError - ? ({provider.loginError}) + ? (

{provider.loginError}

) : null; const renderApiKeySection = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string, isPending = false) => { const instanceId = selectedInstanceId ?? provider.instanceId; @@ -356,9 +359,10 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec {renderAnthropicPrecedenceBadge(provider)} {provider.authenticated && provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})} - {provider.type !== "api_key" && !hasMultipleInstances(provider) &&
{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}
} + {provider.type !== "api_key" && !hasMultipleInstances(provider) &&
{renderAuthenticatedOAuthActions(provider)}
} {providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)} + {provider.type !== "api_key" && !hasMultipleInstances(provider) && renderProviderAuthError(provider)} {renderInstanceControls(provider)} ))} {renderAnthropicPrecedenceRow()} @@ -378,16 +382,17 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec {provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})} - {provider.type !== "api_key" && !hasMultipleInstances(provider) &&
{renderAvailableOAuthActions(provider)}{renderProviderAuthError(provider)}
} + {provider.type !== "api_key" && !hasMultipleInstances(provider) &&
{renderAvailableOAuthActions(provider)}
} {providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)} + {provider.type !== "api_key" && !hasMultipleInstances(provider) && renderProviderAuthError(provider)} {renderInstanceControls(provider)} ))} )} )} {/* FNXC:SettingsHelp 2026-07-16-12:45: - The provider cards' ``s stay inline: they are all live state (save progress, key errors, provider loginError, OpenCode refresh status) that must stay visible where the operator is acting. The two DESCRIPTIVE blurbs this section carried — the panel-level "changes take effect immediately" hint and the reopen-onboarding hint — moved behind the shared "?" affordance per the operator requirement that no inline description paragraphs remain in Settings. + Save-progress, API-key, and OpenCode refresh ``s stay inline on the control they describe. Provider loginError is a wrapping block banner under the card header so a long OAuth expiry message cannot overflow the card. The two DESCRIPTIVE blurbs this section carried — the panel-level "changes take effect immediately" hint and the reopen-onboarding hint — moved behind the shared "?" affordance per the operator requirement that no inline description paragraphs remain in Settings. */} {onReopenOnboarding && (