Skip to content
Open
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
9 changes: 6 additions & 3 deletions client/e2e/fixtures/api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* shapes mirror `client/src/auth/AuthContext.tsx` (`User`, `LoginResponse`).
*/

import { test as base, expect, type Page } from "@playwright/test";
import { test as base, expect, type Page, type Route } from "@playwright/test";

export interface MockUser {
email: string;
Expand Down Expand Up @@ -71,7 +71,7 @@ export function createApiMock(page: Page): ApiMock {
},

async mockMe({ user = DEFAULT_TEST_USER, status = 200 } = {}) {
await page.route("**/auth/me", async (route) => {
const fulfillUser = async (route: Route) => {
if (status === 200) {
await route.fulfill({
status,
Expand All @@ -85,7 +85,10 @@ export function createApiMock(page: Page): ApiMock {
contentType: "application/json",
body: JSON.stringify({ detail: "Unauthorized" }),
});
});
};

await page.route("**/auth/email/me", fulfillUser);
await page.route("**/auth/me", fulfillUser);
},

async mockUnauthorized(urlPattern) {
Expand Down
12 changes: 12 additions & 0 deletions client/src/components/icons/GitHubIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function GitHubIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 .5C5.65.5.5 5.8.5 12.33c0 5.23 3.3 9.67 7.88 11.24.58.11.79-.26.79-.57 0-.28-.01-1.21-.02-2.19-3.21.72-3.89-1.4-3.89-1.4-.52-1.36-1.28-1.72-1.28-1.72-1.05-.74.08-.72.08-.72 1.16.09 1.77 1.23 1.77 1.23 1.03 1.82 2.7 1.29 3.36.98.1-.77.4-1.29.73-1.59-2.56-.3-5.25-1.32-5.25-5.9 0-1.3.45-2.36 1.2-3.19-.12-.31-.52-1.57.11-3.28 0 0 .98-.32 3.2 1.22a10.8 10.8 0 0 1 5.82 0c2.22-1.54 3.2-1.22 3.2-1.22.63 1.71.23 2.97.11 3.28.75.83 1.2 1.89 1.2 3.19 0 4.59-2.69 5.59-5.26 5.89.41.37.78 1.08.78 2.19 0 1.58-.01 2.85-.01 3.24 0 .31.21.69.8.57 4.57-1.57 7.87-6.01 7.87-11.24C23.5 5.8 18.35.5 12 .5Z" />
</svg>
);
}
14 changes: 8 additions & 6 deletions client/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { Header } from "./Header";

export function AppShell({ children }: { children: ReactNode }) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<Header />
<div className="flex flex-1 flex-col gap-4 p-6">{children}</div>
</SidebarInset>
<SidebarProvider className="flex min-h-svh flex-col">
<Header />
<div className="flex min-h-0 flex-1">
<AppSidebar />
<SidebarInset className="min-h-0">
<div className="flex flex-1 flex-col gap-4 p-6">{children}</div>
</SidebarInset>
</div>
</SidebarProvider>
);
}
56 changes: 39 additions & 17 deletions client/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
import { useIntl } from "react-intl";
import { useAuth } from "../../auth/useAuth";
import { LanguageSwitcher } from "../ui/LanguageSwitcher";
import { ThemeToggle } from "../ui/ThemeToggle";
import { BookOpen } from "lucide-react";
import appPackage from "../../../package.json";
import { GitHubIcon } from "../icons/GitHubIcon";
import { MainNavIcon } from "../icons/MainNavIcon";
import { SidebarTrigger } from "../ui/sidebar";
import { HeaderProfileMenu } from "./HeaderProfileMenu";
import { HeaderQuickNav } from "./HeaderQuickNav";
import { TeamSwitcher } from "./TeamSwitcher";

export function Header() {
const intl = useIntl();
const { user, logout } = useAuth();

return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4">
<SidebarTrigger />
<div className="flex items-center gap-3">
<LanguageSwitcher />
<ThemeToggle />
{user && <span className="text-sm text-muted-foreground">{user.email}</span>}
<button
onClick={logout}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center shrink-0">
<MainNavIcon className="h-6 w-6" />
</div>
<SidebarTrigger />
<TeamSwitcher />
</div>
<div className="flex items-center gap-2">
<HeaderQuickNav />
<span className="hidden text-sm font-medium text-muted-foreground sm:inline">
v{appPackage.version}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

this version may not match the actual app release version, is there a better source of truth for the version displayed here?

</span>
<a
href="https://github.com/IBM/mcp-context-forge"
target="_blank"
rel="noopener noreferrer"
className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="GitHub"
title="GitHub"
>
<GitHubIcon className="size-4" aria-hidden="true" />
</a>
<a
href="https://ibm.github.io/mcp-context-forge/latest/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Documentation"
title="Documentation"
>
{intl.formatMessage({ id: "auth.logout" })}
</button>
<BookOpen className="size-4" aria-hidden="true" />
</a>
<HeaderProfileMenu />
</div>
</header>
);
Expand Down
110 changes: 110 additions & 0 deletions client/src/components/layout/HeaderProfileMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { ThemeProvider } from "@/hooks/useTheme";
import { I18nProvider } from "@/i18n";
import { render, screen } from "@testing-library/react";
import { HeaderProfileMenu } from "./HeaderProfileMenu";

