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
10 changes: 7 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 @@ -63,7 +63,7 @@ export function createApiMock(page: Page): ApiMock {
},

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

await page.route("**/app/auth/me", fulfillUser);
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 />
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

TeamSwitcher should be part of SidebarTrigger

</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.

is this the correct source for the version number?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please see my comment below.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The version should be read from the API endpoint /version?partial=false, where the response has version under app
e.g.

{
  "timestamp": "2026-05-14T13:05:13.969571Z",
  "host": "Mareks-MacBook-Pro.local",
  "uptime_seconds": 242,
  "app": {
    "name": "ContextForge",
    "version": "1.0.0",
    "mcp_protocol_version": "2025-11-25"
  },
}

</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: "[email protected]",
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.

is there a source for this avatar?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a very good approach (to have TODO)! We do not have a user avatar at the moment.

<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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please replace it with the Shadcn Button component
import { Button } from "@/components/ui/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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please replace it with the Shadcn Button component
import { Button } from "@/components/ui/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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please replace it with the Shadcn Button component
import { Button } from "@/components/ui/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
Loading