Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/i18n/locales/en-US/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
5 changes: 4 additions & 1 deletion src/i18n/locales/es-ES/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
5 changes: 4 additions & 1 deletion src/i18n/locales/pt-BR/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
108 changes: 107 additions & 1 deletion src/pages/Login.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof useAuth>);

renderWithI18n(<Login />);

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<typeof useAuth>);

renderWithI18n(<Login />);

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<typeof useAuth>);

renderWithI18n(<Login />);

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<typeof useAuth>);
vi.stubGlobal("location", { ...window.location, href: "" });

renderWithI18n(<Login />);

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<typeof useAuth>);
window.history.pushState({}, "", "/app/login?error=sso_access_denied");

renderWithI18n(<Login />);

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<typeof useAuth>);
window.history.pushState({}, "", "/app/login?error=auth_failed");

renderWithI18n(<Login />);

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<typeof useAuth>);
window.history.pushState({}, "", "/app/login?next=%2Fapp%2Ftools&error=sso_access_denied");

renderWithI18n(<Login />);

await waitFor(() => {
expect(window.location.search).toBe("?next=%2Fapp%2Ftools");
});
});
});
});
42 changes: 38 additions & 4 deletions src/pages/Login.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,20 +10,37 @@ 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<string | null>(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<string | null>(() =>
ssoErrorParam ? intl.formatMessage({ id: "auth.login.error.ssoFailed" }) : null,
);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (isAuthenticated) {
navigate(returnTo);
}
}, [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);
Expand Down Expand Up @@ -105,6 +122,23 @@ export function Login() {
: intl.formatMessage({ id: "auth.login.submit" })}
</Button>
</form>
{ssoEnabled && ssoProviderName && (
<>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<span className="w-full border-t border-neutral-200 dark:border-neutral-700" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-neutral-800 px-2 text-neutral-500 dark:text-neutral-400">
{intl.formatMessage({ id: "auth.login.sso.divider" })}
</span>
</div>
</div>
<Button type="button" variant="outline" onClick={handleSsoLogin} className="w-full">
{intl.formatMessage({ id: "auth.login.sso.signIn" }, { provider: ssoProviderName })}
</Button>
</>
)}
<div className="mt-4 text-center">
<Button
type="button"
Expand Down
Loading