Move company invites into a tab of the Members page (#12289)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Company settings include member management and invites, which are one workflow: invite someone, watch the join request, manage the membership > - Today invites sit on a standalone settings page, costing a sidebar entry and forcing people to bounce between two pages for one task > - Merging invites into the Members page keeps the workflow in one place without losing any capability > - This pull request turns the Members page into a Members/Invites tab bar, redirects the old URL, and keeps both operator-visibility keys meaningful > - The benefit is a tighter settings surface for every deployment, with `company.invites` now hiding just the tab while `company.members` hides the whole page ## Linked Issues or Issue Description No public issue exists; describing the issue inline per the enhancement template: **What existing behavior does this improve?** Company invites live on a standalone settings page separate from the Members page, even though inviting someone and managing the resulting membership are one workflow. **Subsystem affected** Web UI (company settings) **Current behavior** `/company/settings/invites` is its own page with its own sidebar entry (gated by `company.invites`); the Members page (`company.members`) is separate. The sidebar company menu's "Invite people" shortcut links to the invites page and is not gated by the hidden-settings mechanism at all. **Proposed behavior** The Members page carries a Members/Invites tab bar, addressable via `?tab=invites`. The invite creation flow, latest-link panel, and invite history move unchanged into an `InvitesSection` component. The old URL redirects to the tab (still behind its `HiddenSettingsPageGate`). `company.members` hides the whole page; `company.invites` hides just the Invites tab, and the tab bar collapses when only Members remains. The "Invite people" shortcut points at the tab and hides when either surface is operator-hidden. **Reason and benefit** One settings surface for one workflow: fewer sidebar entries and no bouncing between two pages to invite someone and then manage the membership. Operators keep the same visibility controls, with a sharper meaning for each key. **Breaking changes** None. Bookmarks to the old invites URL redirect to the tab, invite and membership APIs are unchanged, and both hidden-settings keys keep working. ## What Changed - `ui/src/pages/CompanyInvites.tsx` → `ui/src/components/access/InvitesSection.tsx` (page chrome and breadcrumbs dropped; content unchanged), with its tests moved alongside. - `ui/src/pages/CompanyAccess.tsx`: Members/Invites tabs via the shared `PageTabBar`, `?tab=invites` search param, `company.invites` gating with tab snap-back; legacy "Open Invites" button retargeted. - `ui/src/App.tsx`: the invites route becomes a gated redirect to `/company/settings/members?tab=invites`. - `CompanySettingsSidebar` / `CompanySettingsNav`: standalone Invites entry/tab removed; the old path maps to the members tab. - `ui/src/components/SidebarCompanyMenu.tsx`: "Invite people" now links to the tab and hides when `company.members` or `company.invites` is hidden (closes an existing gating gap). - Tests: moved invites tests, new tab coverage (default tab, deep link, operator-hidden tab skips the invites fetch), sidebar/nav suites updated. ## Verification - `npx vitest run ui/src/components/CompanySettingsSidebar.test.tsx ui/src/components/access/CompanySettingsNav.test.tsx ui/src/pages/CompanyAccess.test.tsx ui/src/components/access/InvitesSection.test.tsx` — 24 tests passing. - `pnpm --filter @paperclipai/ui typecheck` — clean. ## Risks - Low. Pure UI restructure: invite APIs, membership APIs, and the hidden-settings registry keys are unchanged. Bookmarks to the old invites URL redirect (and stay gated). The `company.invites` key's meaning narrows from "hide the page" to "hide the tab", which is the same effective surface. ## Model Used Claude (Anthropic), model id `claude-fable-5`, extended thinking, agentic tool use via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
7551b63ef2
commit
a4f1b3c533
|
|
@ -68,7 +68,6 @@ import { AppDetail } from "./pages/apps/AppDetail";
|
|||
import { AppNotConnected } from "./pages/apps/AppNotConnected";
|
||||
import { GatewaysList } from "./pages/apps/gateways/GatewaysList";
|
||||
import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { SkillStudio } from "./pages/SkillStudio";
|
||||
import { Secrets } from "./pages/Secrets";
|
||||
|
|
@ -122,8 +121,13 @@ function boardRoutes() {
|
|||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
</Route>
|
||||
{/* Invites moved into the Members page; the old URL redirects (and stays
|
||||
gated so a hidden Invites surface never round-trips through it). */}
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.invites" />}>
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route
|
||||
path="company/settings/invites"
|
||||
element={<Navigate to="/company/settings/members?tab=invites" replace />}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.export" />}>
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -145,7 +145,6 @@ describe("CompanySettingsSidebar", () => {
|
|||
expect(container.textContent).toContain("Export");
|
||||
expect(container.textContent).toContain("Import");
|
||||
expect(container.textContent).toContain("Members");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).not.toContain("Tools & Access");
|
||||
|
|
@ -191,13 +190,6 @@ describe("CompanySettingsSidebar", () => {
|
|||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/invites",
|
||||
label: "Invites",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/secrets",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
Download,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
MailPlus,
|
||||
MonitorCog,
|
||||
Puzzle,
|
||||
Shield,
|
||||
|
|
@ -128,9 +127,6 @@ export function CompanySettingsSidebar() {
|
|||
end
|
||||
/>
|
||||
))}
|
||||
{showPage("company.invites") && (
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
)}
|
||||
{showPage("company.secrets") && (
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -467,7 +467,9 @@ describe("Layout", () => {
|
|||
expect(selectorText).toContain("export");
|
||||
expect(selectorText).toContain("import");
|
||||
expect(selectorText).toContain("members");
|
||||
expect(selectorText).toContain("invites");
|
||||
// Invites live on a tab of the Members page now, so the selector no
|
||||
// longer carries a standalone entry for them.
|
||||
expect(selectorText).not.toContain("invites");
|
||||
expect(selectorText).toContain("secrets");
|
||||
expect(selectorText).toContain("profile");
|
||||
expect(selectorText).toContain("environments");
|
||||
|
|
|
|||
|
|
@ -316,6 +316,9 @@ describe("SidebarCompanyMenu", () => {
|
|||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// The invite shortcut waits for the health response before it shows, so
|
||||
// resolve it here the way CloudAccessGate does in the app.
|
||||
queryClient.setQueryData(queryKeys.health, { status: "ok" });
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
|
|
@ -366,6 +369,57 @@ describe("SidebarCompanyMenu", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the invite shortcut out of the menu until hidden settings resolve", async () => {
|
||||
// No health data in the cache: the hidden-settings set is unknown, so the
|
||||
// shortcut must not flash in and then disappear once the response lands.
|
||||
const { root } = renderMenu();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the invite shortcut when the operator hides the invites surface", async () => {
|
||||
const { root } = renderMenu({
|
||||
health: { status: "ok", hiddenSettings: ["company.invites"] },
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the invite shortcut when the operator hides the members page", async () => {
|
||||
const { root } = renderMenu({
|
||||
health: { status: "ok", hiddenSettings: ["company.members"] },
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).not.toContain("Invite people");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles company order editing without selecting a company", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { hidesCompanyPage, type Company } from "@paperclipai/shared";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { cloudApi, type CloudStackSummary } from "@/api/cloud";
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
||||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
|
|
@ -236,6 +237,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
// exactly one company, and switching means leaving this tenant host entirely.
|
||||
const cloud = useCloudInstance();
|
||||
const isCloud = Boolean(cloud);
|
||||
// Invites now live on the Members page; hide the shortcut when the hosting
|
||||
// operator hides either surface. Until the health response resolves, the
|
||||
// hidden set is unknown — keep the shortcut out rather than flash it.
|
||||
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
|
||||
const showInvitePeople =
|
||||
hiddenSettingsLoaded &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.members") &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.invites");
|
||||
const cloudBaseUrl = cloud?.cloudBaseUrl ?? null;
|
||||
const stacksQuery = useQuery({
|
||||
queryKey: queryKeys.cloud.stacks,
|
||||
|
|
@ -480,23 +489,25 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings/invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{showInvitePeople ? (
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings/members?tab=invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{session?.session ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ vi.mock("@/components/PageTabBar", () => ({
|
|||
return (
|
||||
<div>
|
||||
<div data-testid="active-tab">{props.value}</div>
|
||||
<button type="button" onClick={() => props.onValueChange?.("invites")}>
|
||||
<button type="button" onClick={() => props.onValueChange?.("secrets")}>
|
||||
switch-tab
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -77,7 +77,7 @@ describe("CompanySettingsNav", () => {
|
|||
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
|
||||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/profile")).toBe("instance-profile");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("general");
|
||||
|
|
@ -124,7 +124,6 @@ describe("CompanySettingsNav", () => {
|
|||
{ value: "export", label: "Export" },
|
||||
{ value: "import", label: "Import" },
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
{ value: "instance-profile", label: "Profile" },
|
||||
{ value: "instance-environments", label: "Environments" },
|
||||
|
|
@ -143,7 +142,7 @@ describe("CompanySettingsNav", () => {
|
|||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/company/settings/invites");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/company/settings/secrets");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -166,7 +165,6 @@ describe("CompanySettingsNav", () => {
|
|||
"export",
|
||||
"import",
|
||||
"members",
|
||||
"invites",
|
||||
"secrets",
|
||||
"instance-profile",
|
||||
"instance-environments",
|
||||
|
|
@ -195,7 +193,6 @@ describe("CompanySettingsNav", () => {
|
|||
expect(renderedValues).not.toContain("secrets");
|
||||
expect(renderedValues).toContain("export");
|
||||
expect(renderedValues).toContain("members");
|
||||
expect(renderedValues).toContain("invites");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ const items = [
|
|||
{ value: "export", label: "Export", href: "/company/export" },
|
||||
{ value: "import", label: "Import", href: "/company/import" },
|
||||
{ value: "members", label: "Members", href: "/company/settings/members" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
|
||||
{ value: "instance-profile", label: "Profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` },
|
||||
{ value: "instance-environments", label: "Environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` },
|
||||
|
|
@ -27,7 +26,6 @@ const hiddenSettingKeyByTab: Partial<Record<CompanySettingsTab, string>> = {
|
|||
export: "company.export",
|
||||
import: "company.import",
|
||||
members: "company.members",
|
||||
invites: "company.invites",
|
||||
secrets: "company.secrets",
|
||||
"instance-profile": "instance.profile",
|
||||
"instance-environments": "instance.environments",
|
||||
|
|
@ -83,7 +81,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
|||
}
|
||||
|
||||
if (pathname.includes("/company/settings/invites")) {
|
||||
return "invites";
|
||||
// Invites live on the Members page now; the old URL redirects there.
|
||||
return "members";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/secrets")) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanyInvites } from "./CompanyInvites";
|
||||
import { InvitesSection } from "./InvitesSection";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const listInvitesMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -49,7 +49,7 @@ async function flushReact() {
|
|||
});
|
||||
}
|
||||
|
||||
describe("CompanyInvites", () => {
|
||||
describe("InvitesSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
const inviteHistory = Array.from({ length: 25 }, (_, index) => {
|
||||
const inviteNumber = 25 - index;
|
||||
|
|
@ -128,7 +128,7 @@ describe("CompanyInvites", () => {
|
|||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyInvites />
|
||||
<InvitesSection />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
|
@ -136,7 +136,6 @@ describe("CompanyInvites", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Organization Invites");
|
||||
expect(container.textContent).toContain("Invite a person");
|
||||
expect(container.textContent).not.toContain("Invite an agent");
|
||||
expect(container.textContent).not.toContain("Generate agent onboarding prompt");
|
||||
|
|
@ -262,7 +261,7 @@ describe("CompanyInvites", () => {
|
|||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyInvites />
|
||||
<InvitesSection />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
|
@ -305,7 +304,7 @@ describe("CompanyInvites", () => {
|
|||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyInvites />
|
||||
<InvitesSection />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, MailPlus } from "lucide-react";
|
||||
import { Check, Copy, ExternalLink } from "lucide-react";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Link } from "@/lib/router";
|
||||
|
|
@ -46,9 +45,9 @@ function isInviteHistoryRow(value: unknown): value is Awaited<ReturnType<typeof
|
|||
return "id" in value && "state" in value && "createdAt" in value;
|
||||
}
|
||||
|
||||
export function CompanyInvites() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
/** The Invites tab of the Members page (extracted from the former standalone Invites page). */
|
||||
export function InvitesSection() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator");
|
||||
|
|
@ -88,14 +87,6 @@ export function CompanyInvites() {
|
|||
return copyText(url, "The invite URL is selected. Copy it manually from the field.", selectLatestInviteUrl);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Invites" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", INVITE_HISTORY_PAGE_SIZE);
|
||||
const invitesQuery = useInfiniteQuery({
|
||||
queryKey: inviteHistoryQueryKey,
|
||||
|
|
@ -179,15 +170,10 @@ export function CompanyInvites() {
|
|||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MailPlus className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Organization Invites</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Invite people to request access to this organization. New invite links are copied to your clipboard when they are generated.
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Invite people to request access to this organization. New invite links are copied to your clipboard when they are
|
||||
generated.
|
||||
</p>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-border p-5">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -14,6 +14,8 @@ const listAgentsMock = vi.hoisted(() => vi.fn());
|
|||
const listIssuesMock = vi.hoisted(() => vi.fn());
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const listInvitesMock = vi.hoisted(() => vi.fn());
|
||||
const mockSearchParamsState = vi.hoisted(() => ({ current: new URLSearchParams() }));
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
|
|
@ -27,6 +29,9 @@ vi.mock("@/api/access", () => ({
|
|||
archiveMemberMock(companyId, memberId, input),
|
||||
approveJoinRequest: vi.fn(),
|
||||
rejectJoinRequest: vi.fn(),
|
||||
listInvites: (companyId: string, options: unknown) => listInvitesMock(companyId, options),
|
||||
createCompanyInvite: vi.fn(),
|
||||
revokeInvite: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -48,12 +53,31 @@ vi.mock("@/lib/router", () => ({
|
|||
mockNavigate(to, replace);
|
||||
return <div data-testid="navigate">{to}</div>;
|
||||
},
|
||||
useSearchParams: () => [
|
||||
mockSearchParamsState.current,
|
||||
(
|
||||
updater:
|
||||
| URLSearchParams
|
||||
| ((prev: URLSearchParams) => URLSearchParams),
|
||||
) => {
|
||||
mockSearchParamsState.current =
|
||||
typeof updater === "function"
|
||||
? updater(mockSearchParamsState.current)
|
||||
: new URLSearchParams(updater);
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
|
|
@ -85,6 +109,8 @@ describe("CompanyAccess", () => {
|
|||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockSearchParamsState.current = new URLSearchParams();
|
||||
listInvitesMock.mockResolvedValue({ invites: [], nextOffset: null });
|
||||
listMembersMock.mockResolvedValue({
|
||||
members: [
|
||||
{
|
||||
|
|
@ -468,3 +494,84 @@ describe("CompanyAccess", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyAccess invites tab", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockSearchParamsState.current = new URLSearchParams();
|
||||
listInvitesMock.mockResolvedValue({ invites: [], nextOffset: null });
|
||||
listMembersMock.mockResolvedValue({
|
||||
members: [],
|
||||
access: { currentUserRole: "owner", canApproveJoinRequests: false },
|
||||
});
|
||||
listAgentsMock.mockResolvedValue([]);
|
||||
listJoinRequestsMock.mockResolvedValue([]);
|
||||
mockUsePluginSlots.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderPage(queryClient?: QueryClient) {
|
||||
const root = createRoot(container);
|
||||
const client =
|
||||
queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompanyAccess />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
it("shows Members and Invites tabs with Members active by default", async () => {
|
||||
const root = await renderPage();
|
||||
|
||||
const tabLabels = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent);
|
||||
expect(tabLabels).toEqual(["Members", "Invites"]);
|
||||
expect(container.textContent).toContain("Organization Members");
|
||||
expect(container.textContent).not.toContain("Invite a person");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the Invites tab from a ?tab=invites deep link", async () => {
|
||||
mockSearchParamsState.current = new URLSearchParams("tab=invites");
|
||||
const root = await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Invite a person");
|
||||
expect(container.textContent).toContain("Invite history");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the Invites tab when the operator hides company.invites", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(["health"], { hiddenSettings: ["company.invites"] } as never);
|
||||
mockSearchParamsState.current = new URLSearchParams("tab=invites");
|
||||
const root = await renderPage(client);
|
||||
|
||||
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(0);
|
||||
expect(container.textContent).not.toContain("Invite a person");
|
||||
expect(container.textContent).toContain("Organization Members");
|
||||
expect(listInvitesMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
hidesCompanyPage,
|
||||
type Agent,
|
||||
} from "@paperclipai/shared";
|
||||
import { Shield, ShieldCheck, Trash2 } from "lucide-react";
|
||||
|
|
@ -23,9 +24,13 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Link, Navigate } from "@/lib/router";
|
||||
import { Link, Navigate, useSearchParams } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { InvitesSection } from "@/components/access/InvitesSection";
|
||||
|
||||
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
|
||||
type EditableMemberStatus = "pending" | "active" | "suspended";
|
||||
|
|
@ -35,6 +40,27 @@ export function CompanyAccess() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Invites render as a tab of this page; `company.invites` hides just that
|
||||
// tab while `company.members` (the route gate) hides the whole page.
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const hideInvitesTab = hidesCompanyPage(hiddenSettings, "company.invites");
|
||||
const requestedTab = searchParams.get("tab") === "invites" ? "invites" : "members";
|
||||
const activeTab = hideInvitesTab ? "members" : requestedTab;
|
||||
const handleTabChange = (value: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (value === "invites") {
|
||||
next.set("tab", "invites");
|
||||
} else {
|
||||
next.delete("tab");
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
|
||||
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
|
||||
|
|
@ -241,6 +267,20 @@ export function CompanyAccess() {
|
|||
<h1 className="text-lg font-semibold">Organization Members</h1>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex flex-col gap-4">
|
||||
{!hideInvitesTab && (
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
/>
|
||||
)}
|
||||
<TabsContent value="members" className="space-y-8">
|
||||
|
||||
{access && !access.currentUserRole && (
|
||||
<div className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
|
||||
This account can manage access here through instance-admin privileges, but it does not currently hold an active organization membership.
|
||||
|
|
@ -519,6 +559,13 @@ export function CompanyAccess() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
{!hideInvitesTab && (
|
||||
<TabsContent value="invites">
|
||||
<InvitesSection />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -575,7 +622,7 @@ export function CompanyAccessLegacyRoute() {
|
|||
<Link to="/company/settings/members">Open Members</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/company/settings/invites">Open Invites</Link>
|
||||
<Link to="/company/settings/members?tab=invites">Open Invites</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue