feat(#416): redesign login page with dynamic provider rendering
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Fetches GET /auth/config on mount and renders OAuth + email/password forms based on backend-advertised providers. Falls back to email-only if config fetch fails. Refs #416 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,37 +1,279 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
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";
|
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", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: (): { push: ReturnType<typeof vi.fn> } => ({
|
useRouter: (): { push: Mock } => ({
|
||||||
push: vi.fn(),
|
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<AuthConfigResponse> => 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 => {
|
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(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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(<LoginPage />);
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack");
|
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 => {
|
it("has proper layout styling", (): void => {
|
||||||
render(<LoginPage />);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
const descriptions = screen.getAllByText(/Your personal assistant platform/i);
|
|
||||||
expect(descriptions.length).toBeGreaterThan(0);
|
|
||||||
expect(descriptions[0]).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the sign in button", (): void => {
|
|
||||||
render(<LoginPage />);
|
|
||||||
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(<LoginPage />);
|
const { container } = render(<LoginPage />);
|
||||||
const main = container.querySelector("main");
|
const main = container.querySelector("main");
|
||||||
expect(main).toHaveClass("flex", "min-h-screen");
|
expect(main).toHaveClass("flex", "min-h-screen");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches /auth/config on mount", async (): Promise<void> => {
|
||||||
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith("http://localhost:3001/auth/config");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders OAuth button when OIDC provider is in config", async (): Promise<void> => {
|
||||||
|
mockFetchConfig(OAUTH_ONLY_CONFIG);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchConfig(BOTH_PROVIDERS_CONFIG);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchConfig(OAUTH_ONLY_CONFIG);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchFailure();
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
(global.fetch as Mock).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchConfig(OAUTH_ONLY_CONFIG);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
mockSignInEmail.mockResolvedValueOnce({
|
||||||
|
error: { message: "Invalid credentials" },
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
mockSignInEmail.mockRejectedValueOnce(new Error("Network failure"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import type { ReactElement } 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 {
|
export default function LoginPage(): ReactElement {
|
||||||
|
const router = useRouter();
|
||||||
|
const [config, setConfig] = useState<AuthConfigResponse | null>(null);
|
||||||
|
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||||
|
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
||||||
|
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function fetchConfig(): Promise<void> {
|
||||||
|
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<void> => {
|
||||||
|
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 (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-8 bg-gray-50">
|
<main className="flex min-h-screen flex-col items-center justify-center p-8 bg-gray-50">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
@@ -12,8 +106,49 @@ export default function LoginPage(): ReactElement {
|
|||||||
PDA-friendly approach.
|
PDA-friendly approach.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-8 rounded-lg shadow-md">
|
<div className="bg-white p-8 rounded-lg shadow-md">
|
||||||
<LoginButton />
|
{loadingConfig ? (
|
||||||
|
<div className="flex items-center justify-center py-8" data-testid="loading-spinner">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Loading authentication options</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error && !hasCredentials && (
|
||||||
|
<AuthErrorBanner
|
||||||
|
message={error}
|
||||||
|
onDismiss={(): void => {
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOAuth &&
|
||||||
|
oauthProviders.map((provider) => (
|
||||||
|
<OAuthButton
|
||||||
|
key={provider.id}
|
||||||
|
providerName={provider.name}
|
||||||
|
providerId={provider.id}
|
||||||
|
onClick={(): void => {
|
||||||
|
handleOAuthLogin(provider.id);
|
||||||
|
}}
|
||||||
|
isLoading={oauthLoading === provider.id}
|
||||||
|
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasOAuth && hasCredentials && <AuthDivider />}
|
||||||
|
|
||||||
|
{hasCredentials && (
|
||||||
|
<LoginForm
|
||||||
|
onSubmit={handleCredentialsLogin}
|
||||||
|
isLoading={credentialsLoading}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user