diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index 6facd93..683514c 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -1,37 +1,279 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { AuthConfigResponse } from "@mosaic/shared"; import LoginPage from "./page"; -// Mock next/navigation +/* ------------------------------------------------------------------ */ +/* Hoisted mocks */ +/* ------------------------------------------------------------------ */ + +const { mockOAuth2, mockSignInEmail, mockPush } = vi.hoisted(() => ({ + mockOAuth2: vi.fn(), + mockSignInEmail: vi.fn(), + mockPush: vi.fn(), +})); + vi.mock("next/navigation", () => ({ - useRouter: (): { push: ReturnType } => ({ - push: vi.fn(), + useRouter: (): { push: Mock } => ({ + push: mockPush, }), })); +vi.mock("@/lib/auth-client", () => ({ + signIn: { + oauth2: mockOAuth2, + email: mockSignInEmail, + }, +})); + +vi.mock("@/lib/config", () => ({ + API_BASE_URL: "http://localhost:3001", +})); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function mockFetchConfig(config: AuthConfigResponse): void { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: (): Promise => Promise.resolve(config), + }); +} + +function mockFetchFailure(): void { + (global.fetch as Mock).mockRejectedValueOnce(new Error("Network error")); +} + +const OAUTH_ONLY_CONFIG: AuthConfigResponse = { + providers: [{ id: "authentik", name: "Authentik", type: "oauth" }], +}; + +const EMAIL_ONLY_CONFIG: AuthConfigResponse = { + providers: [{ id: "email", name: "Email", type: "credentials" }], +}; + +const BOTH_PROVIDERS_CONFIG: AuthConfigResponse = { + providers: [ + { id: "authentik", name: "Authentik", type: "oauth" }, + { id: "email", name: "Email", type: "credentials" }, + ], +}; + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + describe("LoginPage", (): void => { - it("should render the login page with title", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + it("renders loading state initially", (): void => { + // Never resolve fetch so it stays in loading state + // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise to test loading state + (global.fetch as Mock).mockReturnValueOnce(new Promise(() => {})); + render(); + + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + expect(screen.getByText("Loading authentication options")).toBeInTheDocument(); + }); + + it("renders the page heading and description", (): void => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack"); + expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument(); }); - it("should display the description", (): void => { - render(); - const descriptions = screen.getAllByText(/Your personal assistant platform/i); - expect(descriptions.length).toBeGreaterThan(0); - expect(descriptions[0]).toBeInTheDocument(); - }); + it("has proper layout styling", (): void => { + mockFetchConfig(EMAIL_ONLY_CONFIG); - it("should render the sign in button", (): void => { - render(); - const buttons = screen.getAllByRole("button", { name: /sign in/i }); - expect(buttons.length).toBeGreaterThan(0); - expect(buttons[0]).toBeInTheDocument(); - }); - - it("should have proper layout styling", (): void => { const { container } = render(); const main = container.querySelector("main"); expect(main).toHaveClass("flex", "min-h-screen"); }); + + it("fetches /auth/config on mount", async (): Promise => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect(global.fetch).toHaveBeenCalledWith("http://localhost:3001/auth/config"); + }); + }); + + it("renders OAuth button when OIDC provider is in config", async (): Promise => { + mockFetchConfig(OAUTH_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); + }); + }); + + it("renders only LoginForm when only email provider is configured", async (): Promise => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); + }); + + it("renders both OAuth button and LoginForm with divider when both providers present", async (): Promise => { + mockFetchConfig(BOTH_PROVIDERS_CONFIG); + + render(); + + await waitFor((): void => { + expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); + }); + + expect(screen.getByText(/or continue with email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + + it("does not render divider when only OAuth providers present", async (): Promise => { + mockFetchConfig(OAUTH_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); + }); + + expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument(); + }); + + it("falls back to email-only on fetch failure", async (): Promise => { + mockFetchFailure(); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); + }); + + it("falls back to email-only on non-ok response", async (): Promise => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); + }); + + it("calls signIn.oauth2 when OAuth button is clicked", async (): Promise => { + mockFetchConfig(OAUTH_ONLY_CONFIG); + const user = userEvent.setup(); + + render(); + + await waitFor((): void => { + expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /continue with authentik/i })); + + expect(mockOAuth2).toHaveBeenCalledWith({ + providerId: "authentik", + callbackURL: "/", + }); + }); + + it("calls signIn.email and redirects on success", async (): Promise => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } }); + const user = userEvent.setup(); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /continue/i })); + + await waitFor((): void => { + expect(mockSignInEmail).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + }); + }); + + await waitFor((): void => { + expect(mockPush).toHaveBeenCalledWith("/tasks"); + }); + }); + + it("shows error banner on sign-in failure", async (): Promise => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + mockSignInEmail.mockResolvedValueOnce({ + error: { message: "Invalid credentials" }, + }); + const user = userEvent.setup(); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.type(screen.getByLabelText(/password/i), "wrong"); + await user.click(screen.getByRole("button", { name: /continue/i })); + + await waitFor((): void => { + expect(screen.getByText("Invalid credentials")).toBeInTheDocument(); + }); + + expect(mockPush).not.toHaveBeenCalled(); + }); + + it("shows generic error on unexpected sign-in exception", async (): Promise => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + mockSignInEmail.mockRejectedValueOnce(new Error("Network failure")); + const user = userEvent.setup(); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.type(screen.getByLabelText(/password/i), "password"); + await user.click(screen.getByRole("button", { name: /continue/i })); + + await waitFor((): void => { + expect( + screen.getByText("Something went wrong. Please try again in a moment.") + ).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 4881a19..5cf34ae 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,7 +1,101 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; import type { ReactElement } from "react"; -import { LoginButton } from "@/components/auth/LoginButton"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared"; +import { API_BASE_URL } from "@/lib/config"; +import { signIn } from "@/lib/auth-client"; +import { OAuthButton } from "@/components/auth/OAuthButton"; +import { LoginForm } from "@/components/auth/LoginForm"; +import { AuthDivider } from "@/components/auth/AuthDivider"; +import { AuthErrorBanner } from "@/components/auth/AuthErrorBanner"; + +/** Fallback config when the backend is unreachable */ +const EMAIL_ONLY_CONFIG: AuthConfigResponse = { + providers: [{ id: "email", name: "Email", type: "credentials" }], +}; export default function LoginPage(): ReactElement { + const router = useRouter(); + const [config, setConfig] = useState(null); + const [loadingConfig, setLoadingConfig] = useState(true); + const [oauthLoading, setOauthLoading] = useState(null); + const [credentialsLoading, setCredentialsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchConfig(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/auth/config`); + if (!response.ok) { + throw new Error("Failed to fetch auth config"); + } + const data = (await response.json()) as AuthConfigResponse; + if (!cancelled) { + setConfig(data); + } + } catch { + if (!cancelled) { + setConfig(EMAIL_ONLY_CONFIG); + } + } finally { + if (!cancelled) { + setLoadingConfig(false); + } + } + } + + void fetchConfig(); + + return (): void => { + cancelled = true; + }; + }, []); + + const oauthProviders: AuthProviderConfig[] = + config?.providers.filter((p) => p.type === "oauth") ?? []; + const credentialProviders: AuthProviderConfig[] = + config?.providers.filter((p) => p.type === "credentials") ?? []; + + const hasOAuth = oauthProviders.length > 0; + const hasCredentials = credentialProviders.length > 0; + + const handleOAuthLogin = useCallback((providerId: string): void => { + setOauthLoading(providerId); + setError(null); + void signIn.oauth2({ providerId, callbackURL: "/" }); + }, []); + + const handleCredentialsLogin = useCallback( + async (email: string, password: string): Promise => { + setCredentialsLoading(true); + setError(null); + + try { + const result = await signIn.email({ email, password }); + + if (result.error) { + setError( + typeof result.error.message === "string" + ? result.error.message + : "Unable to sign in. Please check your credentials and try again." + ); + } else { + router.push("/tasks"); + } + } catch { + setError("Something went wrong. Please try again in a moment."); + } finally { + setCredentialsLoading(false); + } + }, + [router] + ); + return (
@@ -12,8 +106,49 @@ export default function LoginPage(): ReactElement { PDA-friendly approach.

+
- + {loadingConfig ? ( +
+
+ ) : ( + <> + {error && !hasCredentials && ( + { + setError(null); + }} + /> + )} + + {hasOAuth && + oauthProviders.map((provider) => ( + { + handleOAuthLogin(provider.id); + }} + isLoading={oauthLoading === provider.id} + disabled={oauthLoading !== null && oauthLoading !== provider.id} + /> + ))} + + {hasOAuth && hasCredentials && } + + {hasCredentials && ( + + )} + + )}