diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 6a6441e002a..b0c92acb006 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -905,6 +905,7 @@ export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [ "general", "environments", "access", + "members", "invites", "secrets", ] as const; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fa320fe4639..abc17fc2487 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -31,7 +31,7 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; -import { CompanyAccess } from "./pages/CompanyAccess"; +import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { Secrets } from "./pages/Secrets"; @@ -70,7 +70,8 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index adc9fec554e..d10c4e401ea 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -120,7 +120,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); - expect(container.textContent).toContain("Access"); + expect(container.textContent).toContain("Members"); expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Secrets"); expect(sidebarNavItemMock).toHaveBeenCalledWith( @@ -139,8 +139,8 @@ describe("CompanySettingsSidebar", () => { ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ - to: "/company/settings/access", - label: "Access", + to: "/company/settings/members", + label: "Members", badge: 2, end: true, }), diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 522f2f5bb28..ed84e9baed3 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; @@ -67,9 +67,9 @@ export function CompanySettingsSidebar() { end /> diff --git a/ui/src/components/access/CompanySettingsNav.test.tsx b/ui/src/components/access/CompanySettingsNav.test.tsx index be3daa819c3..7d795ee825a 100644 --- a/ui/src/components/access/CompanySettingsNav.test.tsx +++ b/ui/src/components/access/CompanySettingsNav.test.tsx @@ -60,27 +60,29 @@ describe("CompanySettingsNav", () => { expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general"); expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments"); expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments"); - expect(getCompanySettingsTab("/company/settings/access")).toBe("access"); - expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access"); + expect(getCompanySettingsTab("/company/settings/members")).toBe("members"); + 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"); }); it("renders the active tab and navigates when a different tab is selected", async () => { - currentPathname = "/PAP/company/settings/access"; + currentPathname = "/PAP/company/settings/members"; const root = createRoot(container); await act(async () => { root.render(); }); - expect(container.textContent).toContain("access"); + expect(container.textContent).toContain("members"); expect(pageTabBarMock).toHaveBeenCalledWith( expect.objectContaining({ - value: "access", + value: "members", items: [ { value: "general", label: "General" }, { value: "environments", label: "Environments" }, - { value: "access", label: "Access" }, + { value: "members", label: "Members" }, { value: "invites", label: "Invites" }, ], }), diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index 82fb224551e..ded7a63def8 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router"; const items = [ { value: "general", label: "General", href: "/company/settings" }, { value: "environments", label: "Environments", href: "/company/settings/environments" }, - { value: "access", label: "Access", href: "/company/settings/access" }, + { value: "members", label: "Members", href: "/company/settings/members" }, { value: "invites", label: "Invites", href: "/company/settings/invites" }, ] as const; @@ -16,8 +16,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab { return "environments"; } - if (pathname.includes("/company/settings/access")) { - return "access"; + if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) { + return "members"; } if (pathname.includes("/company/settings/invites")) { diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx index eefb7d73054..aaf4979e272 100644 --- a/ui/src/pages/CompanyAccess.test.tsx +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -4,23 +4,25 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CompanyAccess } from "./CompanyAccess"; +import { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess"; const listMembersMock = vi.hoisted(() => vi.fn()); const listJoinRequestsMock = vi.hoisted(() => vi.fn()); -const updateMemberAccessMock = vi.hoisted(() => vi.fn()); +const updateMemberMock = vi.hoisted(() => vi.fn()); const archiveMemberMock = vi.hoisted(() => vi.fn()); const listAgentsMock = vi.hoisted(() => vi.fn()); const listIssuesMock = vi.hoisted(() => vi.fn()); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); vi.mock("@/api/access", () => ({ accessApi: { listMembers: (companyId: string) => listMembersMock(companyId), listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status), - updateMember: vi.fn(), + updateMember: (companyId: string, memberId: string, input: unknown) => + updateMemberMock(companyId, memberId, input), updateMemberPermissions: vi.fn(), - updateMemberAccess: (companyId: string, memberId: string, input: unknown) => - updateMemberAccessMock(companyId, memberId, input), + updateMemberAccess: vi.fn(), archiveMember: (companyId: string, memberId: string, input: unknown) => archiveMemberMock(companyId, memberId, input), approveJoinRequest: vi.fn(), @@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({ }, })); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => { + mockNavigate(to, replace); + return
{to}
; + }, +})); + +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, +})); + vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -146,7 +160,7 @@ describe("CompanyAccess", () => { }, }, ]); - updateMemberAccessMock.mockResolvedValue({}); + updateMemberMock.mockResolvedValue({}); archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 }); listAgentsMock.mockResolvedValue([ { @@ -164,6 +178,11 @@ describe("CompanyAccess", () => { status: "todo", }, ]); + mockUsePluginSlots.mockReturnValue({ + slots: [], + isLoading: false, + errorMessage: null, + }); }); afterEach(() => { @@ -172,7 +191,7 @@ describe("CompanyAccess", () => { vi.clearAllMocks(); }); - it("keeps the page human-focused and explains implicit versus explicit grants", async () => { + it("keeps the page human-focused and hides advanced permission controls", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -188,10 +207,15 @@ describe("CompanyAccess", () => { await flushReact(); await flushReact(); - expect(container.textContent).toContain("Manage company user memberships"); + expect(container.textContent).toContain("Manage the people who can work in Paperclip"); + expect(container.textContent).toContain("Members can collaborate across the company by default"); + expect(container.textContent).toContain("Advanced permission controls are available in Paperclip EE"); expect(container.textContent).toContain("Humans"); expect(container.textContent).toContain("Pending human joins"); expect(container.textContent).toContain("User account"); + expect(container.textContent).not.toContain("Grants"); + expect(container.textContent).not.toContain("explicit grants"); + expect(container.textContent).not.toContain("Assign scoped tasks"); expect(container.textContent).not.toContain("Agents"); expect(container.textContent).not.toContain("Pending agent joins"); expect(container.textContent).not.toContain("Open join request queue"); @@ -210,18 +234,16 @@ describe("CompanyAccess", () => { }); await flushReact(); - expect(document.body.textContent).toContain("Implicit grants from role"); - expect(document.body.textContent).toContain("Owner currently includes these permissions automatically."); - expect(document.body.textContent).toContain( - "Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.", - ); + expect(document.body.textContent).toContain("Update company role and membership status"); + expect(document.body.textContent).not.toContain("Implicit grants from role"); + expect(document.body.textContent).not.toContain("permissionKey"); await act(async () => { root.unmount(); }); }); - it("saves member role, status, and grants in one request", async () => { + it("saves member role and status without touching grants", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -248,7 +270,7 @@ describe("CompanyAccess", () => { await flushReact(); const saveButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent === "Save access", + (button) => button.textContent === "Save member", ); expect(saveButton).toBeTruthy(); @@ -257,10 +279,9 @@ describe("CompanyAccess", () => { }); await flushReact(); - expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", { + expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", { membershipRole: "owner", status: "active", - grants: [], }); await act(async () => { @@ -382,4 +403,65 @@ describe("CompanyAccess", () => { root.unmount(); }); }); + + it("redirects legacy access deep links to the EE permissions route when installed", async () => { + mockUsePluginSlots.mockReturnValue({ + slots: [ + { + type: "companySettingsPage", + id: "permissions", + displayName: "Permissions", + routePath: "permissions", + pluginKey: "paperclip-ee", + }, + ], + isLoading: false, + errorMessage: null, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockNavigate).toHaveBeenCalledWith("/company/settings/permissions", true); + expect(container.textContent).toContain("/company/settings/permissions"); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows a read-only EE unavailable fallback for legacy access deep links", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Advanced Permissions"); + expect(container.textContent).toContain("Paperclip EE unavailable"); + expect(container.textContent).toContain("Open Members"); + expect(container.textContent).toContain("Open Invites"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 3f31fe1ef5b..0834e8c0266 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, - PERMISSION_KEYS, type Agent, - type PermissionKey, } from "@paperclipai/shared"; -import { ShieldCheck, Trash2, Users } from "lucide-react"; +import { Shield, ShieldCheck, Trash2, Users } from "lucide-react"; import { accessApi, type CompanyMember } from "@/api/access"; import { agentsApi } from "@/api/agents"; import { ApiError } from "@/api/client"; import { issuesApi } from "@/api/issues"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -25,38 +22,13 @@ import { Badge } from "@/components/ui/badge"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; +import { Link, Navigate } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; - -const permissionLabels: Record = { - "agents:create": "Create agents", - "users:invite": "Invite humans and agents", - "users:manage_permissions": "Manage members and grants", - "tasks:assign": "Assign tasks", - "tasks:assign_scope": "Assign scoped tasks", - "tasks:manage_active_checkouts": "Manage active task checkouts", - "joins:approve": "Approve join requests", - "environments:manage": "Manage environments", -}; - -function formatGrantSummary(member: CompanyMember) { - if (member.grants.length === 0) return "No explicit grants"; - return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", "); -} - -const implicitRoleGrantMap: Record, PermissionKey[]> = { - owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"], - admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"], - operator: ["tasks:assign"], - viewer: [], -}; +import { usePluginSlots } from "@/plugins/slots"; const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out"; type EditableMemberStatus = "pending" | "active" | "suspended"; -function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) { - return role ? implicitRoleGrantMap[role] : []; -} - export function CompanyAccess() { const { selectedCompany, selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -67,13 +39,12 @@ export function CompanyAccess() { const [reassignmentTarget, setReassignmentTarget] = useState("__unassigned"); const [draftRole, setDraftRole] = useState(null); const [draftStatus, setDraftStatus] = useState("active"); - const [draftGrants, setDraftGrants] = useState>(new Set()); useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, { label: "Settings", href: "/company/settings" }, - { label: "Access" }, + { label: "Members" }, ]); }, [selectedCompany?.name, setBreadcrumbs]); @@ -103,11 +74,10 @@ export function CompanyAccess() { }; const updateMemberMutation = useMutation({ - mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => { - return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, { + mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => { + return accessApi.updateMember(selectedCompanyId!, input.memberId, { membershipRole: input.membershipRole, status: input.status, - grants: input.grants.map((permissionKey) => ({ permissionKey })), }); }, onSuccess: async () => { @@ -223,7 +193,6 @@ export function CompanyAccess() { if (!editingMember) return; setDraftRole(editingMember.membershipRole); setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended"); - setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey))); }, [editingMember]); useEffect(() => { @@ -255,8 +224,6 @@ export function CompanyAccess() { joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? []; const joinRequestActionPending = approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending; - const implicitGrantKeys = getImplicitGrantKeys(draftRole); - const implicitGrantSet = new Set(implicitGrantKeys); const activeReassignmentUsers = members.filter( (member) => member.status === "active" && @@ -271,11 +238,14 @@ export function CompanyAccess() {
-

Company Access

+

Company Members

- Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}. + Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.

+
+ Advanced permission controls are available in Paperclip EE. Core keeps this page focused on membership, invite approvals, and safe member removal. +
{access && !access.currentUserRole && ( @@ -291,7 +261,7 @@ export function CompanyAccess() {

Humans

- Manage human company memberships, status, and grants here. + Manage human company memberships and status here.

@@ -340,11 +310,10 @@ export function CompanyAccess() { ) : null}
-
+
User account
Role
Status
-
Grants
Action
{members.length === 0 ? ( @@ -356,7 +325,7 @@ export function CompanyAccess() { return (
{member.user?.name?.trim() || member.user?.email || member.principalId}
@@ -372,7 +341,6 @@ export function CompanyAccess() { {member.status.replace("_", " ")}
-
{formatGrantSummary(member)}
- -
-
-

Grants

-

- Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes. -

-
-
-
Implicit grants from role
-

- {draftRole - ? `${HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole]} currently includes these permissions automatically.` - : "No role is selected, so this member has no implicit grants right now."} -

- {implicitGrantKeys.length > 0 ? ( -
- {implicitGrantKeys.map((permissionKey) => ( - - {permissionLabels[permissionKey]} - - ))} -
- ) : null} -
-
- {PERMISSION_KEYS.map((permissionKey) => ( - - ))} -
-
)} @@ -516,12 +424,11 @@ export function CompanyAccess() { memberId: editingMember.id, membershipRole: draftRole, status: draftStatus, - grants: [...draftGrants], }); }} disabled={updateMemberMutation.isPending} > - {updateMemberMutation.isPending ? "Saving…" : "Save access"} + {updateMemberMutation.isPending ? "Saving…" : "Save member"} @@ -616,6 +523,66 @@ export function CompanyAccess() { ); } +export function CompanyAccessLegacyRoute() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { slots, isLoading, errorMessage } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: selectedCompanyId, + enabled: !!selectedCompanyId, + }); + + useEffect(() => { + setBreadcrumbs([ + { label: "Settings", href: "/company/settings" }, + { label: "Access" }, + ]); + }, [setBreadcrumbs]); + + const permissionsSlot = slots.find((slot) => slot.routePath === "permissions"); + if (permissionsSlot) { + return ; + } + + if (isLoading) { + return
Checking for Paperclip EE permissions...
; + } + + return ( +
+
+
+ +

Advanced Permissions

+
+

+ Advanced access, scoped assignment, and explicit grant controls are now owned by the Paperclip EE Permissions plugin. +

+
+ +
+
+

Paperclip EE unavailable

+

+ Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires Paperclip EE. +

+ {errorMessage ? ( +

Plugin extensions unavailable: {errorMessage}

+ ) : null} +
+
+ + +
+
+
+ ); +} + function memberDisplayName(member: CompanyMember | null) { if (!member) return "this member"; return member.user?.name?.trim() || member.user?.email || member.principalId; diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx index 03937030a7d..27961dbc73c 100644 --- a/ui/src/pages/CompanyInvites.test.tsx +++ b/ui/src/pages/CompanyInvites.test.tsx @@ -152,7 +152,8 @@ describe("CompanyInvites", () => { expect(container.textContent).toContain("Choose a role"); expect(container.textContent).toContain("Each invite link is single-use."); expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests."); - expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants."); + expect(container.textContent).toContain("Everything in Admin, plus managing members."); + expect(container.textContent).not.toContain("permission grants"); expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 }); const viewMoreButton = Array.from(container.querySelectorAll("button")).find( diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx index 0d52bf314dc..f20ee636b3a 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/pages/CompanyInvites.tsx @@ -14,8 +14,8 @@ const inviteRoleOptions = [ { value: "viewer", label: "Viewer", - description: "Can view company work and follow along without operational permissions.", - gets: "No built-in grants.", + description: "Can view company work and follow along.", + gets: "View-only company membership.", }, { value: "operator", @@ -32,8 +32,8 @@ const inviteRoleOptions = [ { value: "owner", label: "Owner", - description: "Full company access, including membership and permission management.", - gets: "Everything in Admin, plus managing members and permission grants.", + description: "Full company access, including membership management.", + gets: "Everything in Admin, plus managing members.", }, ] as const; diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index 0fe56c2dc2f..a800bd6f036 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -403,16 +403,16 @@ describe("InviteLandingPage", () => { expect(container.textContent).toContain("Request to join Acme Robotics"); expect(container.textContent).toContain("A company admin must approve your request to join."); expect(container.textContent).toContain( - "Ask them to visit Company Settings → Access to approve your request.", + "Ask them to visit Company Settings → Members to approve your request.", ); expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull(); - expect(container.textContent).not.toContain("http://localhost/company/settings/access"); + expect(container.textContent).not.toContain("http://localhost/company/settings/members"); const approvalLinks = Array.from(container.querySelectorAll("a")).filter( - (link) => link.textContent === "Company Settings → Access", + (link) => link.textContent === "Company Settings → Members", ); expect(approvalLinks).toHaveLength(2); - const expectedApprovalUrl = `${window.location.origin}/company/settings/access`; + const expectedApprovalUrl = `${window.location.origin}/company/settings/members`; for (const link of approvalLinks) { expect(link.getAttribute("href")).toBe(expectedApprovalUrl); } @@ -471,7 +471,7 @@ describe("InviteLandingPage", () => { expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull(); expect(container.textContent).toContain("Your request is still awaiting approval."); expect(container.textContent).toContain( - "Ask them to visit Company Settings → Access to approve your request.", + "Ask them to visit Company Settings → Members to approve your request.", ); await act(async () => { diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 7dcef629b3f..cb394505900 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({ claimApiKeyPath = null, onboardingTextUrl = null, }: AwaitingJoinApprovalPanelProps) { - const approvalUrl = `${window.location.origin}/company/settings/access`; + const approvalUrl = `${window.location.origin}/company/settings/members`; const approverLabel = invitedByUserName ?? "A company admin"; return ( @@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({ href={approvalUrl} className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100" > - Company Settings → Access + Company Settings → Members

- Ask them to visit Company Settings → Access to approve your request. + Ask them to visit Company Settings → Members to approve your request.

Refresh this page after you've been approved — you'll be redirected automatically. diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx index 941a4875512..9a356d0a27d 100644 --- a/ui/src/pages/InviteUxLab.tsx +++ b/ui/src/pages/InviteUxLab.tsx @@ -23,8 +23,8 @@ const inviteRoleOptions = [ { value: "viewer", label: "Viewer", - description: "Can view company work and follow along without operational permissions.", - gets: "No built-in grants.", + description: "Can view company work and follow along.", + gets: "View-only company membership.", }, { value: "operator", @@ -41,8 +41,8 @@ const inviteRoleOptions = [ { value: "owner", label: "Owner", - description: "Full company access, including membership and permission management.", - gets: "Everything in Admin, plus managing members and permission grants.", + description: "Full company access, including membership management.", + gets: "Everything in Admin, plus managing members.", }, ] as const; @@ -423,8 +423,8 @@ function InviteResultPreview({ <>

@@ -897,7 +897,7 @@ export function InviteUxLab() { />