diff --git a/frontend/playwright/ui/visual-specs/visual-login.spec.js b/frontend/playwright/ui/visual-specs/visual-login.spec.js index 6b53a2d5d9..7c9650d656 100644 --- a/frontend/playwright/ui/visual-specs/visual-login.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-login.spec.js @@ -37,3 +37,74 @@ test.describe("Login form", () => { await expect(login.page).toHaveScreenshot(); }); }); + +test.describe("Login dialog section links", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + // The links switch the dialog section: the tests use the keyboard to + // check they work as buttons. + test("Login and register modal", async ({ page }) => { + // The viewer and the MCP authorize page share this modal; the MCP page + // opens it on a 401, with no file to load. + await LoginPage.mockConfigFlags(page, ["enable-registration"]); + await page.route("**/api/mcp/oauth/request?*", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: "{}", + }), + ); + await page.goto("/?screen=mcp-authorize&request=test"); + + const modal = page.getByRole("dialog"); + const forgot = modal.getByText("Forgot password?", { exact: true }); + await expect(forgot).toBeVisible(); + await page.mouse.move(10, 10); + await expect(forgot).toHaveScreenshot(shot); + await forgot.hover(); + await expect(forgot).toHaveScreenshot(shot); + + const create = modal.getByText("Create an account", { exact: true }); + await create.hover(); + await expect(create).toHaveScreenshot(shot); + + await create.focus(); + await page.keyboard.press("Enter"); + const loginHere = modal.getByText("Login here.", { exact: true }); + await expect(loginHere).toBeVisible(); + await page.mouse.move(10, 10); + await expect(loginHere).toHaveScreenshot(shot); + await loginHere.hover(); + await expect(loginHere).toHaveScreenshot(shot); + + await loginHere.focus(); + await page.keyboard.press("Enter"); + await page.mouse.move(10, 10); + await expect(modal).toHaveScreenshot(shot); + }); + + test("Not found page dialog", async ({ page }) => { + await LoginPage.mockConfigFlags(page, ["enable-registration"]); + // An invalid team id is a not found error; logged out users get the + // login dialog on top of it. + await page.goto("/?screen=dashboard-recent&team-id=not-a-uuid"); + + const create = page.getByText("Create an account", { exact: true }); + await expect(create).toBeVisible(); + await page.mouse.move(10, 10); + await expect(create).toHaveScreenshot(shot); + await create.hover(); + await expect(create).toHaveScreenshot(shot); + + await create.focus(); + await page.keyboard.press("Enter"); + const loginHere = page.getByText("Login here.", { exact: true }); + await expect(loginHere).toBeVisible(); + // The new section reuses the focused button: blur it for the rest shot. + await loginHere.blur(); + await page.mouse.move(10, 10); + await expect(loginHere).toHaveScreenshot(shot); + await loginHere.hover(); + await expect(loginHere).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/auth/login_modal.cljs b/frontend/src/app/main/ui/auth/login_modal.cljs index ee192893a4..fa445b0b4d 100644 --- a/frontend/src/app/main/ui/auth/login_modal.cljs +++ b/frontend/src/app/main/ui/auth/login_modal.cljs @@ -102,15 +102,19 @@ :hide-recovery-link true}] [:div {:class (stl/css :login-links)} [:div - [:a {:on-click set-section - :data-value "recovery-request"} + [:button {:type "button" + :class (stl/css :link) + :on-click set-section + :data-value "recovery-request"} (tr "auth.forgot-password")]] (when (contains? cf/flags :registration) [:div [:span (tr "auth.register") " "] - [:a {:on-click set-section - :data-value "register"} + [:button {:type "button" + :class (stl/css :link) + :on-click set-section + :data-value "register"} (tr "auth.register-submit")]])]] :register @@ -119,8 +123,10 @@ [:div {:class (stl/css :login-links)} [:div [:span (tr "auth.already-have-account") " "] - [:a {:on-click set-section - :data-value "login"} + [:button {:type "button" + :class (stl/css :link) + :on-click set-section + :data-value "login"} (tr "auth.login-here")]]]] :register-validate diff --git a/frontend/src/app/main/ui/auth/login_modal.scss b/frontend/src/app/main/ui/auth/login_modal.scss index 92963aae79..28f06b8524 100644 --- a/frontend/src/app/main/ui/auth/login_modal.scss +++ b/frontend/src/app/main/ui/auth/login_modal.scss @@ -72,3 +72,21 @@ position: relative; color: var(--color-foreground-primary); } + +.link { + font: inherit; + font-weight: 500; + + // A button is a block in its line: the normal line height keeps the box of + // the inline link it replaces. + line-height: normal; + color: var(--color-accent-primary); + background: none; + border: none; + padding: 0; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index fe75e1f7b2..b801e379c2 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -176,8 +176,10 @@ [:div {:class (stl/css :change-section)} (tr "auth.register") " " - [:a {:data-section "register" - :on-click set-section} + [:button {:type "button" + :class (stl/css :link) + :data-section "register" + :on-click set-section} (tr "auth.register-submit")]]] :register @@ -190,8 +192,11 @@ [:div {:class (stl/css :change-section)} (tr "auth.already-have-account") " " - [:a {:data-section "login" - :on-click set-section} (tr "auth.login-here")]] + [:button {:type "button" + :class (stl/css :link) + :data-section "login" + :on-click set-section} + (tr "auth.login-here")]] [:div {:class (stl/css :links)} [:hr {:class (stl/css :separator)}] [:> register/terms-service-privacy-policy*]]] diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index 77e7961895..3f1b7c81a9 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -373,9 +373,22 @@ inline-size: 100%; text-align: center; - // Native inside change-section — no class available - a { + .link { + font: inherit; + font-weight: 500; + + // A button is a block in its line: the normal line height keeps the box of + // the inline link it replaces. + line-height: normal; color: var(--color-accent-primary); + background: none; + border: none; + padding: 0; + cursor: pointer; + + &:hover { + text-decoration: underline; + } } }