Files
PaperClipAI/ui/src/components/SidebarAccountMenu.tsx
T
d6fa1fd1ef feat(ui): streamline account menu profile access and add Invite shortcut (#14480)
## 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) <bender@paperclip.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
2026-09-30 16:18:59 -07:00

297 lines
12 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
Flag,
LogOut,
Settings,
type LucideIcon,
UserPlus,
} from "lucide-react";
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";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { ThemeToggle } from "./ThemeToggle";
import { SidebarServerInfo } from "./SidebarServerInfo";
const INVITES_PATH = "/company/settings/members?tab=invites";
const DOCS_URL = "https://docs.paperclip.ing/";
const FEEDBACK_URL = "https://paperclip.ing/feedback";
interface SidebarAccountMenuProps {
deploymentMode?: DeploymentMode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Contextual navigation occupies a full sidebar even if the saved global nav mode is collapsed. */
forceExpanded?: boolean;
}
interface MenuActionProps {
label: string;
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) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
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";
const content = (
<>
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</>
);
if (href) {
if (external) {
return (
<a href={href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
{content}
</a>
);
}
if (topLevel) {
return (
<a href={href} className={className} onClick={onClick}>
{content}
</a>
);
}
return (
<Link to={href} className={className} onClick={onClick}>
{content}
</Link>
);
}
return (
<button type="button" className={className} onClick={onClick}>
{content}
</button>
);
}
export function SidebarAccountMenu({
deploymentMode,
open: controlledOpen,
onOpenChange,
forceExpanded = false,
}: SidebarAccountMenuProps) {
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;
const open = controlledOpen ?? internalOpen;
const stagingCommit = useStagingCommit(open);
const setOpen = onOpenChange ?? setInternalOpen;
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
const displayName = session?.user.name?.trim() || "Board";
const secondaryLabel =
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
const initials = deriveInitials(displayName);
const profileHref = userProfilePath(session?.user);
function closeNavigationChrome() {
setOpen(false);
if (isMobile) setSidebarOpen(false);
}
function handleSignOut() {
signOutMutation.mutate();
}
return (
<div className="bg-border/50 px-3 py-2 dark:bg-muted">
<div className={cn("flex items-center gap-0.5", !rail && "px-2")}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex min-w-0 items-center gap-2.5 rounded-lg text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
rail ? "w-full px-3 py-2" : "flex-1 px-2 py-1.5",
)}
aria-label="Open account menu"
>
<Avatar size="sm">
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
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. */}
<div className="relative flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
<Link
to={profileHref}
aria-label="View profile"
onClick={closeNavigationChrome}
className="absolute inset-0 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
/>
<Avatar className="pointer-events-none relative size-9">
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback className="text-xs text-foreground">{initials}</AvatarFallback>
</Avatar>
<div className="pointer-events-none relative min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
{displayName}
</h2>
<p className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
{secondaryLabel}
</p>
{stagingCommit ? (
<a
className="pointer-events-auto block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
href={`https://github.com/paperclipai/paperclip/commit/${stagingCommit}`}
target="_blank"
rel="noreferrer"
aria-label={`View commit ${stagingCommit} on GitHub`}
title={stagingCommit}
>
SHA {stagingCommit.slice(0, 7)}
</a>
) : null}
</div>
</div>
<div className="flex flex-1 flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
<MenuAction
label="Settings"
icon={Settings}
href="/company/settings"
onClick={closeNavigationChrome}
/>
{showInvite && inviteHref ? (
<MenuAction
label="Invite"
icon={UserPlus}
href={inviteHref}
topLevel={isCloud}
onClick={closeNavigationChrome}
/>
) : null}
<MenuAction
label="Documentation"
icon={BookOpen}
href={DOCS_URL}
external
onClick={() => setOpen(false)}
/>
<ThemeToggle variant="compact-menu-action" onAfterToggle={() => setOpen(false)} />
{deploymentMode === "authenticated" ? (
<button
type="button"
className={cn(
"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-destructive/10",
signOutMutation.isPending && "cursor-not-allowed opacity-60",
)}
onClick={handleSignOut}
disabled={signOutMutation.isPending}
>
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<LogOut className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate">
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</span>
</button>
) : null}
<SidebarServerInfo />
</div>
</PopoverContent>
</Popover>
{!rail && !isCloud ? (
<Tooltip>
<TooltipTrigger asChild>
<a
href={FEEDBACK_URL}
target="_blank"
rel="noreferrer"
aria-label="Share feedback"
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground/50 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Flag className="h-4 w-4" aria-hidden="true" />
</a>
</TooltipTrigger>
<TooltipContent side="top">Share feedback</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
);
}