diff --git a/src/i18n/locales/en-US/auth.json b/src/i18n/locales/en-US/auth.json index fa2bc54f..fb123b2c 100644 --- a/src/i18n/locales/en-US/auth.json +++ b/src/i18n/locales/en-US/auth.json @@ -5,6 +5,8 @@ "auth.login.submit": "Sign In", "auth.login.submitting": "Signing in…", "auth.login.forgotPassword": "Forgot Password?", + "auth.login.sso.divider": "or", + "auth.login.sso.signIn": "Sign in with {provider}", "auth.forgotPassword.title": "Reset Password", "auth.forgotPassword.description": "Enter your email to receive reset instructions", "auth.forgotPassword.email": "Email Address", @@ -64,5 +66,6 @@ "auth.logout": "Sign Out", "auth.login.error.invalidCredentials": "Invalid credentials.", "auth.login.error.failed": "Login failed ({status}).", - "auth.login.error.unexpected": "An unexpected error occurred." + "auth.login.error.unexpected": "An unexpected error occurred.", + "auth.login.error.ssoFailed": "Single sign-on failed. Please try again or use your email and password." } diff --git a/src/i18n/locales/es-ES/auth.json b/src/i18n/locales/es-ES/auth.json index 94ee65d5..abf333c5 100644 --- a/src/i18n/locales/es-ES/auth.json +++ b/src/i18n/locales/es-ES/auth.json @@ -5,6 +5,8 @@ "auth.login.submit": "Iniciar Sesión", "auth.login.submitting": "Iniciando sesión…", "auth.login.forgotPassword": "¿Olvidaste tu contraseña?", + "auth.login.sso.divider": "o", + "auth.login.sso.signIn": "Iniciar sesión con {provider}", "auth.forgotPassword.title": "Restablecer Contraseña", "auth.forgotPassword.description": "Ingresa tu correo electrónico para recibir instrucciones de restablecimiento", "auth.forgotPassword.email": "Dirección de Correo Electrónico", @@ -64,5 +66,6 @@ "auth.logout": "Cerrar Sesión", "auth.login.error.invalidCredentials": "Credenciales inválidas.", "auth.login.error.failed": "Error al iniciar sesión ({status}).", - "auth.login.error.unexpected": "Ocurrió un error inesperado." + "auth.login.error.unexpected": "Ocurrió un error inesperado.", + "auth.login.error.ssoFailed": "El inicio de sesión único falló. Inténtalo de nuevo o usa tu correo y contraseña." } diff --git a/src/i18n/locales/pt-BR/auth.json b/src/i18n/locales/pt-BR/auth.json index 4a9c2801..2ca4ed67 100644 --- a/src/i18n/locales/pt-BR/auth.json +++ b/src/i18n/locales/pt-BR/auth.json @@ -5,6 +5,8 @@ "auth.login.submit": "Entrar", "auth.login.submitting": "Entrando…", "auth.login.forgotPassword": "Esqueceu a senha?", + "auth.login.sso.divider": "ou", + "auth.login.sso.signIn": "Entrar com {provider}", "auth.forgotPassword.title": "Redefinir Senha", "auth.forgotPassword.description": "Digite seu e-mail para receber instruções de redefinição", "auth.forgotPassword.email": "Endereço de E-mail", @@ -64,5 +66,6 @@ "auth.logout": "Sair", "auth.login.error.invalidCredentials": "Credenciais inválidas.", "auth.login.error.failed": "Falha ao entrar ({status}).", - "auth.login.error.unexpected": "Ocorreu um erro inesperado." + "auth.login.error.unexpected": "Ocorreu um erro inesperado.", + "auth.login.error.ssoFailed": "O login único falhou. Tente novamente ou use seu e-mail e senha." } diff --git a/src/pages/Login.test.tsx b/src/pages/Login.test.tsx index 119a5534..86631337 100644 --- a/src/pages/Login.test.tsx +++ b/src/pages/Login.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { Login } from "./Login"; import { useAuth } from "../auth/useAuth"; @@ -225,4 +225,110 @@ describe("Login", () => { expect(mockNavigate).toHaveBeenCalledWith("/app/tools?page=2"); }); + + describe("SSO", () => { + const originalPath = window.location.pathname + window.location.search; + + afterEach(() => { + vi.unstubAllGlobals(); + window.history.replaceState(null, "", originalPath); + }); + + it("does not render an SSO button when ssoEnabled is false", () => { + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + ssoEnabled: false, + } as unknown as ReturnType); + + renderWithI18n(); + + expect(screen.queryByRole("button", { name: /Sign in with/i })).not.toBeInTheDocument(); + }); + + it("does not render an SSO button when ssoEnabled/ssoProviderName are absent (session not yet resolved)", () => { + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + } as unknown as ReturnType); + + renderWithI18n(); + + expect(screen.queryByRole("button", { name: /Sign in with/i })).not.toBeInTheDocument(); + }); + + it("does not render an SSO button when ssoEnabled is true but ssoProviderName is absent", () => { + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + ssoEnabled: true, + ssoProviderName: undefined, + } as unknown as ReturnType); + + renderWithI18n(); + + expect(screen.queryByRole("button", { name: /Sign in with/i })).not.toBeInTheDocument(); + }); + + it("renders and navigates via a full-page redirect when SSO is enabled", () => { + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + ssoEnabled: true, + ssoProviderName: "Keycloak", + } as unknown as ReturnType); + vi.stubGlobal("location", { ...window.location, href: "" }); + + renderWithI18n(); + + const ssoButton = screen.getByRole("button", { name: /Sign in with Keycloak/i }); + fireEvent.click(ssoButton); + + expect(window.location.href).toBe("/auth/sso/login?next=%2Fapp%2F"); + }); + + it("renders a visible error for an SSO callback failure without navigating further", async () => { + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + } as unknown as ReturnType); + window.history.pushState({}, "", "/app/login?error=sso_access_denied"); + + renderWithI18n(); + + expect(screen.getByRole("alert")).toHaveTextContent(/Single sign-on failed/i); + expect(mockNavigate).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(window.location.search).toBe(""); + }); + }); + + it("ignores a non-SSO error param", () => { + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + } as unknown as ReturnType); + window.history.pushState({}, "", "/app/login?error=auth_failed"); + + renderWithI18n(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(window.location.search).toBe("?error=auth_failed"); + }); + + it("strips the error param but keeps other query params (e.g. next) intact", async () => { + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + } as unknown as ReturnType); + window.history.pushState({}, "", "/app/login?next=%2Fapp%2Ftools&error=sso_access_denied"); + + renderWithI18n(); + + await waitFor(() => { + expect(window.location.search).toBe("?next=%2Fapp%2Ftools"); + }); + }); + }); }); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 4a96cdb1..71a64bd3 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { useAuth } from "../auth/useAuth"; import { useRouter, resolveNextParam } from "../router"; @@ -10,13 +10,19 @@ import { Label } from "@/components/ui/label"; export function Login() { const intl = useIntl(); - const { isAuthenticated, login } = useAuth(); + const { isAuthenticated, login, ssoEnabled, ssoProviderName } = useAuth(); const { navigate } = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); const returnTo = resolveNextParam(window.location.search); + const ssoErrorParam = useMemo(() => { + const param = new URLSearchParams(window.location.search).get("error"); + return param?.startsWith("sso_") ? param : null; + }, []); + const [error, setError] = useState(() => + ssoErrorParam ? intl.formatMessage({ id: "auth.login.error.ssoFailed" }) : null, + ); + const [loading, setLoading] = useState(false); useEffect(() => { if (isAuthenticated) { @@ -24,6 +30,17 @@ export function Login() { } }, [isAuthenticated, navigate, returnTo]); + useEffect(() => { + if (!ssoErrorParam) return; + const url = new URL(window.location.href); + url.searchParams.delete("error"); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + }, [ssoErrorParam]); + + function handleSsoLogin() { + window.location.href = `/auth/sso/login?next=${encodeURIComponent(returnTo)}`; + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); @@ -105,6 +122,23 @@ export function Login() { : intl.formatMessage({ id: "auth.login.submit" })} + {ssoEnabled && ssoProviderName && ( + <> +
+ +
+ + {intl.formatMessage({ id: "auth.login.sso.divider" })} + +
+
+ + + )}