Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 5 additions & 0 deletions .changeset/quiet-login-sso.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evidence-browser/api": minor

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — changeset targets the wrong package.

This changeset bumps @evidence-browser/api": minor, but every file this PR touches is under packages/web/** (router.tsx, lib/api.ts, lib/types.ts, tests, e2e spec). packages/api has no changes here — the /api/auth/config route itself already shipped in #175 (bright-rivers-serve.md, correctly scoped to @evidence-browser/api).

As written, changeset version will bump @evidence-browser/api with an empty diff and give @evidence-browser/web — the package that actually changed — no version bump or CHANGELOG entry. Should be:

---
"@evidence-browser/web": minor
---

Non-blocking for functionality, but worth a one-line fix before/at merge so the release changelog stays accurate.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 89d85e9: the changeset now targets @evidence-browser/web and uses patch, matching the issue's changeset:patch label.

---

Add config-driven login rendering for issue #167 so deployments can expose OIDC SSO, local login, or both from `/api/auth/config`.
158 changes: 158 additions & 0 deletions packages/web/e2e/login-auth-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { expect, test, type Page } from "@playwright/test";

type AuthConfig = {
local: boolean;
oidc: {
enabled: boolean;
label: string;
};
};

async function stubLoginPrerequisites(page: Page) {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Unauthenticated" }),
});
});
await page.route("**/api/setup/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ needsSetup: false, hasAdmin: true, hasWorkspace: true }),
});
});
}

async function stubAuthConfig(page: Page, config: AuthConfig) {
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(config),
});
});
}

async function gotoLogin(page: Page, path: string, configuredBaseURL?: string) {
const baseURL = configuredBaseURL ?? process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
await page.goto(new URL(path, baseURL).toString());
}

test.beforeEach(async ({ page }) => {
await stubLoginPrerequisites(page);
});

test("does not render sign-in controls while auth config is loading", async ({ page, baseURL }) => {
await page.route("**/api/auth/config", () => undefined);

await gotoLogin(page, "/login?callbackUrl=%2Fw%2Finfra", baseURL);

await expect(page.getByLabel("Username")).toHaveCount(0);
await expect(page.getByLabel("Password")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Sign in" })).toHaveCount(0);
await expect(page.getByRole("link", { name: /SSO/ })).toHaveCount(0);
});

test("renders the unchanged local-only login form", async ({ page, baseURL }) => {
await stubAuthConfig(page, {
local: true,
oidc: { enabled: false, label: "Sign in with SSO" },
});

await gotoLogin(page, "/login?callbackUrl=%2Fw%2Finfra", baseURL);

await expect(page.getByRole("heading", { name: "Sign in to your workspace" })).toBeVisible();
await expect(page.getByText("Enter your credentials to access your workspace.")).toBeVisible();
await expect(page.getByLabel("Username")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in with SSO" })).toHaveCount(0);
await expect(page.getByRole("separator", { name: "or" })).toHaveCount(0);
});

test("renders local form with divider and SSO button when both modes are enabled", async ({ page, baseURL }) => {
await stubAuthConfig(page, {
local: true,
oidc: { enabled: true, label: "Continue with Acme SSO" },
});

await gotoLogin(page, "/login?callbackUrl=%2Fw%2Finfra", baseURL);

await expect(page.getByLabel("Username")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
await expect(page.getByRole("separator", { name: "or" })).toBeVisible();
await expect(page.getByRole("link", { name: "Continue with Acme SSO" })).toHaveAttribute(
"href",
"/api/auth/oidc/start?callbackUrl=%2Fw%2Finfra"
);
});

test("renders only the SSO button when local login is disabled", async ({ page, baseURL }) => {
await stubAuthConfig(page, {
local: false,
oidc: { enabled: true, label: "Continue with Company SSO" },
});

await gotoLogin(page, "/login?callbackUrl=%2Fadmin", baseURL);

await expect(page.getByText("Continue with single sign-on to access your workspace.")).toBeVisible();
await expect(page.getByText("Enter your credentials to access your workspace.")).toHaveCount(0);
await expect(page.getByRole("link", { name: "Continue with Company SSO" })).toBeVisible();
await expect(page.getByLabel("Username")).toHaveCount(0);
await expect(page.getByLabel("Password")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Sign in" })).toHaveCount(0);
await expect(page.getByRole("separator", { name: "or" })).toHaveCount(0);
});

test("uses full-page SSO navigation with the current callbackUrl", async ({ page, baseURL }) => {
await stubAuthConfig(page, {
local: false,
oidc: { enabled: true, label: "Continue with SSO" },
});
await page.route("**/api/auth/oidc/start**", async (route) => {
await route.fulfill({
status: 204,
});
});

await gotoLogin(page, "/login?callbackUrl=%2Fw%2Finfra%3Ftab%3Dfiles", baseURL);

const requestPromise = page.waitForRequest("**/api/auth/oidc/start?callbackUrl=%2Fw%2Finfra%3Ftab%3Dfiles");
await page.getByRole("link", { name: "Continue with SSO" }).click();

expect((await requestPromise).url()).toContain("/api/auth/oidc/start?callbackUrl=%2Fw%2Finfra%3Ftab%3Dfiles");
});

test("renders safe OIDC error copy without raw error detail", async ({ page, baseURL }) => {
await stubAuthConfig(page, {
local: false,
oidc: { enabled: true, label: "Continue with SSO" },
});

await gotoLogin(page, "/login?error=oidc_failed&callbackUrl=%2F", baseURL);

const loginCard = page.locator(".relative.z-10");
await expect(page.getByText("Single sign-on could not be completed. Try again or use another sign-in method.")).toBeVisible();
await expect(loginCard).not.toContainText("oidc_failed");
await expect(loginCard).not.toContainText("issuer");
});

test("falls back to local login when auth config fails", async ({ page, baseURL }) => {
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "Config unavailable" }),
});
});

