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>
This commit is contained in:
authored and GitHub committed 2026-09-30 16:18:59 -07:00
1 parent f7e36ba3e2
commit d6fa1fd1ef
10 files changed
+829 -79

No files matched your search

@@ -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 (
<a href={href} className={className} onClick={onClick}>
{content}
</a>
);
}
return (
<Link to={href} className={className} onClick={onClick}>
{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({
>
<div className="h-24 bg-(image:--gradient-extract-25)" />
<div className="-mt-8 px-4 pb-4">
<div className="flex items-start gap-3">
<div className="rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
<div className="relative flex items-start gap-3">
<Link
to={profileHref}
aria-label="View profile"
onClick={closeNavigationChrome}
className="absolute inset-0 rounded-xl transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<div className="pointer-events-none relative rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
<Avatar size="lg">
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="pointer-events-none relative min-w-0 flex-1 pt-1">
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
{stagingCommit ? (
<a
className="block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
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"
@@ -192,20 +230,16 @@ export function SidebarAccountMenu({
</div>
<div className="mt-4 space-y-1">
<MenuAction
label="View profile"
description="Open your activity, task, and usage ledger."
icon={UserRound}
href={profileHref}
onClick={closeNavigationChrome}
/>
<MenuAction
label="Edit profile"
description="Update your display name and avatar."
icon={UserRoundPen}
href={PROFILE_SETTINGS_PATH}
onClick={closeNavigationChrome}
/>
{showInvite && inviteHref ? (
<MenuAction
label="Invite"
description="Invite people to your organization."
icon={UserPlus}
href={inviteHref}
topLevel={isCloud}
onClick={closeNavigationChrome}
/>
) : null}
<MenuAction
label="Documentation"
description="Open Paperclip docs in a new tab."
+285 -2
View File
@@ -20,6 +20,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
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<typeof import("@/api/access")>();
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<HTMLAnchorElement>('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<HTMLAnchorElement>(
'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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<AccountMenu deploymentMode="authenticated" open />
</TooltipProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<AccountMenu deploymentMode="authenticated" open />
</TooltipProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<AccountMenu deploymentMode="authenticated" open />
</TooltipProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<AccountMenu deploymentMode="authenticated" open />
</TooltipProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarAccountMenu deploymentMode="authenticated" open />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const popover = document.body.querySelector('[data-slot="popover-content"]');
const inviteLink = popover?.querySelector<HTMLAnchorElement>(
'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({
+64 -36
View File
@@ -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 (
<a href={href} className={className} onClick={onClick}>
{content}
</a>
);
}
return (
<Link to={href} className={className} onClick={onClick}>
{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)"
>
<div className="flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
<Avatar className="size-9">
{/* 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="min-w-0 flex-1">
<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>
@@ -184,7 +215,7 @@ export function SidebarAccountMenu({
</p>
{stagingCommit ? (
<a
className="block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
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"
@@ -204,18 +235,15 @@ export function SidebarAccountMenu({
href="/company/settings"
onClick={closeNavigationChrome}
/>
<MenuAction
label="View profile"
icon={UserRound}
href={profileHref}
onClick={closeNavigationChrome}
/>
<MenuAction
label="Edit profile"
icon={UserRoundPen}
href={PROFILE_SETTINGS_PATH}
onClick={closeNavigationChrome}
/>
{showInvite && inviteHref ? (
<MenuAction
label="Invite"
icon={UserPlus}
href={inviteHref}
topLevel={isCloud}
onClick={closeNavigationChrome}
/>
) : null}
<MenuAction
label="Documentation"
icon={BookOpen}
+36
View File
@@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { cloudApi } from "@/api/cloud";
import { queryKeys } from "@/lib/queryKeys";
import { cloudStackInviteUrl } from "@/lib/cloudLinks";
import { useCloudInstance } from "./useCloudInstance";
/**
* Where the signed-in user invites people on a Paperclip Cloud instance, or
* null when no Cloud invite link should be offered.
*
* Mirrors the Members page: Cloud manages human invitations in the current
* stack's People settings, and only that stack's owner or admin may invite
* (company roles can differ from Cloud roles, so the company membership is
* not enough). Resolves to null on self-hosted instances, while the stack
* portfolio is still loading or has failed, for every other stack role, and
* when the stack slug or cloud base URL is unknown.
*
* Callers must not substitute the in-app Invites tab when this is null on a
* Cloud instance: that tab drives in-app invitations, a different flow from
* Cloud People settings. Offer no shortcut instead.
*/
export function useCloudInviteUrl(): string | null {
const cloud = useCloudInstance();
const isCloud = Boolean(cloud);
const cloudStacksQuery = useQuery({
queryKey: queryKeys.cloud.stacks,
queryFn: () => 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);
}
@@ -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> = {}): 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);
});
});
+46
View File
@@ -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);
}
+25
View File
@@ -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");
});
});
+34
View File
@@ -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";
}
+177
View File
@@ -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 }) => (
<a href={to} {...props}>{children}</a>
),
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(
<QueryClientProvider client={queryClient}>
<UserProfile />
</QueryClientProvider>,
);
});
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<HTMLAnchorElement>(`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());
});
});
+30 -2
View File
@@ -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() {
<span>{metaParts.join(" · ")}</span>
</div>
</div>
{canEditProfile ? (
<Button asChild variant="outline" size="sm">
<Link to={PROFILE_SETTINGS_PATH}>
<UserRoundPen />
Edit profile
</Link>
</Button>
) : null}
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">