const mockLogout = vi.fn();
const mockNavigate = vi.fn();

vi.mock("@/auth/useAuth", () => ({
useAuth: () => ({
user: {
email: "bobo@cf.com",
full_name: "Bobo Example",
is_admin: false,
is_active: true,
auth_provider: "local",
email_verified: true,
password_change_required: false,
},
logout: mockLogout,
}),
}));

vi.mock("@/router", async () => {
const actual = await vi.importActual<typeof import("@/router")>("@/router");
return {
...actual,
useRouter: () => ({
path: "/app/",
params: {},
navigate: mockNavigate,
}),
};
});

describe("HeaderProfileMenu", () => {
beforeEach(() => {
mockLogout.mockReset();
mockNavigate.mockReset();
localStorage.clear();
});

function renderMenu() {
return render(
<I18nProvider>
<ThemeProvider>
<HeaderProfileMenu />
</ThemeProvider>
</I18nProvider>,
);
}

it("renders the profile trigger", () => {
renderMenu();
expect(screen.getByRole("button", { name: "Bobo Example" })).toBeInTheDocument();
});

it("navigates to settings from the dropdown", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByText("Settings"));

expect(mockNavigate).toHaveBeenCalledWith("/app/settings");
});

it("logs out from the dropdown", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByText("Sign Out"));

expect(mockLogout).toHaveBeenCalled();
});

it("updates the saved theme preference", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByRole("button", { name: "Dark mode" }));

expect(localStorage.getItem("theme-preference")).toBe("dark");
});

it("supports switching back to light mode", async () => {
const user = userEvent.setup();
localStorage.setItem("theme-preference", "dark");
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByRole("button", { name: "Light mode" }));

expect(localStorage.getItem("theme-preference")).toBe("light");
});

it("supports switching to system theme", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByRole("button", { name: "System theme" }));

expect(localStorage.getItem("theme-preference")).toBe("system");
});
});
91 changes: 91 additions & 0 deletions client/src/components/layout/HeaderProfileMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ChevronDown, LogOut, Monitor, Moon, Settings2, Sun } from "lucide-react";
import { useIntl } from "react-intl";
import { useAuth } from "../../auth/useAuth";
import { useTheme } from "../../hooks/useTheme";
import { useRouter } from "../../router";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";

export function HeaderProfileMenu() {
const intl = useIntl();
const { user, logout } = useAuth();
const { navigate } = useRouter();
const { theme, setTheme } = useTheme();

if (!user) return null;

const displayName = user.full_name || user.email;

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* TODO: User photo/avatar data does not appear to be available in the current frontend. Using fallback button for now. */}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

TODO: source for photo or avatar?

<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 rounded-lg px-1.5 hover:bg-muted"
aria-label={displayName}
>
<span className="block size-6 overflow-hidden rounded-md bg-muted" aria-hidden="true" />
<ChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 rounded-xl p-2">
<DropdownMenuLabel className="px-3 py-2 text-sm font-normal text-muted-foreground">
{user.email}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex items-center justify-between gap-3 px-3 py-2">
<span className="text-sm">{intl.formatMessage({ id: "common.theme" })}</span>
<div className="flex items-center gap-1 rounded-full bg-muted p-1">
<button
type="button"
onClick={() => setTheme("light")}
className={`rounded-full p-1.5 transition-colors ${theme === "light" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
aria-label={intl.formatMessage({ id: "common.theme.light" })}
title={intl.formatMessage({ id: "common.theme.light" })}
>
<Sun className="size-4" />
</button>
<button
type="button"
onClick={() => setTheme("dark")}
className={`rounded-full p-1.5 transition-colors ${theme === "dark" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
aria-label={intl.formatMessage({ id: "common.theme.dark" })}
title={intl.formatMessage({ id: "common.theme.dark" })}
>
<Moon className="size-4" />
</button>
<button
type="button"
onClick={() => setTheme("system")}
className={`rounded-full p-1.5 transition-colors ${theme === "system" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
aria-label={intl.formatMessage({ id: "common.theme.system" })}
title={intl.formatMessage({ id: "common.theme.system" })}
>
<Monitor className="size-4" />
</button>
</div>
</div>
<DropdownMenuItem
onClick={() => navigate("/app/settings")}
className="gap-2 rounded-lg px-3 py-2"
>
<Settings2 className="size-4" aria-hidden="true" />
{intl.formatMessage({ id: "navigation.settings" })}
</DropdownMenuItem>
<DropdownMenuItem onClick={logout} className="gap-2 rounded-lg px-3 py-2">
<LogOut className="size-4" aria-hidden="true" />
{intl.formatMessage({ id: "auth.logout" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
Loading
โšก