diff --git a/server/src/__tests__/auth-session-route.test.ts b/server/src/__tests__/auth-session-route.test.ts new file mode 100644 index 00000000000..91eeb8a07f2 --- /dev/null +++ b/server/src/__tests__/auth-session-route.test.ts @@ -0,0 +1,61 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { actorMiddleware } from "../middleware/auth.js"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createDb() { + return { + select: vi + .fn() + .mockImplementationOnce(() => createSelectChain([])) + .mockImplementationOnce(() => createSelectChain([])), + } as any; +} + +describe("actorMiddleware authenticated session profile", () => { + it("preserves the signed-in user name and email on the board actor", async () => { + const app = express(); + app.use( + actorMiddleware(createDb(), { + deploymentMode: "authenticated", + resolveSession: async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "User One", + email: "user@example.com", + }, + }), + }), + ); + app.get("/actor", (req, res) => { + res.json(req.actor); + }); + + const res = await request(app).get("/actor"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "board", + userId: "user-1", + userName: "User One", + userEmail: "user@example.com", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + }); +}); diff --git a/server/src/__tests__/authz-company-access.test.ts b/server/src/__tests__/authz-company-access.test.ts index 7b30399e187..37850c306ee 100644 --- a/server/src/__tests__/authz-company-access.test.ts +++ b/server/src/__tests__/authz-company-access.test.ts @@ -45,4 +45,34 @@ describe("assertCompanyAccess", () => { expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only"); }); + + it("rejects signed-in instance admins without explicit company access", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + memberships: [], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have access to this company"); + }); + + it("allows local trusted board access without explicit membership", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); }); diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts index 86d9441cd55..6d8de8b73f8 100644 --- a/server/src/__tests__/company-branding-route.test.ts +++ b/server/src/__tests__/company-branding-route.test.ts @@ -194,3 +194,52 @@ describe("PATCH /api/companies/:companyId/branding", () => { expect(mockCompanyService.update).not.toHaveBeenCalled(); }); }); + +describe("GET /api/companies", () => { + beforeEach(() => { + mockCompanyService.list.mockReset(); + mockCompanyService.stats.mockReset(); + }); + + it("filters list results to explicit memberships for signed-in instance admins", async () => { + mockCompanyService.list.mockResolvedValue([ + { ...createCompany(), id: "company-1", issuePrefix: "PAP" }, + { ...createCompany(), id: "company-2", issuePrefix: "OPS" }, + ]); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }], + }); + + const res = await request(app).get("/api/companies"); + + expect(res.status).toBe(200); + expect(res.body.map((company: { id: string }) => company.id)).toEqual(["company-1"]); + }); + + it("filters stats results to explicit memberships for signed-in instance admins", async () => { + mockCompanyService.stats.mockResolvedValue({ + "company-1": { activeAgents: 2 }, + "company-2": { activeAgents: 1 }, + }); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }], + }); + + const res = await request(app).get("/api/companies/stats"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + "company-1": { activeAgents: 2 }, + }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 5535ab3df24..2ef02d175ac 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -117,8 +117,8 @@ export async function createApp( }, user: { id: req.actor.userId, - email: null, - name: req.actor.source === "local_implicit" ? "Local Board" : null, + email: req.actor.userEmail ?? null, + name: req.actor.userName ?? null, }, }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index ce1360b8ce7..2438aee3069 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -23,7 +23,14 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa return async (req, _res, next) => { req.actor = opts.deploymentMode === "local_trusted" - ? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" } + ? { + type: "board", + userId: "local-board", + userName: "Local Board", + userEmail: null, + isInstanceAdmin: true, + source: "local_implicit", + } : { type: "none", source: "none" }; const runIdHeader = req.header("x-paperclip-run-id"); @@ -66,6 +73,8 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId, + userName: session.user.name ?? null, + userEmail: session.user.email ?? null, companyIds: memberships.map((row) => row.companyId), memberships, isInstanceAdmin: Boolean(roleRow), @@ -95,6 +104,8 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId: boardKey.userId, + userName: access.user?.name ?? null, + userEmail: access.user?.email ?? null, companyIds: access.companyIds, memberships: access.memberships, isInstanceAdmin: access.isInstanceAdmin, diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 3480da493ce..6bf519d8f33 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -22,7 +22,7 @@ export function assertCompanyAccess(req: Request, companyId: string) { if (req.actor.type === "agent" && req.actor.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } - if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { + if (req.actor.type === "board" && req.actor.source !== "local_implicit") { const allowedCompanies = req.actor.companyIds ?? []; if (!allowedCompanies.includes(companyId)) { throw forbidden("User does not have access to this company"); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 5112baac8c5..1a449066d53 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -60,7 +60,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.get("/", async (req, res) => { assertBoard(req); const result = await svc.list(); - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + if (req.actor.source === "local_implicit") { res.json(result); return; } @@ -70,7 +70,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.get("/stats", async (req, res) => { assertBoard(req); - const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin + const allowed = req.actor.source === "local_implicit" ? null : new Set(req.actor.companyIds ?? []); const stats = await svc.stats(); diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 50c2c3cb617..1915e2ffdb1 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -6,6 +6,8 @@ declare global { actor: { type: "board" | "agent" | "none"; userId?: string; + userName?: string | null; + userEmail?: string | null; agentId?: string; companyId?: string; companyIds?: string[]; diff --git a/tests/e2e/multi-user-authenticated.spec.ts b/tests/e2e/multi-user-authenticated.spec.ts index 7055d206b37..2652da389f5 100644 --- a/tests/e2e/multi-user-authenticated.spec.ts +++ b/tests/e2e/multi-user-authenticated.spec.ts @@ -278,6 +278,10 @@ test.describe("Multi-user: authenticated mode", () => { const company = await createCompanyForSession(page, companyName); const companyPrefix = company.issuePrefix ?? company.id; + await page.goto(`${BASE}/${companyPrefix}/dashboard`); + await expect(page.getByTestId("layout-account-menu-trigger")).toContainText(ownerUser.name); + await page.getByTestId("layout-account-menu-trigger").click(); + await expect(page.getByText(ownerUser.email)).toBeVisible(); const inviteUrl = await createAuthenticatedInvite(page, companyPrefix); const invited = await newPage(browser); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8761b71c9b1..03244c7a8e6 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { BookOpen, Moon, Settings, Sun } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { BookOpen, LogOut, Moon, Settings, Sun } from "lucide-react"; import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; @@ -23,6 +23,7 @@ import { useSidebar } from "../context/SidebarContext"; import { useTheme } from "../context/ThemeContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; +import { authApi, type AuthSession } from "../api/auth"; import { healthApi } from "../api/health"; import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { @@ -33,7 +34,16 @@ import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Identity } from "./Identity"; const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath"; @@ -46,7 +56,78 @@ function readRememberedInstanceSettingsPath(): string { } } +function getSessionDisplayName(session: AuthSession) { + return session.user.name?.trim() || session.user.email?.trim() || session.user.id; +} + +function FooterPrimaryAction({ + session, + onSignOut, + signingOut, +}: { + session: AuthSession | null; + onSignOut: () => Promise; + signingOut: boolean; +}) { + if (!session) { + return ( + + + Documentation + + ); + } + + const displayName = getSessionDisplayName(session); + + return ( + + + + + + +
{displayName}
+
+ {session.user.email?.trim() || session.user.id} +
+
+ + + + + Documentation + + + { + void onSignOut(); + }} + > + + {signingOut ? "Signing out..." : "Sign out"} + +
+
+ ); +} + export function Layout() { + const queryClient = useQueryClient(); const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { togglePanelVisible } = usePanel(); @@ -67,6 +148,7 @@ export function Layout() { const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); + const [isSigningOut, setIsSigningOut] = useState(false); const nextTheme = theme === "dark" ? "light" : "dark"; const matchedCompany = useMemo(() => { if (!companyPrefix) return null; @@ -85,6 +167,25 @@ export function Layout() { }, refetchIntervalInBackground: true, }); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + enabled: health?.deploymentMode === "authenticated", + retry: false, + }); + + const handleSignOut = useCallback(async () => { + if (isSigningOut) return; + setIsSigningOut(true); + try { + await authApi.signOut(); + queryClient.setQueryData(queryKeys.auth.session, null); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + navigate("/auth", { replace: true }); + } finally { + setIsSigningOut(false); + } + }, [isSigningOut, navigate, queryClient]); useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; @@ -296,15 +397,7 @@ export function Layout() {
- - - Documentation - + {health?.version && ( @@ -354,15 +447,7 @@ export function Layout() {
- - - Documentation - + {health?.version && (