From d6fa1fd1ef1d378aee192845b9eb8860e00496fa Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 30 Sep 2026 16:18:59 -0700 Subject: [PATCH] feat(ui): streamline account menu profile access and add Invite shortcut (#14480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Humans work in teams, so Paperclip has a multi-user system with logins, profiles, and a company sidebar. > - The account menu in the lower-left corner of the sidebar is the main entry point for a user's own settings. > - The account menu spent half of its rows on "View profile" and "Edit profile". Users open these rows rarely. > - The account menu had no fast path to invite a new member. The invite flow is different on a self-hosted instance and on Paperclip Cloud. > - This pull request removes the two profile rows, makes the header a link to the profile, adds an "Edit profile" button on the profile page, and adds an "Invite" row. > - The benefit is a shorter account menu that keeps profile access and gives users with the invite permission a one-click path to invite people. ## Linked Issues or Issue Description No public GitHub issue exists for this change. The description below follows the enhancement template. Refs #14060. That earlier pull request holds the first two commits and the first Greptile review. It closed when the branch got a new name to remove an internal ticket id. All Greptile findings from both reviews are fixed in this branch. **What existing behavior does this improve?** The account menu in the sidebar (`SidebarAccountMenu` and its `.production` variant) and the user profile page at `/u/:userSlug`. **Subsystem affected** ui/ — React + Vite board UI **Current behavior** The account menu shows the user's picture and name at the top. Below them, the menu shows "View profile" and "Edit profile" rows, then the other rows. The header is not a link. The menu has no row to invite people. On a self-hosted instance, the user must open Company settings, then Members, then the Invites tab. On Paperclip Cloud, the user must open the Members page and use the Cloud People link there. **Proposed behavior** The account menu does not show "View profile" or "Edit profile". The picture and name at the top of the menu are a link to the user's own profile page. The profile page shows an "Edit profile" button when the viewer looks at their own profile. The account menu shows an "Invite" row with the same `UserPlus` icon as the company menu. On a self-hosted instance, the row opens the Members page on the Invites tab, and shows only to boards that hold the `users:invite` grant (company owners and admins, instance admins, and local boards). On Paperclip Cloud, the row opens the People settings for the current stack, and shows only to the owner or admin of the stack, the same rule the Members page uses. **Reason and benefit** Users open their profile rarely, but the two rows took half of the menu. Inviting people is a common task, but it needed three clicks and a different path on Cloud. The new menu is shorter, keeps profile access in one tap on the header, and gives one "Invite" entry point on both hosting modes to the users who can invite. **Breaking changes** None. Routes, API responses, and settings keys do not change. The profile page and the invite pages keep their current URLs. ## What Changed - `SidebarAccountMenu.tsx` and `SidebarAccountMenu.production.tsx`: remove the "View profile" and "Edit profile" rows. Make the picture and name header a link to the user's profile. Add the "Invite" row after "Settings" in the streamlined menu and first in the production menu. - Header structure: `master` added a staging commit SHA link under the email in the same header. The profile link is now a stretched overlay behind the header content, so the SHA anchor sits beside the email and the header has no nested anchors. - `ui/src/lib/userProfileLinks.ts` (new): build the profile path from the user id first. The profile endpoint treats the id as the one unique slug, so two members with the same display name get different links. Name and email are a fallback only when the session has no id. - `ui/src/hooks/useCloudInviteUrl.ts` (new): read the Cloud stack portfolio and return the People settings URL only when the current stack role is owner or admin. This is the same rule as the Members page. - `ui/src/hooks/useCompanyInviteAccess.ts` (new): read the current board access snapshot and report whether the board may invite people to the selected company on a self-hosted instance. Local boards and instance admins pass. Other boards need an active owner or admin membership, the roles that carry `users:invite`. This follows the same client-side gate pattern as `ToolsAdminGate` and the run ledger. The server stays authoritative. - Invite row visibility: the row is hidden when the operator hides `company.members` or `company.invites`, and until the health check resolves. On self-hosted instances, the row is hidden until the board access snapshot loads and when the board cannot invite. On Cloud, the row is hidden when no People URL can be built. The in-app Invites tab is never a fallback on Cloud, because it drives a different flow. - `ui/src/pages/UserProfile.tsx`: add an "Edit profile" button that links to `/company/settings/instance/profile`. The button shows only on the viewer's own profile and follows the `instance.profile` hidden-settings gate. - Tests: extend `SidebarAccountMenu.test.tsx`; add `userProfileLinks.test.ts`, `UserProfile.test.tsx`, and `useCompanyInviteAccess.test.ts`. - No documentation references the removed menu rows, so no docs change is needed. ## Verification Run the focused tests from the `ui/` directory: ```bash pnpm vitest run src/components/SidebarAccountMenu.test.tsx src/hooks/useCompanyInviteAccess.test.ts src/lib/userProfileLinks.test.ts src/pages/UserProfile.test.tsx ``` - 52 tests pass in these four files. They cover the header link by user id, the removed rows, the header overlay with no nested anchors, the self-hosted invite target, the self-hosted permission gate (owner, admin, instance admin, and local board see the row; an operator does not, on both menu variants), the Cloud invite target with no `target="_blank"`, the menu order, the hidden-settings gate on both variants, the Cloud role gate (a plain member sees no row), the no-fallback rule when Cloud stack metadata is missing, the staging commit SHA link from `master`, and the own-profile-only "Edit profile" button. - `tsc -b` in `ui/` reports no errors in the changed files. The only errors are pre-existing `@paperclipai/plugin-sdk/ui` resolution errors in `PluginOrganizationSwitcher.tsx` from an unbuilt workspace package. Manual steps: 1. Sign in and open the account menu in the lower-left corner. Confirm the menu has no "View profile" or "Edit profile" rows. 2. Click your picture or name at the top of the menu. Confirm your profile page opens and shows an "Edit profile" button. 3. Open another user's profile. Confirm the page shows no "Edit profile" button. 4. On a self-hosted instance, as a company owner or admin, click "Invite". Confirm the Members page opens on the Invites tab. As an operator or viewer, confirm the menu shows no "Invite" row. 5. On Paperclip Cloud, as a stack owner or admin, click "Invite". Confirm the Cloud People settings page opens in the same tab. As a plain member, confirm the menu shows no "Invite" row. ## Risks - Low risk. The change is limited to the UI and touches ten files. - Users who know the "View profile" and "Edit profile" rows must learn the new header link. The header has hover and focus styles to show that it is a link. - On self-hosted instances, the "Invite" row depends on the board access snapshot from `/cli-auth/me`, which other gates in the UI already use. A member with a custom `users:invite` grant but an operator or viewer role does not see the row. That member can still use the Members page. The row is a shortcut, not the only path. - On Paperclip Cloud, the "Invite" row depends on the stack portfolio query. When that query fails or the role is unknown, the menu hides the row instead of sending the user to the wrong flow. - Both menu variants change together, so a behavior difference between them is not expected. ## Model Used - Provider: Anthropic. Model: Claude Fable 5.1 (`claude-fable-5-1`). - Run through Claude Code on the Claude Agent SDK inside a Paperclip `claude_local` agent, with extended thinking and tool use (file edits, shell, tests, GitHub API). - The model wrote the code, the tests, and this description. A human reviewed the pull request and requested the review fixes. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Bender (Fable) Co-authored-by: Claude Fable 5.1 Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> --- .../SidebarAccountMenu.production.tsx | 112 ++++--- ui/src/components/SidebarAccountMenu.test.tsx | 287 +++++++++++++++++- ui/src/components/SidebarAccountMenu.tsx | 100 +++--- ui/src/hooks/useCloudInviteUrl.ts | 36 +++ ui/src/hooks/useCompanyInviteAccess.test.ts | 59 ++++ ui/src/hooks/useCompanyInviteAccess.ts | 46 +++ ui/src/lib/userProfileLinks.test.ts | 25 ++ ui/src/lib/userProfileLinks.ts | 34 +++ ui/src/pages/UserProfile.test.tsx | 177 +++++++++++ ui/src/pages/UserProfile.tsx | 32 +- 10 files changed, 829 insertions(+), 79 deletions(-) create mode 100644 ui/src/hooks/useCloudInviteUrl.ts create mode 100644 ui/src/hooks/useCompanyInviteAccess.test.ts create mode 100644 ui/src/hooks/useCompanyInviteAccess.ts create mode 100644 ui/src/lib/userProfileLinks.test.ts create mode 100644 ui/src/lib/userProfileLinks.ts create mode 100644 ui/src/pages/UserProfile.test.tsx diff --git a/ui/src/components/SidebarAccountMenu.production.tsx b/ui/src/components/SidebarAccountMenu.production.tsx index 005a31ad9f..e0549e56fe 100644 --- a/ui/src/components/SidebarAccountMenu.production.tsx +++ b/ui/src/components/SidebarAccountMenu.production.tsx @@ -5,16 +5,19 @@ import { Flag, LogOut, type LucideIcon, - UserRound, - UserRoundPen, + UserPlus, } from "lucide-react"; -import type { DeploymentMode } from "@paperclipai/shared"; +import { hidesCompanyPage, type DeploymentMode } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { useCloudInstance } from "@/hooks/useCloudInstance"; +import { useCloudInviteUrl } from "@/hooks/useCloudInviteUrl"; +import { useCanInviteCompanyMembers } from "@/hooks/useCompanyInviteAccess"; +import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { useSignOut } from "@/hooks/useSignOut"; import { useStagingCommit } from "@/hooks/useStagingCommit"; +import { userProfilePath } from "@/lib/userProfileLinks"; import { useSidebar } from "../context/SidebarContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -23,7 +26,7 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { ThemeToggle } from "./ThemeToggle"; import { SidebarServerInfo } from "./SidebarServerInfo"; -const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; +const INVITES_PATH = "/company/settings/members?tab=invites"; const DOCS_URL = "https://docs.paperclip.ing/"; const FEEDBACK_URL = "https://paperclip.ing/feedback"; @@ -39,7 +42,14 @@ interface MenuActionProps { icon: LucideIcon; onClick?: () => void; href?: string; + /** Opens `href` in a new tab (docs and other off-product links). */ external?: boolean; + /** + * Leaves the app in the current tab with a full navigation. Cloud links + * must use this: the cloud harness shadows those paths on tenant hosts, so + * the in-app router can never reach them. + */ + topLevel?: boolean; } function deriveInitials(name: string) { @@ -50,21 +60,15 @@ function deriveInitials(name: string) { return name.slice(0, 2).toUpperCase(); } -function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) { - const candidates = [name, email?.split("@")[0], email, id]; - for (const candidate of candidates) { - const slug = candidate - ?.trim() - .toLowerCase() - .replace(/['"]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - if (slug) return slug; - } - return "me"; -} - -function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) { +function MenuAction({ + label, + description, + icon: Icon, + onClick, + href, + external = false, + topLevel = false, +}: MenuActionProps) { const className = "flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60"; @@ -89,6 +93,14 @@ function MenuAction({ label, description, icon: Icon, onClick, href, external = ); } + if (topLevel) { + return ( + + {content} + + ); + } + return ( {content} @@ -108,7 +120,26 @@ export function SidebarAccountMenu({ open: controlledOpen, onOpenChange, }: SidebarAccountMenuProps) { - const isCloud = Boolean(useCloudInstance()); + const cloud = useCloudInstance(); + const isCloud = Boolean(cloud); + // Invites live on the Members page (or in Cloud's People settings). Hide the + // shortcut when the hosting operator hides either surface, and until the + // health response resolves so a hidden surface never flashes. + const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings(); + // On Cloud the shortcut exists only for the current stack's owner/admin and + // only once the stack metadata is known; the in-app Invites tab is never a + // fallback there because it drives a different invitation flow. + const cloudInviteUrl = useCloudInviteUrl(); + // Self-hosted invites need the `users:invite` grant. Offer the shortcut only + // to boards that hold it (company owner/admin, instance admins, local + // boards) so a plain member is never sent to a permission error. + const canInviteMembers = useCanInviteCompanyMembers(!isCloud); + const inviteHref = isCloud ? cloudInviteUrl : canInviteMembers ? INVITES_PATH : null; + const showInvite = + hiddenSettingsLoaded && + inviteHref !== null && + !hidesCompanyPage(hiddenSettings, "company.members") && + !hidesCompanyPage(hiddenSettings, "company.invites"); const [internalOpen, setInternalOpen] = useState(false); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; @@ -127,7 +158,7 @@ export function SidebarAccountMenu({ const secondaryLabel = session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board"); const initials = deriveInitials(displayName); - const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`; + const profileHref = userProfilePath(session?.user); function closeNavigationChrome() { setOpen(false); @@ -166,19 +197,26 @@ export function SidebarAccountMenu({ >
-
-
+ {/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */} +
+ +
{session?.user.image ? : null} {initials}
-
+

{displayName}

{secondaryLabel}

{stagingCommit ? (
- - + {showInvite && inviteHref ? ( + + ) : null} ({ getExperimental: vi.fn(), })); const mockHealthApi = vi.hoisted(() => ({ get: vi.fn() })); +const mockGetCurrentBoardAccess = vi.hoisted(() => vi.fn()); const mockToggleTheme = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); const mockNavigateTopLevel = vi.hoisted(() => vi.fn()); @@ -30,6 +31,18 @@ vi.mock("@/api/auth", () => ({ vi.mock("@/api/health", () => ({ healthApi: mockHealthApi })); +vi.mock("@/api/access", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + accessApi: { ...actual.accessApi, getCurrentBoardAccess: mockGetCurrentBoardAccess }, + }; +}); + +vi.mock("@/context/CompanyContext", () => ({ + useOptionalCompany: () => ({ selectedCompanyId: "company-1" }), +})); + vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: mockNavigateTopLevel, })); @@ -78,6 +91,39 @@ async function flushReact() { }); } +/** Stack portfolio with the acme-labs stack current at the given Cloud role. */ +function cloudStacksPortfolio(role: string) { + return { + stacks: [ + { + displayName: "Acme Labs", + stackSlug: "acme-labs", + primaryHost: "acme-labs.example.test", + lifecycleState: "active", + sleepState: "awake", + role, + isCurrent: true, + }, + ], + }; +} + +/** Board access snapshot for a signed-in member of company-1 at the given company role. */ +function boardAccess( + membershipRole: "owner" | "admin" | "operator" | "viewer", + overrides: { isInstanceAdmin?: boolean; source?: string } = {}, +) { + return { + user: { id: "user-1", email: "jane@example.com", name: "Jane Example", image: null }, + userId: "user-1", + isInstanceAdmin: overrides.isInstanceAdmin ?? false, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole, status: "active" as const }], + source: overrides.source ?? "session", + keyId: null, + }; +} + describe("SidebarAccountMenu", () => { let container: HTMLDivElement; @@ -97,6 +143,9 @@ describe("SidebarAccountMenu", () => { enableIsolatedWorkspaces: false, }); mockAuthApi.signOut.mockResolvedValue({ success: true, redirectTo: "/cloud/logout" }); + // Default to a company owner: the self-hosted Invite shortcut needs the + // `users:invite` grant, which owners and admins hold. + mockGetCurrentBoardAccess.mockResolvedValue(boardAccess("owner")); }); afterEach(() => { @@ -347,7 +396,6 @@ describe("SidebarAccountMenu", () => { }); await flushReact(); - expect(document.body.textContent).toContain("Edit profile"); expect(document.body.textContent).toContain("Settings"); expect(document.body.textContent).not.toContain("Instance settings"); expect(document.body.textContent).toContain("Documentation"); @@ -356,6 +404,35 @@ describe("SidebarAccountMenu", () => { expect(popover?.textContent).not.toContain("Feedback"); expect(popover?.querySelector('a[href="https://paperclip.ing/feedback"]')).toBeNull(); + // Profile access moved off the action list: the header links to the + // profile, and the separate view/edit rows are gone. Editing lives on the + // profile page itself. + expect(popover?.textContent).not.toContain("View profile"); + expect(popover?.textContent).not.toContain("Edit profile"); + // The link resolves by user id, not display name: names are not unique, + // and the profile endpoint returns the first member whose slug matches. + const profileHeaderLink = popover?.querySelector('a[aria-label="View profile"]'); + expect(profileHeaderLink?.getAttribute("href")).toBe("/u/user-1"); + // The link is a stretched overlay over the header block (so the staging + // SHA anchor can live beside the email without nesting anchors), so the + // identity text is in the header block rather than inside the anchor. + const profileHeader = profileHeaderLink?.parentElement; + expect(profileHeader?.textContent).toContain("Jane Example"); + expect(profileHeader?.textContent).toContain("jane@example.com"); + expect(profileHeader?.querySelector("a a")).toBeNull(); + expect(popover?.querySelector('a[href="/company/settings/instance/profile"]')).toBeNull(); + + // Self-hosted invites open the in-app Members page on its Invites tab. + const inviteLink = popover?.querySelector( + 'a[href="/company/settings/members?tab=invites"]', + ); + expect(inviteLink?.textContent).toBe("Invite"); + expect(inviteLink?.querySelector("svg")?.classList).toContain("lucide-user-plus"); + expect(inviteLink?.hasAttribute("target")).toBe(false); + const menuOrder = popover?.textContent ?? ""; + expect(menuOrder.indexOf("Settings")).toBeLessThan(menuOrder.indexOf("Invite")); + expect(menuOrder.indexOf("Invite")).toBeLessThan(menuOrder.indexOf("Documentation")); + // Documentation still appears before the theme toggle. const menuText = popover?.textContent ?? ""; const docsPos = menuText.indexOf("Documentation"); @@ -376,7 +453,6 @@ describe("SidebarAccountMenu", () => { .not.toContain("bg-muted"); expect(document.body.textContent).not.toContain("Manage company and instance settings."); expect(document.body.textContent).not.toContain("Open your activity, task, and usage ledger."); - expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull(); expect(document.body.querySelector('a[href="/company/settings"]')).not.toBeNull(); const signOutButton = Array.from(document.body.querySelectorAll("button")).find( @@ -412,6 +488,7 @@ describe("SidebarAccountMenu", () => { cloudBaseUrl: "https://cloud.example.test", }, }); + queryClient.setQueryData(queryKeys.cloud.stacks, cloudStacksPortfolio("owner")); await act(async () => { root.render( @@ -430,6 +507,20 @@ describe("SidebarAccountMenu", () => { expect(container.querySelector('a[aria-label="Share feedback"]')).toBeNull(); + // Cloud manages invitations in the stack's People settings, reached with a + // same-tab top-level navigation rather than the in-app router. + const popover = document.body.querySelector('[data-slot="popover-content"]'); + const inviteLink = Array.from(popover?.querySelectorAll("a") ?? []).find( + (anchor) => anchor.textContent?.includes("Invite"), + ); + expect(inviteLink?.getAttribute("href")).toBe( + "https://cloud.example.test/workspaces/acme-labs/settings?section=people", + ); + expect(inviteLink?.hasAttribute("target")).toBe(false); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.querySelector('a[aria-label="View profile"]')?.getAttribute("href")).toBe("/u/user-1"); + expect(popover?.textContent).not.toContain("Edit profile"); + const signOutButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.textContent?.includes("Sign out"), ); @@ -448,6 +539,198 @@ describe("SidebarAccountMenu", () => { }); }); + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("offers no cloud invite shortcut to members below stack admin (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + cloud: { + managed: true, + managedBy: "paperclip-cloud", + stackSlug: "acme-labs", + cloudBaseUrl: "https://cloud.example.test", + }, + }); + // Company roles can differ from Cloud roles; the Members page only offers + // the Cloud invite link to the current stack's owner/admin, so the menu + // must match rather than send a plain member to People settings. + queryClient.setQueryData(queryKeys.cloud.stacks, cloudStacksPortfolio("member")); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href^="https://cloud.example.test/"]')).toBeNull(); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("never falls back to in-app invites when cloud stack metadata is missing (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // Managed instance whose health block lacks the stack slug: the portfolio + // has no current stack either, so no Cloud People URL can be built. The + // in-app Invites tab drives a different flow and must not appear instead. + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + cloud: { + managed: true, + managedBy: "paperclip-cloud", + stackSlug: null, + cloudBaseUrl: "https://cloud.example.test", + }, + }); + queryClient.setQueryData(queryKeys.cloud.stacks, { stacks: [] }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.textContent).toContain("Documentation"); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("hides the invite shortcut when the operator hides invites (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + hiddenSettings: ["company.invites"], + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + // The settings-independent entries stay put. + expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull(); + expect(popover?.textContent).toContain("Documentation"); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("offers no self-hosted invite shortcut to members without the invite grant (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + }); + // Operators and viewers lack `users:invite`; the Invites tab would only + // show them a permission error, so the menu must not offer it. + mockGetCurrentBoardAccess.mockResolvedValue(boardAccess("operator")); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockGetCurrentBoardAccess).toHaveBeenCalledOnce(); + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull(); + expect(popover?.textContent).toContain("Documentation"); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([ + ["instance admin", boardAccess("viewer", { isInstanceAdmin: true })], + ["local board", boardAccess("viewer", { source: "local_implicit" })], + ["company admin", boardAccess("admin")], + ])("offers the self-hosted invite shortcut to a %s", async (_label, access) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + }); + mockGetCurrentBoardAccess.mockResolvedValue(access); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + const inviteLink = popover?.querySelector( + 'a[href="/company/settings/members?tab=invites"]', + ); + expect(inviteLink?.textContent).toBe("Invite"); + + await act(async () => { + root.unmount(); + }); + }); + it("keeps sign-out hidden outside authenticated deployment mode", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index d6552c3a50..f40959aa70 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -6,16 +6,19 @@ import { LogOut, Settings, type LucideIcon, - UserRound, - UserRoundPen, + UserPlus, } from "lucide-react"; -import type { DeploymentMode } from "@paperclipai/shared"; +import { hidesCompanyPage, type DeploymentMode } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { useCloudInstance } from "@/hooks/useCloudInstance"; +import { useCloudInviteUrl } from "@/hooks/useCloudInviteUrl"; +import { useCanInviteCompanyMembers } from "@/hooks/useCompanyInviteAccess"; +import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { useSignOut } from "@/hooks/useSignOut"; import { useStagingCommit } from "@/hooks/useStagingCommit"; +import { userProfilePath } from "@/lib/userProfileLinks"; import { useSidebar } from "../context/SidebarContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -24,7 +27,7 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { ThemeToggle } from "./ThemeToggle"; import { SidebarServerInfo } from "./SidebarServerInfo"; -const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; +const INVITES_PATH = "/company/settings/members?tab=invites"; const DOCS_URL = "https://docs.paperclip.ing/"; const FEEDBACK_URL = "https://paperclip.ing/feedback"; @@ -41,7 +44,14 @@ interface MenuActionProps { icon: LucideIcon; onClick?: () => void; href?: string; + /** Opens `href` in a new tab (docs and other off-product links). */ external?: boolean; + /** + * Leaves the app in the current tab with a full navigation. Cloud links + * must use this: the cloud harness shadows those paths on tenant hosts, so + * the in-app router can never reach them. + */ + topLevel?: boolean; } function deriveInitials(name: string) { @@ -52,26 +62,13 @@ function deriveInitials(name: string) { return name.slice(0, 2).toUpperCase(); } -function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) { - const candidates = [name, email?.split("@")[0], email, id]; - for (const candidate of candidates) { - const slug = candidate - ?.trim() - .toLowerCase() - .replace(/['"]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - if (slug) return slug; - } - return "me"; -} - function MenuAction({ label, icon: Icon, onClick, href, external = false, + topLevel = false, }: MenuActionProps) { const className = "flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-accent"; @@ -94,6 +91,14 @@ function MenuAction({ ); } + if (topLevel) { + return ( + + {content} + + ); + } + return ( {content} @@ -114,7 +119,26 @@ export function SidebarAccountMenu({ onOpenChange, forceExpanded = false, }: SidebarAccountMenuProps) { - const isCloud = Boolean(useCloudInstance()); + const cloud = useCloudInstance(); + const isCloud = Boolean(cloud); + // Invites live on the Members page (or in Cloud's People settings). Hide the + // shortcut when the hosting operator hides either surface, and until the + // health response resolves so a hidden surface never flashes. + const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings(); + // On Cloud the shortcut exists only for the current stack's owner/admin and + // only once the stack metadata is known; the in-app Invites tab is never a + // fallback there because it drives a different invitation flow. + const cloudInviteUrl = useCloudInviteUrl(); + // Self-hosted invites need the `users:invite` grant. Offer the shortcut only + // to boards that hold it (company owner/admin, instance admins, local + // boards) so a plain member is never sent to a permission error. + const canInviteMembers = useCanInviteCompanyMembers(!isCloud); + const inviteHref = isCloud ? cloudInviteUrl : canInviteMembers ? INVITES_PATH : null; + const showInvite = + hiddenSettingsLoaded && + inviteHref !== null && + !hidesCompanyPage(hiddenSettings, "company.members") && + !hidesCompanyPage(hiddenSettings, "company.invites"); const [internalOpen, setInternalOpen] = useState(false); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking && !forceExpanded; @@ -133,7 +157,7 @@ export function SidebarAccountMenu({ const secondaryLabel = session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board"); const initials = deriveInitials(displayName); - const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`; + const profileHref = userProfilePath(session?.user); function closeNavigationChrome() { setOpen(false); @@ -170,12 +194,19 @@ export function SidebarAccountMenu({ sideOffset={10} className="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)" > -
- + {/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */} +
+ + {session?.user.image ? : null} {initials} -
+

{displayName}

@@ -184,7 +215,7 @@ export function SidebarAccountMenu({

{stagingCommit ? ( - - + {showInvite && inviteHref ? ( + + ) : null} cloudApi.listStacks(), + enabled: isCloud, + staleTime: 30_000, + retry: false, + }); + if (!cloud || cloudStacksQuery.isError) return null; + const currentStack = cloudStacksQuery.data?.stacks.find((stack) => stack.isCurrent); + if (currentStack?.role !== "owner" && currentStack?.role !== "admin") return null; + return cloudStackInviteUrl(cloud.cloudBaseUrl, currentStack.stackSlug); +} diff --git a/ui/src/hooks/useCompanyInviteAccess.test.ts b/ui/src/hooks/useCompanyInviteAccess.test.ts new file mode 100644 index 0000000000..1939426a12 --- /dev/null +++ b/ui/src/hooks/useCompanyInviteAccess.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { CurrentBoardAccess } from "@/api/access"; +import { canInviteCompanyMembers } from "./useCompanyInviteAccess"; + +function snapshot(overrides: Partial = {}): CurrentBoardAccess { + return { + user: { id: "user-1", email: "jane@example.com", name: "Jane Example", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + source: "session", + keyId: null, + ...overrides, + }; +} + +describe("canInviteCompanyMembers", () => { + it("is false until the board access snapshot is known", () => { + expect(canInviteCompanyMembers("company-1", undefined)).toBe(false); + }); + + it("always passes local boards and instance admins", () => { + expect(canInviteCompanyMembers("company-1", snapshot({ source: "local_implicit" }))).toBe(true); + expect(canInviteCompanyMembers("company-1", snapshot({ isInstanceAdmin: true }))).toBe(true); + // Both bypass the company lookup, so a missing company is fine. + expect(canInviteCompanyMembers(null, snapshot({ isInstanceAdmin: true }))).toBe(true); + }); + + it.each(["owner", "admin"] as const)("passes an active company %s", (membershipRole) => { + const access = snapshot({ + memberships: [{ companyId: "company-1", membershipRole, status: "active" }], + }); + expect(canInviteCompanyMembers("company-1", access)).toBe(true); + }); + + it.each(["operator", "viewer", "member", null] as const)("rejects a company %s", (membershipRole) => { + const access = snapshot({ + memberships: [{ companyId: "company-1", membershipRole, status: "active" }], + }); + expect(canInviteCompanyMembers("company-1", access)).toBe(false); + }); + + it("ignores inactive memberships and other companies", () => { + const suspended = snapshot({ + memberships: [{ companyId: "company-1", membershipRole: "owner", status: "suspended" }], + }); + expect(canInviteCompanyMembers("company-1", suspended)).toBe(false); + const elsewhere = snapshot({ + memberships: [{ companyId: "company-2", membershipRole: "owner", status: "active" }], + }); + expect(canInviteCompanyMembers("company-1", elsewhere)).toBe(false); + expect(canInviteCompanyMembers(null, elsewhere)).toBe(false); + }); + + it("is false when the snapshot carries no memberships", () => { + expect(canInviteCompanyMembers("company-1", snapshot({ memberships: undefined }))).toBe(false); + }); +}); diff --git a/ui/src/hooks/useCompanyInviteAccess.ts b/ui/src/hooks/useCompanyInviteAccess.ts new file mode 100644 index 0000000000..7eec8933fc --- /dev/null +++ b/ui/src/hooks/useCompanyInviteAccess.ts @@ -0,0 +1,46 @@ +import { useQuery } from "@tanstack/react-query"; +import { accessApi, type CurrentBoardAccess } from "@/api/access"; +import { queryKeys } from "@/lib/queryKeys"; +import { useOptionalCompany } from "@/context/CompanyContext"; + +/** + * Whether a board may invite people to `companyId` on a self-hosted instance, + * judged from the current board access snapshot. + * + * Mirrors the other client-side role gates (`ToolsAdminGate`, the run + * ledger): local implicit boards and instance admins always pass; otherwise + * the active company membership must be owner or admin, the roles that carry + * the `users:invite` grant. Resolves to false while the snapshot is unknown. + * The server stays authoritative; this only decides whether to offer a + * shortcut that would otherwise end in a permission error. + */ +export function canInviteCompanyMembers( + companyId: string | null | undefined, + boardAccess: CurrentBoardAccess | undefined, +): boolean { + if (!boardAccess) return false; + if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true; + if (!companyId) return false; + const membership = boardAccess.memberships?.find( + (item) => item.companyId === companyId && item.status === "active", + ); + return membership?.membershipRole === "owner" || membership?.membershipRole === "admin"; +} + +/** + * Whether the signed-in board may invite people to the selected company on a + * self-hosted instance. Pass `enabled: false` on Cloud, where invitations live + * in the stack's People settings and use a different rule + * (see `useCloudInviteUrl`). + */ +export function useCanInviteCompanyMembers(enabled = true): boolean { + const company = useOptionalCompany(); + const boardAccess = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + enabled, + retry: false, + staleTime: 30_000, + }); + return enabled && canInviteCompanyMembers(company?.selectedCompanyId, boardAccess.data); +} diff --git a/ui/src/lib/userProfileLinks.test.ts b/ui/src/lib/userProfileLinks.test.ts new file mode 100644 index 0000000000..19a5c5e891 --- /dev/null +++ b/ui/src/lib/userProfileLinks.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { userProfilePath } from "./userProfileLinks"; + +describe("userProfilePath", () => { + it("links by user id even when a display name is available", () => { + // Two members can share a name; the endpoint returns the first slug match, + // so a name-based link could open a namesake's profile. + expect(userProfilePath({ id: "user-1", name: "Jane Example", email: "jane@example.com" })).toBe("/u/user-1"); + }); + + it("slugifies ids the same way the profile endpoint does", () => { + expect(userProfilePath({ id: "paperclip-id:TLqGPuleJpjX" })).toBe("/u/paperclip-id-tlqgpulejpjx"); + }); + + it("falls back to name, then email, when the session carries no id", () => { + expect(userProfilePath({ id: null, name: "Jane Example", email: "jane@example.com" })).toBe("/u/jane-example"); + expect(userProfilePath({ id: "", name: " ", email: "jane.doe@example.com" })).toBe("/u/jane-doe"); + expect(userProfilePath({ email: "@example.com" })).toBe("/u/example-com"); + }); + + it("uses a placeholder when nothing identifies the user", () => { + expect(userProfilePath(undefined)).toBe("/u/me"); + expect(userProfilePath({ id: "!!!", name: "'\"" })).toBe("/u/me"); + }); +}); diff --git a/ui/src/lib/userProfileLinks.ts b/ui/src/lib/userProfileLinks.ts new file mode 100644 index 0000000000..4fcc3c6cc0 --- /dev/null +++ b/ui/src/lib/userProfileLinks.ts @@ -0,0 +1,34 @@ +interface ProfileLinkUser { + id?: string | null; + name?: string | null; + email?: string | null; +} + +function slugifyUserPart(value: string | null | undefined): string | null { + const slug = value + ?.trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || null; +} + +/** + * In-app path of a user's profile page (`/u/:userSlug`). + * + * The profile endpoint resolves `:userSlug` against every member's name, + * email local part, email, and principal id, and returns the first member + * that matches. Display names are not unique, so a name-based slug can open a + * namesake's profile instead of the intended one. The principal id is the one + * candidate that is unique per member, so links to a known user always use + * it; name and email slugs are only a fallback for sessions with no user id. + */ +export function userProfilePath(user: ProfileLinkUser | null | undefined): string { + const candidates = [user?.id, user?.name, user?.email?.split("@")[0], user?.email]; + for (const candidate of candidates) { + const slug = slugifyUserPart(candidate); + if (slug) return `/u/${slug}`; + } + return "/u/me"; +} diff --git a/ui/src/pages/UserProfile.test.tsx b/ui/src/pages/UserProfile.test.tsx new file mode 100644 index 0000000000..3d7b8517c0 --- /dev/null +++ b/ui/src/pages/UserProfile.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom + +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 type { UserProfileResponse } from "@paperclipai/shared"; +import { queryKeys } from "../lib/queryKeys"; +import { UserProfile } from "./UserProfile"; + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockUserProfilesApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockUseParams = vi.hoisted(() => vi.fn()); + +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("../api/userProfiles", () => ({ + userProfilesApi: mockUserProfilesApi, +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ + setBreadcrumbs: mockSetBreadcrumbs, + }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" }, + }), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => ( + {children} + ), + useParams: () => mockUseParams(), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function windowStats(key: "last7" | "last30" | "all", label: string): UserProfileResponse["stats"][number] { + return { + key, + label, + touchedIssues: 4, + createdIssues: 2, + completedIssues: 3, + assignedOpenIssues: 1, + commentCount: 5, + activityCount: 9, + costCents: 1200, + inputTokens: 1000, + cachedInputTokens: 200, + outputTokens: 300, + costEventCount: 2, + }; +} + +function profileFor(userId: string): UserProfileResponse { + return { + user: { + id: userId, + slug: "jane-example", + name: "Jane Example", + email: "jane@example.com", + image: null, + membershipRole: "owner", + membershipStatus: "active", + joinedAt: new Date("2026-01-05T00:00:00Z"), + }, + stats: [windowStats("last7", "Last 7 days"), windowStats("last30", "Last 30 days"), windowStats("all", "All time")], + daily: [], + recentIssues: [], + recentActivity: [], + topAgents: [], + topProviders: [], + }; +} + +const EDIT_PROFILE_HREF = "/company/settings/instance/profile"; + +describe("UserProfile", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockUseParams.mockReturnValue({ userSlug: "jane-example" }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: null, + }, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function render(profile: UserProfileResponse, hiddenSettings: string[] = []) { + mockUserProfilesApi.get.mockResolvedValue(profile); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + hiddenSettings, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + return root; + } + + it("offers an edit link on the viewer's own profile", async () => { + const root = await render(profileFor("user-1")); + + expect(container.textContent).toContain("Jane Example"); + const editLink = container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`); + expect(editLink).not.toBeNull(); + expect(editLink?.textContent).toContain("Edit profile"); + expect(editLink?.querySelector("svg")?.classList).toContain("lucide-user-round-pen"); + + await act(async () => root.unmount()); + }); + + it("keeps other users' profiles read-only", async () => { + const root = await render(profileFor("user-2")); + + expect(container.textContent).toContain("Jane Example"); + expect(container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`)).toBeNull(); + expect(container.textContent).not.toContain("Edit profile"); + + await act(async () => root.unmount()); + }); + + it("drops the edit link when the operator hides profile settings", async () => { + const root = await render(profileFor("user-1"), ["instance.profile"]); + + expect(container.textContent).toContain("Jane Example"); + expect(container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`)).toBeNull(); + + await act(async () => root.unmount()); + }); +}); diff --git a/ui/src/pages/UserProfile.tsx b/ui/src/pages/UserProfile.tsx index f91e33c2f9..5bca922917 100644 --- a/ui/src/pages/UserProfile.tsx +++ b/ui/src/pages/UserProfile.tsx @@ -1,15 +1,22 @@ import { useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, UserRound } from "lucide-react"; -import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared"; +import { AlertCircle, UserRound, UserRoundPen } from "lucide-react"; +import { + hidesInstancePage, + type UserProfileDailyPoint, + type UserProfileWindowStats, +} from "@paperclipai/shared"; import { Link, useParams } from "@/lib/router"; +import { authApi } from "../api/auth"; import { userProfilesApi } from "../api/userProfiles"; import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; +import { Button } from "../components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueStatusBadge } from "../components/StatusBadge"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; +import { useHiddenSettings } from "../hooks/useHiddenSettings"; import { queryKeys } from "../lib/queryKeys"; import { formatCents, @@ -23,6 +30,7 @@ import { } from "../lib/utils"; const NO_COMPANY = "__none__"; +const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; function initials(name: string | null | undefined) { const value = name?.trim() || "User"; @@ -205,6 +213,18 @@ export function UserProfile() { queryFn: () => userProfilesApi.get(companyId, userSlug), enabled: !!selectedCompanyId && !!userSlug, }); + // The edit link only belongs on the viewer's own profile, and never when the + // hosting operator hides the profile settings surface the link points at. + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const { hidden: hiddenSettings } = useHiddenSettings(); + const canEditProfile = + Boolean(data?.user.id) && + data?.user.id === session?.user.id && + !hidesInstancePage(hiddenSettings, "instance.profile"); useEffect(() => { setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]); @@ -280,6 +300,14 @@ export function UserProfile() { {metaParts.join(" · ")}
+ {canEditProfile ? ( + + ) : null}