await gotoLogin(page, "/login?callbackUrl=%2F", baseURL);

await expect(page.getByText("Could not load sign-in options. Local sign-in is still available.")).toBeVisible();
await expect(page.getByLabel("Username")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
});
18 changes: 18 additions & 0 deletions packages/web/src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, ApiError } from "./api";

describe("api.getAuthConfig", () => {
it("loads the auth configuration with credentials", async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
local: true,
oidc: { enabled: true, label: "Continue with SSO" },
})));
vi.stubGlobal("fetch", fetchMock);

await expect(api.getAuthConfig()).resolves.toEqual({
local: true,
oidc: { enabled: true, label: "Continue with SSO" },
});
expect(fetchMock).toHaveBeenCalledWith("/api/auth/config", expect.objectContaining({
credentials: "include",
}));
});
});

class FakeUploadTarget {
private progressListener?: (event: ProgressEvent) => void;

Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
Workspace,
WorkspaceWithBundleCount,
} from "@evidence-browser/shared/api/types";
import type { BundleShareTokenPublic } from "@/lib/types";
import type { AuthConfig, BundleShareTokenPublic } from "@/lib/types";

export class ApiError extends Error {
constructor(public status: number, message: string) {
Expand Down Expand Up @@ -94,6 +94,7 @@ export function uploadBundleWithProgress(

export const api = {
me: () => apiFetch<{ user: AuthUser }>("/api/auth/me"),
getAuthConfig: () => apiFetch<AuthConfig>("/api/auth/config"),
login: (username: string, password: string) =>
apiFetch<{ user: AuthUser }>("/api/auth/login", {
method: "POST",
Expand Down
8 changes: 8 additions & 0 deletions packages/web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export type {
WorkspaceWithBundleCount,
} from "@evidence-browser/shared/api/types";

export interface AuthConfig {
local: boolean;
oidc: {
enabled: boolean;
label: string;
};
}

export interface BundleShareTokenPublic {
id: string;
bundle_id: string;
Expand Down
21 changes: 21 additions & 0 deletions packages/web/src/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
CheckSetup,
DefaultNotFoundComponent,
DefaultRouterErrorComponent,
getOidcLoginErrorMessage,
getOidcStartHref,
SetupPage,
WorkspacePageContent,
} from "./router";
Expand Down Expand Up @@ -82,6 +84,25 @@ function renderWithQueryClient(ui: React.ReactElement) {
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}

describe("login helpers", () => {
it("maps supported OIDC errors to safe user-facing messages", () => {
expect(getOidcLoginErrorMessage("oidc_failed")).toBe(
"Single sign-on could not be completed. Try again or use another sign-in method."
);
expect(getOidcLoginErrorMessage("oidc_forbidden")).toBe(
"Your single sign-on account is not permitted to access this workspace."
);
expect(getOidcLoginErrorMessage("oidc_failed: issuer secret")).toBeNull();
});

it("builds a plain OIDC start URL with the callbackUrl encoded once", () => {
expect(getOidcStartHref("/w/infra?tab=files")).toBe(
"/api/auth/oidc/start?callbackUrl=%2Fw%2Finfra%3Ftab%3Dfiles"
);
expect(getOidcStartHref()).toBe("/api/auth/oidc/start?callbackUrl=%2F");
});
});

describe("bundle query states", () => {
beforeEach(() => {
vi.restoreAllMocks();
Expand Down
Loading
Loading