mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
feat: implement multi-user access and invite flows (#3784)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - V1 needs to stay local-first while also supporting shared, authenticated deployments. > - Human operators need real identities, company membership, invite flows, profile surfaces, and company-scoped access controls. > - Agents and operators also need the existing issue, inbox, workspace, approval, and plugin flows to keep working under those authenticated boundaries. > - This branch accumulated the multi-user implementation, follow-up QA fixes, workspace/runtime refinements, invite UX improvements, release-branch conflict resolution, and review hardening. > - This pull request consolidates that branch onto the current `master` branch as a single reviewable PR. > - The benefit is a complete multi-user implementation path with tests and docs carried forward without dropping existing branch work. ## What Changed - Added authenticated human-user access surfaces: auth/session routes, company user directory, profile settings, company access/member management, join requests, and invite management. - Added invite creation, invite landing, onboarding, logo/branding, invite grants, deduped join requests, and authenticated multi-user E2E coverage. - Tightened company-scoped and instance-admin authorization across board, plugin, adapter, access, issue, and workspace routes. - Added profile-image URL validation hardening, avatar preservation on name-only profile updates, and join-request uniqueness migration cleanup for pending human requests. - Added an atomic member role/status/grants update path so Company Access saves no longer leave partially updated permissions. - Improved issue chat, inbox, assignee identity rendering, sidebar/account/company navigation, workspace routing, and execution workspace reuse behavior for multi-user operation. - Added and updated server/UI tests covering auth, invites, membership, issue workspace inheritance, plugin authz, inbox/chat behavior, and multi-user flows. - Merged current `public-gh/master` into this branch, resolved all conflicts, and verified no `pnpm-lock.yaml` change is included in this PR diff. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx` - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/workspace-runtime-service-authz.test.ts server/src/__tests__/access-validators.test.ts` - `pnpm exec vitest run server/src/__tests__/authz-company-access.test.ts server/src/__tests__/routines-routes.test.ts server/src/__tests__/sidebar-preferences-routes.test.ts server/src/__tests__/approval-routes-idempotency.test.ts server/src/__tests__/openclaw-invite-prompt-route.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts server/src/__tests__/routines-e2e.test.ts` - `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts ui/src/pages/CompanyAccess.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm db:generate` - `npx playwright test --config tests/e2e/playwright.config.ts --list` - Confirmed branch has no uncommitted changes and is `0` commits behind `public-gh/master` before PR creation. - Confirmed no `pnpm-lock.yaml` change is staged or present in the PR diff. ## Risks - High review surface area: this PR contains the accumulated multi-user branch plus follow-up fixes, so reviewers should focus especially on company-boundary enforcement and authenticated-vs-local deployment behavior. - UI behavior changed across invites, inbox, issue chat, access settings, and sidebar navigation; no browser screenshots are included in this branch-consolidation PR. - Plugin install, upgrade, and lifecycle/config mutations now require instance-admin access, which is intentional but may change expectations for non-admin board users. - A join-request dedupe migration rejects duplicate pending human requests before creating unique indexes; deployments with unusual historical duplicates should review the migration behavior. - Company member role/status/grant saves now use a new combined endpoint; older separate endpoints remain for compatibility. - Full production build was not run locally in this heartbeat; CI should cover the full matrix. ## Model Used - OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use environment. Exact deployed model identifier and context window were not exposed by the runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Note on screenshots: this is a branch-consolidation PR for an already-developed multi-user branch, and no browser screenshots were captured during this heartbeat. --------- Co-authored-by: dotta <dotta@example.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
146
ui/src/App.test.tsx
Normal file
146
ui/src/App.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type ReactNode } 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 { CloudAccessGate } from "./components/CloudAccessGate";
|
||||
|
||||
const mockHealthApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessApi = vi.hoisted(() => ({
|
||||
getCurrentBoardAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api/health", () => ({
|
||||
healthApi: mockHealthApi,
|
||||
}));
|
||||
|
||||
vi.mock("./api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("./api/access", () => ({
|
||||
accessApi: mockAccessApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Navigate: ({ to }: { to: string }) => <div>Navigate:{to}</div>,
|
||||
Outlet: () => <div>Outlet content</div>,
|
||||
Route: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
Routes: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }),
|
||||
useParams: () => ({}),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CloudAccessGate", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
bootstrapStatus: "ready",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a no-access message for signed-in users without org access", async () => {
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
});
|
||||
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [],
|
||||
source: "session",
|
||||
keyId: null,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudAccessGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("No company access");
|
||||
expect(container.textContent).not.toContain("Outlet content");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows authenticated users with company access through to the board", async () => {
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
});
|
||||
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
keyId: null,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudAccessGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("No company access");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { OnboardingWizard } from "./components/OnboardingWizard";
|
||||
import { authApi } from "./api/auth";
|
||||
import { healthApi } from "./api/health";
|
||||
import { CloudAccessGate } from "./components/CloudAccessGate";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
@@ -25,18 +23,23 @@ import { Costs } from "./pages/Costs";
|
||||
import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyAccess } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { CompanyExport } from "./pages/CompanyExport";
|
||||
import { CompanyImport } from "./pages/CompanyImport";
|
||||
import { DesignGuide } from "./pages/DesignGuide";
|
||||
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
|
||||
import { InstanceAccess } from "./pages/InstanceAccess";
|
||||
import { InstanceSettings } from "./pages/InstanceSettings";
|
||||
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
|
||||
import { ProfileSettings } from "./pages/ProfileSettings";
|
||||
import { PluginManager } from "./pages/PluginManager";
|
||||
import { PluginSettings } from "./pages/PluginSettings";
|
||||
import { AdapterManager } from "./pages/AdapterManager";
|
||||
import { PluginPage } from "./pages/PluginPage";
|
||||
import { IssueChatUxLab } from "./pages/IssueChatUxLab";
|
||||
import { InviteUxLab } from "./pages/InviteUxLab";
|
||||
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||
import { OrgChart } from "./pages/OrgChart";
|
||||
import { NewAgent } from "./pages/NewAgent";
|
||||
@@ -44,80 +47,13 @@ import { AuthPage } from "./pages/Auth";
|
||||
import { BoardClaimPage } from "./pages/BoardClaim";
|
||||
import { CliAuthPage } from "./pages/CliAuth";
|
||||
import { InviteLandingPage } from "./pages/InviteLanding";
|
||||
import { JoinRequestQueue } from "./pages/JoinRequestQueue";
|
||||
import { NotFoundPage } from "./pages/NotFound";
|
||||
import { queryKeys } from "./lib/queryKeys";
|
||||
import { useCompany } from "./context/CompanyContext";
|
||||
import { useDialog } from "./context/DialogContext";
|
||||
import { loadLastInboxTab } from "./lib/inbox";
|
||||
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
|
||||
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudAccessGate() {
|
||||
const location = useLocation();
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as
|
||||
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
|
||||
| undefined;
|
||||
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
|
||||
? 2000
|
||||
: false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: isAuthenticatedMode,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (healthQuery.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
|
||||
{healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
const next = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
return <Navigate to={`/auth?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function boardRoutes() {
|
||||
return (
|
||||
<>
|
||||
@@ -126,6 +62,8 @@ function boardRoutes() {
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="skills/*" element={<CompanySkills />} />
|
||||
@@ -177,9 +115,11 @@ function boardRoutes() {
|
||||
<Route path="inbox/recent" element={<Inbox />} />
|
||||
<Route path="inbox/unread" element={<Inbox />} />
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
|
||||
<Route path="tests/ux/invites" element={<InviteUxLab />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
@@ -323,7 +263,9 @@ export function App() {
|
||||
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
|
||||
<Route path="instance/settings" element={<Layout />}>
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="profile" element={<ProfileSettings />} />
|
||||
<Route path="general" element={<InstanceGeneralSettings />} />
|
||||
<Route path="access" element={<InstanceAccess />} />
|
||||
<Route path="heartbeats" element={<InstanceSettings />} />
|
||||
<Route path="experimental" element={<InstanceExperimentalSettings />} />
|
||||
<Route path="plugins" element={<PluginManager />} />
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest, PermissionKey } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer";
|
||||
|
||||
type InviteSummary = {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
companyName?: string | null;
|
||||
companyLogoUrl?: string | null;
|
||||
companyBrandColor?: string | null;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole?: HumanCompanyRole | null;
|
||||
expiresAt: string;
|
||||
onboardingPath?: string;
|
||||
onboardingUrl?: string;
|
||||
@@ -15,6 +20,9 @@ type InviteSummary = {
|
||||
skillIndexPath?: string;
|
||||
skillIndexUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
invitedByUserName?: string | null;
|
||||
joinRequestStatus?: JoinRequest["status"] | null;
|
||||
joinRequestType?: JoinRequest["requestType"] | null;
|
||||
};
|
||||
|
||||
type AcceptInviteInput =
|
||||
@@ -88,17 +96,162 @@ type CompanyInviteCreated = {
|
||||
inviteUrl: string;
|
||||
expiresAt: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole?: HumanCompanyRole | null;
|
||||
companyName?: string | null;
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
};
|
||||
|
||||
export type CompanyMemberGrant = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
permissionKey: PermissionKey;
|
||||
scope: Record<string, unknown> | null;
|
||||
grantedByUserId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CompanyMember = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
status: "pending" | "active" | "suspended";
|
||||
membershipRole: HumanCompanyRole | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
grants: CompanyMemberGrant[];
|
||||
};
|
||||
|
||||
export type CompanyMembersResponse = {
|
||||
members: CompanyMember[];
|
||||
access: {
|
||||
currentUserRole: HumanCompanyRole | null;
|
||||
canManageMembers: boolean;
|
||||
canInviteUsers: boolean;
|
||||
canApproveJoinRequests: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CompanyUserDirectoryEntry = {
|
||||
principalId: string;
|
||||
status: "active";
|
||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
};
|
||||
|
||||
export type CompanyUserDirectoryResponse = {
|
||||
users: CompanyUserDirectoryEntry[];
|
||||
};
|
||||
|
||||
export type CompanyInviteRecord = {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole: HumanCompanyRole | null;
|
||||
defaultsPayload: Record<string, unknown> | null;
|
||||
expiresAt: string;
|
||||
invitedByUserId: string | null;
|
||||
revokedAt: string | null;
|
||||
acceptedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
inviteMessage: string | null;
|
||||
state: "active" | "revoked" | "accepted" | "expired";
|
||||
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
relatedJoinRequestId: string | null;
|
||||
};
|
||||
|
||||
export type CompanyInviteListResponse = {
|
||||
invites: CompanyInviteRecord[];
|
||||
nextOffset: number | null;
|
||||
};
|
||||
|
||||
export type CompanyJoinRequest = JoinRequest & {
|
||||
requesterUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
approvedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
rejectedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
invite: {
|
||||
id: string;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole: HumanCompanyRole | null;
|
||||
inviteMessage: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
acceptedAt: string | null;
|
||||
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AdminUserDirectoryEntry = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
activeCompanyMembershipCount: number;
|
||||
};
|
||||
|
||||
export type UserCompanyAccessEntry = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
status: "pending" | "active" | "suspended";
|
||||
membershipRole: HumanCompanyRole | "member" | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
companyName: string | null;
|
||||
companyStatus: "active" | "paused" | "archived" | null;
|
||||
};
|
||||
|
||||
export type UserCompanyAccessResponse = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
} | null;
|
||||
companyAccess: UserCompanyAccessEntry[];
|
||||
};
|
||||
|
||||
export type CurrentBoardAccess = {
|
||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
userId: string;
|
||||
isInstanceAdmin: boolean;
|
||||
companyIds: string[];
|
||||
source: string;
|
||||
keyId: string | null;
|
||||
};
|
||||
|
||||
function buildInviteListQuery(options: {
|
||||
state?: "active" | "revoked" | "accepted" | "expired";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.state) params.set("state", options.state);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
if (options.offset) params.set("offset", String(options.offset));
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
export const accessApi = {
|
||||
createCompanyInvite: (
|
||||
companyId: string,
|
||||
input: {
|
||||
allowedJoinTypes?: "human" | "agent" | "both";
|
||||
humanRole?: HumanCompanyRole | null;
|
||||
defaultsPayload?: Record<string, unknown> | null;
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
@@ -126,8 +279,67 @@ export const accessApi = {
|
||||
input,
|
||||
),
|
||||
|
||||
listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") =>
|
||||
api.get<JoinRequest[]>(`/companies/${companyId}/join-requests?status=${status}`),
|
||||
listInvites: (
|
||||
companyId: string,
|
||||
options: {
|
||||
state?: "active" | "revoked" | "accepted" | "expired";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
) =>
|
||||
api.get<CompanyInviteListResponse>(
|
||||
`/companies/${companyId}/invites${buildInviteListQuery(options)}`,
|
||||
),
|
||||
|
||||
revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}),
|
||||
|
||||
listJoinRequests: (
|
||||
companyId: string,
|
||||
status: "pending_approval" | "approved" | "rejected" = "pending_approval",
|
||||
requestType?: "human" | "agent",
|
||||
) =>
|
||||
api.get<CompanyJoinRequest[]>(
|
||||
`/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`,
|
||||
),
|
||||
|
||||
listMembers: (companyId: string) =>
|
||||
api.get<CompanyMembersResponse>(`/companies/${companyId}/members`),
|
||||
|
||||
listUserDirectory: (companyId: string) =>
|
||||
api.get<CompanyUserDirectoryResponse>(`/companies/${companyId}/user-directory`),
|
||||
|
||||
updateMember: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
membershipRole?: HumanCompanyRole | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
},
|
||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}`, input),
|
||||
|
||||
updateMemberPermissions: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
grants: Array<{
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}>;
|
||||
},
|
||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/permissions`, input),
|
||||
|
||||
updateMemberAccess: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
membershipRole?: HumanCompanyRole | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
grants: Array<{
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}>;
|
||||
},
|
||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
|
||||
|
||||
approveJoinRequest: (companyId: string, requestId: string) =>
|
||||
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
|
||||
@@ -158,4 +370,22 @@ export const accessApi = {
|
||||
|
||||
cancelCliAuthChallenge: (id: string, token: string) =>
|
||||
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
|
||||
|
||||
searchAdminUsers: (query: string) =>
|
||||
api.get<AdminUserDirectoryEntry[]>(`/admin/users?query=${encodeURIComponent(query)}`),
|
||||
|
||||
promoteInstanceAdmin: (userId: string) =>
|
||||
api.post(`/admin/users/${userId}/promote-instance-admin`, {}),
|
||||
|
||||
demoteInstanceAdmin: (userId: string) =>
|
||||
api.post(`/admin/users/${userId}/demote-instance-admin`, {}),
|
||||
|
||||
getUserCompanyAccess: (userId: string) =>
|
||||
api.get<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`),
|
||||
|
||||
setUserCompanyAccess: (userId: string, companyIds: string[]) =>
|
||||
api.put<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`, { companyIds }),
|
||||
|
||||
getCurrentBoardAccess: () =>
|
||||
api.get<CurrentBoardAccess>("/cli-auth/me"),
|
||||
};
|
||||
|
||||
@@ -1,27 +1,63 @@
|
||||
export type AuthSession = {
|
||||
session: { id: string; userId: string };
|
||||
user: { id: string; email: string | null; name: string | null };
|
||||
};
|
||||
import {
|
||||
authSessionSchema,
|
||||
currentUserProfileSchema,
|
||||
type AuthSession,
|
||||
type CurrentUserProfile,
|
||||
type UpdateCurrentUserProfile,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type AuthErrorBody =
|
||||
| {
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: string | { code?: string; message?: string };
|
||||
}
|
||||
| null;
|
||||
|
||||
export class AuthApiError extends Error {
|
||||
status: number;
|
||||
code: string | null;
|
||||
body: unknown;
|
||||
|
||||
constructor(message: string, status: number, body: unknown, code: string | null = null) {
|
||||
super(message);
|
||||
this.name = "AuthApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
function toSession(value: unknown): AuthSession | null {
|
||||
const direct = authSessionSchema.safeParse(value);
|
||||
if (direct.success) return direct.data;
|
||||
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const sessionValue = record.session;
|
||||
const userValue = record.user;
|
||||
if (!sessionValue || typeof sessionValue !== "object") return null;
|
||||
if (!userValue || typeof userValue !== "object") return null;
|
||||
const session = sessionValue as Record<string, unknown>;
|
||||
const user = userValue as Record<string, unknown>;
|
||||
if (typeof session.id !== "string" || typeof session.userId !== "string") return null;
|
||||
if (typeof user.id !== "string") return null;
|
||||
return {
|
||||
session: { id: session.id, userId: session.userId },
|
||||
user: {
|
||||
id: user.id,
|
||||
email: typeof user.email === "string" ? user.email : null,
|
||||
name: typeof user.name === "string" ? user.name : null,
|
||||
},
|
||||
};
|
||||
const nested = authSessionSchema.safeParse((value as Record<string, unknown>).data);
|
||||
return nested.success ? nested.data : null;
|
||||
}
|
||||
|
||||
function extractAuthError(payload: AuthErrorBody, status: number) {
|
||||
const nested =
|
||||
payload?.error && typeof payload.error === "object"
|
||||
? payload.error
|
||||
: null;
|
||||
const code =
|
||||
typeof nested?.code === "string"
|
||||
? nested.code
|
||||
: typeof payload?.code === "string"
|
||||
? payload.code
|
||||
: null;
|
||||
const message =
|
||||
typeof nested?.message === "string" && nested.message.trim().length > 0
|
||||
? nested.message
|
||||
: typeof payload?.message === "string" && payload.message.trim().length > 0
|
||||
? payload.message
|
||||
: typeof payload?.error === "string" && payload.error.trim().length > 0
|
||||
? payload.error
|
||||
: `Request failed: ${status}`;
|
||||
|
||||
return new AuthApiError(message, status, payload, code);
|
||||
}
|
||||
|
||||
async function authPost(path: string, body: Record<string, unknown>) {
|
||||
@@ -33,16 +69,25 @@ async function authPost(path: string, body: Record<string, unknown>) {
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(payload as { error?: { message?: string } | string } | null)?.error &&
|
||||
typeof (payload as { error?: { message?: string } | string }).error === "object"
|
||||
? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`)
|
||||
: (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`;
|
||||
throw new Error(message);
|
||||
throw extractAuthError(payload as AuthErrorBody, res.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function authPatch<T>(path: string, body: Record<string, unknown>, parse: (value: unknown) => T): Promise<T> {
|
||||
const res = await fetch(`/api/auth${path}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw extractAuthError(payload as AuthErrorBody, res.status);
|
||||
}
|
||||
return parse(payload);
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
getSession: async (): Promise<AuthSession | null> => {
|
||||
const res = await fetch("/api/auth/get-session", {
|
||||
@@ -68,6 +113,21 @@ export const authApi = {
|
||||
await authPost("/sign-up/email", input);
|
||||
},
|
||||
|
||||
getProfile: async (): Promise<CurrentUserProfile> => {
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new Error((payload as { error?: string } | null)?.error ?? `Failed to load profile (${res.status})`);
|
||||
}
|
||||
return currentUserProfileSchema.parse(payload);
|
||||
},
|
||||
|
||||
updateProfile: async (input: UpdateCurrentUserProfile): Promise<CurrentUserProfile> =>
|
||||
authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)),
|
||||
|
||||
signOut: async () => {
|
||||
await authPost("/sign-out", {});
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import { formatActivityVerb } from "../lib/activity-format";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
|
||||
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
||||
switch (entityType) {
|
||||
@@ -19,13 +20,14 @@ function entityLink(entityType: string, entityId: string, name?: string | null):
|
||||
interface ActivityRowProps {
|
||||
event: ActivityEvent;
|
||||
agentMap: Map<string, Agent>;
|
||||
userProfileMap?: Map<string, CompanyUserProfile>;
|
||||
entityNameMap: Map<string, string>;
|
||||
entityTitleMap?: Map<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap });
|
||||
export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap });
|
||||
|
||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||
const heartbeatAgentId = isHeartbeatEvent
|
||||
@@ -43,13 +45,16 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl
|
||||
: entityLink(event.entityType, event.entityId, name);
|
||||
|
||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
||||
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown");
|
||||
const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null;
|
||||
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown"));
|
||||
const actorAvatarUrl = userProfile?.image ?? null;
|
||||
|
||||
const inner = (
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0 truncate">
|
||||
<Identity
|
||||
name={actorName}
|
||||
avatarUrl={actorAvatarUrl}
|
||||
size="xs"
|
||||
className="align-baseline"
|
||||
/>
|
||||
|
||||
114
ui/src/components/CloudAccessGate.tsx
Normal file
114
ui/src/components/CloudAccessGate.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Navigate, Outlet, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
|
||||
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoBoardAccessPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">No company access</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This account is signed in, but it does not have an active company membership or instance-admin access on
|
||||
this Paperclip instance.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Use a company invite or sign in with an account that already belongs to this org.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloudAccessGate() {
|
||||
const location = useLocation();
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as
|
||||
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
|
||||
| undefined;
|
||||
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
|
||||
? 2000
|
||||
: false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: isAuthenticatedMode,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const boardAccessQuery = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
enabled: isAuthenticatedMode && !!sessionQuery.data,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (
|
||||
healthQuery.isLoading ||
|
||||
(isAuthenticatedMode && sessionQuery.isLoading) ||
|
||||
(isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading)
|
||||
) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (healthQuery.error || boardAccessQuery.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
|
||||
{healthQuery.error instanceof Error
|
||||
? healthQuery.error.message
|
||||
: boardAccessQuery.error instanceof Error
|
||||
? boardAccessQuery.error.message
|
||||
: "Failed to load app state"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
const next = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
return <Navigate to={`/auth?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
if (
|
||||
isAuthenticatedMode &&
|
||||
sessionQuery.data &&
|
||||
!boardAccessQuery.data?.isInstanceAdmin &&
|
||||
(boardAccessQuery.data?.companyIds.length ?? 0) === 0
|
||||
) {
|
||||
return <NoBoardAccessPage />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface CompanyPatternIconProps {
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
className?: string;
|
||||
logoFit?: "cover" | "contain";
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
@@ -165,6 +166,7 @@ export function CompanyPatternIcon({
|
||||
logoUrl,
|
||||
brandColor,
|
||||
className,
|
||||
logoFit = "cover",
|
||||
}: CompanyPatternIconProps) {
|
||||
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
|
||||
const [imageError, setImageError] = useState(false);
|
||||
@@ -189,7 +191,10 @@ export function CompanyPatternIcon({
|
||||
src={logo}
|
||||
alt={`${companyName} logo`}
|
||||
onError={() => setImageError(true)}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
className={cn(
|
||||
"absolute inset-0 h-full w-full",
|
||||
logoFit === "contain" ? "object-contain" : "object-cover",
|
||||
)}
|
||||
/>
|
||||
) : patternDataUrl ? (
|
||||
<img
|
||||
|
||||
137
ui/src/components/CompanySettingsSidebar.test.tsx
Normal file
137
ui/src/components/CompanySettingsSidebar.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// @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 { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
|
||||
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" data-to={to} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarNavItem", () => ({
|
||||
SidebarNavItem: (props: {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
badge?: number;
|
||||
}) => {
|
||||
sidebarNavItemMock(props);
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompanySettingsSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockSidebarBadgesApi.get.mockResolvedValue({
|
||||
inbox: 0,
|
||||
approvals: 0,
|
||||
failedRuns: 0,
|
||||
joinRequests: 2,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the company back link and the settings sections in the sidebar", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip");
|
||||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings",
|
||||
label: "General",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/access",
|
||||
label: "Access",
|
||||
badge: 2,
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/invites",
|
||||
label: "Invites",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
ui/src/components/CompanySettingsSidebar.tsx
Normal file
69
ui/src/components/CompanySettingsSidebar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
export function CompanySettingsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { data: badges } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.sidebarBadges(selectedCompanyId)
|
||||
: ["sidebar-badges", "__disabled__"] as const,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await sidebarBadgesApi.get(selectedCompanyId!);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: !!selectedCompanyId,
|
||||
retry: false,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">
|
||||
Company Settings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem
|
||||
to="/company/settings/access"
|
||||
label="Access"
|
||||
icon={Shield}
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees";
|
||||
import {
|
||||
buildExecutionPolicy,
|
||||
@@ -34,14 +38,27 @@ export function ExecutionParticipantPicker({
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const values = stageType === "review" ? reviewerValues : approverValues;
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(issue.companyId),
|
||||
queryFn: () => accessApi.listUserDirectory(issue.companyId),
|
||||
enabled: !!issue.companyId,
|
||||
});
|
||||
|
||||
const sortedAgents = sortAgentsByRecency(
|
||||
agents.filter((a) => a.status !== "terminated"),
|
||||
getRecentAssigneeIds(),
|
||||
);
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const otherUserOptions = useMemo(
|
||||
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
|
||||
[companyMembers?.users, currentUserId, issue.createdByUserId],
|
||||
);
|
||||
|
||||
const userLabel = (userId: string | null | undefined) =>
|
||||
formatAssigneeUserLabel(userId, currentUserId);
|
||||
formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
|
||||
const agentName = (id: string) => {
|
||||
@@ -138,6 +155,24 @@ export function ExecutionParticipantPicker({
|
||||
{creatorUserLabel ?? "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!search.trim()) return true;
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(option.id) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggle(option.id)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
|
||||
@@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
|
||||
}}>
|
||||
<DialogContent className="max-h-[85vh] overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6 [&>*]:min-w-0">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionLabel}</DialogTitle>
|
||||
<DialogDescription className="break-words text-xs sm:text-sm">
|
||||
<DialogDescription className="break-words">
|
||||
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
|
||||
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{readinessQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
) : readinessQuery.error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-destructive">
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
||||
</div>
|
||||
) : readiness ? (
|
||||
<div className="min-w-0 space-y-3 sm:space-y-4">
|
||||
<div className={`rounded-xl border px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="font-medium">
|
||||
{readiness.state === "blocked"
|
||||
? "Close is blocked"
|
||||
@@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{blockingIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking issues</h3>
|
||||
<div className="space-y-2">
|
||||
{blockingIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
@@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.blockingReasons.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason, idx) => (
|
||||
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 sm:px-3 sm:py-2 text-destructive">
|
||||
<h3 className="text-sm font-medium">Blocking reasons</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason) => (
|
||||
<li key={reason} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
@@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.warnings.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning, idx) => (
|
||||
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 sm:px-3 sm:py-2">
|
||||
<h3 className="text-sm font-medium">Warnings</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning) => (
|
||||
<li key={warning} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
@@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.git ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Git status</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">Git status</h3>
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||
<div className="truncate font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
|
||||
<div className="truncate font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
||||
@@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{otherLinkedIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||
<div className="space-y-2">
|
||||
{otherLinkedIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
@@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
|
||||
{readiness.runtimeServices.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||
@@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.plannedActions.map((action, index) => (
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||
{action.command ? (
|
||||
@@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
</section>
|
||||
|
||||
{currentStatus === "cleanup_failed" ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
|
||||
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
|
||||
workspace status if it succeeds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentStatus === "archived" ? (
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
This workspace is already archived.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{readiness.git?.repoRoot ? (
|
||||
<div className="overflow-hidden break-words text-xs text-muted-foreground">
|
||||
<div className="break-words text-xs text-muted-foreground">
|
||||
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
|
||||
{readiness.git.workspacePath ? (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -23,7 +23,9 @@ export function InstanceSidebar() {
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/instance/settings/profile" label="Profile" icon={UserRoundPen} end />
|
||||
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem to="/instance/settings/access" label="Access" icon={Shield} end />
|
||||
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
||||
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
|
||||
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
||||
|
||||
@@ -5,7 +5,12 @@ import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
import {
|
||||
IssueChatThread,
|
||||
canStopIssueChatRun,
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
@@ -661,4 +666,33 @@ describe("IssueChatThread", () => {
|
||||
activeRunIds: new Set<string>(),
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("uses company profile data to distinguish the current user from other humans", () => {
|
||||
const userProfileMap = new Map([
|
||||
["user-1", { label: "Dotta", image: "/avatars/dotta.png" }],
|
||||
["user-2", { label: "Alice", image: "/avatars/alice.png" }],
|
||||
]);
|
||||
|
||||
expect(resolveIssueChatHumanAuthor({
|
||||
authorName: "You",
|
||||
authorUserId: "user-1",
|
||||
currentUserId: "user-1",
|
||||
userProfileMap,
|
||||
})).toEqual({
|
||||
isCurrentUser: true,
|
||||
authorName: "Dotta",
|
||||
avatarUrl: "/avatars/dotta.png",
|
||||
});
|
||||
|
||||
expect(resolveIssueChatHumanAuthor({
|
||||
authorName: "Alice",
|
||||
authorUserId: "user-2",
|
||||
currentUserId: "user-1",
|
||||
userProfileMap,
|
||||
})).toEqual({
|
||||
isCurrentUser: false,
|
||||
authorName: "Alice",
|
||||
avatarUrl: "/avatars/alice.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
shouldPreserveComposerViewport,
|
||||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
describeToolInput,
|
||||
@@ -96,6 +97,8 @@ interface IssueChatMessageContext {
|
||||
feedbackTermsUrl: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
@@ -217,6 +220,8 @@ interface IssueChatThreadProps {
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
@@ -477,12 +482,13 @@ function formatTimelineAssigneeLabel(
|
||||
assignee: IssueTimelineAssignee,
|
||||
agentMap?: Map<string, Agent>,
|
||||
currentUserId?: string | null,
|
||||
userLabelMap?: ReadonlyMap<string, string> | null,
|
||||
) {
|
||||
if (assignee.agentId) {
|
||||
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
|
||||
}
|
||||
if (assignee.userId) {
|
||||
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
|
||||
return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board";
|
||||
}
|
||||
return "Unassigned";
|
||||
}
|
||||
@@ -495,6 +501,26 @@ function initialsForName(name: string) {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function resolveIssueChatHumanAuthor(args: {
|
||||
authorName?: string | null;
|
||||
authorUserId?: string | null;
|
||||
currentUserId?: string | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
}) {
|
||||
const { authorName, authorUserId, currentUserId, userProfileMap } = args;
|
||||
const profile = authorUserId ? userProfileMap?.get(authorUserId) ?? null : null;
|
||||
const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId);
|
||||
const resolvedAuthorName = profile?.label?.trim()
|
||||
|| authorName?.trim()
|
||||
|| (authorUserId === "local-board" ? "Board" : (isCurrentUser ? "You" : "User"));
|
||||
|
||||
return {
|
||||
isCurrentUser,
|
||||
authorName: resolvedAuthorName,
|
||||
avatarUrl: profile?.image ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRunStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "timed_out":
|
||||
@@ -906,108 +932,151 @@ function IssueChatToolPart({
|
||||
}
|
||||
|
||||
function IssueChatUserMessage() {
|
||||
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||
const {
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
} = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||||
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
|
||||
const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null;
|
||||
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
|
||||
const pending = custom.clientStatus === "pending";
|
||||
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const {
|
||||
isCurrentUser,
|
||||
authorName: resolvedAuthorName,
|
||||
avatarUrl,
|
||||
} = resolveIssueChatHumanAuthor({
|
||||
authorName,
|
||||
authorUserId,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
});
|
||||
const authorAvatar = (
|
||||
<Avatar size="sm" className="mt-1 shrink-0">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
|
||||
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
const messageBody = (
|
||||
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
|
||||
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||
queued
|
||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||
: "bg-muted",
|
||||
pending && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{queued ? (
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
{queueTargetRunId && onInterruptQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||||
>
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<div className={cn("mt-1 flex px-1 text-[11px] text-muted-foreground", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||
Sending...
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100",
|
||||
isCurrentUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div className="group flex items-start justify-end gap-2.5">
|
||||
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||
queued
|
||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||
: "bg-muted",
|
||||
pending && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{queued ? (
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
{queueTargetRunId && onInterruptQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||||
>
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar size="sm" className="mt-1 shrink-0">
|
||||
<AvatarFallback>You</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
|
||||
{isCurrentUser ? (
|
||||
<>
|
||||
{messageBody}
|
||||
{authorAvatar}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{authorAvatar}
|
||||
{messageBody}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
@@ -1463,7 +1532,7 @@ function IssueChatFeedbackButtons({
|
||||
}
|
||||
|
||||
function IssueChatSystemMessage() {
|
||||
const { agentMap, currentUserId } = useContext(IssueChatCtx);
|
||||
const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
@@ -1519,11 +1588,11 @@ function IssueChatSystemMessage() {
|
||||
Assignee
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId)}
|
||||
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId)}
|
||||
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1855,6 +1924,8 @@ export function IssueChatThread({
|
||||
issueStatus,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
onVote,
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
@@ -1947,6 +2018,7 @@ export function IssueChatThread({
|
||||
projectId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
}),
|
||||
[
|
||||
comments,
|
||||
@@ -1961,6 +2033,7 @@ export function IssueChatThread({
|
||||
projectId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
],
|
||||
);
|
||||
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
|
||||
@@ -2028,6 +2101,8 @@ export function IssueChatThread({
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
@@ -2043,6 +2118,8 @@ export function IssueChatThread({
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
|
||||
@@ -196,6 +196,8 @@ export function InboxIssueTrailingColumns({
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
assigneeName,
|
||||
assigneeUserName,
|
||||
assigneeUserAvatarUrl,
|
||||
currentUserId,
|
||||
parentIdentifier,
|
||||
parentTitle,
|
||||
@@ -209,6 +211,8 @@ export function InboxIssueTrailingColumns({
|
||||
workspaceId?: string | null;
|
||||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
assigneeUserName?: string | null;
|
||||
assigneeUserAvatarUrl?: string | null;
|
||||
currentUserId: string | null;
|
||||
parentIdentifier: string | null;
|
||||
parentTitle: string | null;
|
||||
@@ -216,7 +220,7 @@ export function InboxIssueTrailingColumns({
|
||||
onFilterWorkspace?: (workspaceId: string) => void;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -243,8 +247,13 @@ export function InboxIssueTrailingColumns({
|
||||
|
||||
if (issue.assigneeUserId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{userLabel}
|
||||
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||
<Identity
|
||||
name={userLabel}
|
||||
avatarUrl={assigneeUserAvatarUrl}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
@@ -187,6 +189,11 @@ export function IssueProperties({
|
||||
queryFn: () => agentsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(companyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId!),
|
||||
@@ -257,13 +264,21 @@ export function IssueProperties({
|
||||
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
||||
[agents, recentAssigneeIds],
|
||||
);
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const otherUserOptions = useMemo(
|
||||
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
|
||||
[companyMembers?.users, currentUserId, issue.createdByUserId],
|
||||
);
|
||||
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
|
||||
@@ -499,6 +514,31 @@ export function IssueProperties({
|
||||
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
const q = assigneeSearch.toLowerCase();
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q);
|
||||
})
|
||||
.map((option) => {
|
||||
const userId = option.id.slice("user:".length);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
issue.assigneeUserId === userId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
|
||||
setAssigneeOpen(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sortedAgents
|
||||
.filter((a) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
@@ -571,6 +611,24 @@ export function IssueProperties({
|
||||
{creatorUserLabel ? creatorUserLabel : "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!search.trim()) return true;
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((option) => (
|
||||
<button
|
||||
key={`${stageType}:${option.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(option.id) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, option.id)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
|
||||
@@ -26,6 +26,11 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessApi = vi.hoisted(() => ({
|
||||
listMembers: vi.fn(),
|
||||
listUserDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listSummaries: vi.fn(),
|
||||
@@ -51,6 +56,10 @@ vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/access", () => ({
|
||||
accessApi: mockAccessApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/execution-workspaces", () => ({
|
||||
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||
}));
|
||||
@@ -183,12 +192,16 @@ describe("IssuesList", () => {
|
||||
mockIssuesApi.list.mockReset();
|
||||
mockIssuesApi.listLabels.mockReset();
|
||||
mockAuthApi.getSession.mockReset();
|
||||
mockAccessApi.listMembers.mockReset();
|
||||
mockAccessApi.listUserDirectory.mockReset();
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||
mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} });
|
||||
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
@@ -498,6 +511,50 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows human assignee names from company member profiles", async () => {
|
||||
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"]));
|
||||
mockAccessApi.listUserDirectory.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
principalId: "user-2",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-2",
|
||||
name: "Jordan Lee",
|
||||
email: "jordan@example.com",
|
||||
image: "https://example.com/jordan.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assignedIssue = createIssue({
|
||||
id: "issue-human",
|
||||
identifier: "PAP-12",
|
||||
title: "Human assigned issue",
|
||||
assigneeUserId: "user-2",
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[assignedIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Jordan Lee");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stored grouping across refresh when initial assignees are applied", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
@@ -282,6 +284,11 @@ export function IssuesList({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
@@ -376,6 +383,15 @@ export function IssuesList({
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
}, [agents]);
|
||||
|
||||
const companyUserLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const companyUserProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
|
||||
const projectById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; color: string | null }>();
|
||||
for (const project of projects ?? []) {
|
||||
@@ -601,11 +617,11 @@ export function IssuesList({
|
||||
key === "__unassigned"
|
||||
? "Unassigned"
|
||||
: key.startsWith("__user:")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId, companyUserLabelMap) ?? "User")
|
||||
: (agentName(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewState.viewMode !== "list") return;
|
||||
@@ -910,6 +926,14 @@ export function IssuesList({
|
||||
const useDeferredRowRendering = !(hasChildren && isExpanded);
|
||||
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
||||
const assigneeUserProfile = issue.assigneeUserId
|
||||
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
|
||||
: null;
|
||||
const assigneeUserLabel = formatAssigneeUserLabel(
|
||||
issue.assigneeUserId,
|
||||
currentUserId,
|
||||
companyUserLabelMap,
|
||||
) ?? assigneeUserProfile?.label ?? null;
|
||||
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -994,6 +1018,8 @@ export function IssuesList({
|
||||
})}
|
||||
onFilterWorkspace={filterToWorkspace}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
assigneeUserName={assigneeUserLabel}
|
||||
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={parentIssue?.identifier ?? null}
|
||||
parentTitle={parentIssue?.title ?? null}
|
||||
@@ -1007,18 +1033,18 @@ export function IssuesList({
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
className="flex w-full shrink-0 items-center overflow-hidden rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" className="min-w-0" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
<Identity
|
||||
name={assigneeUserLabel ?? "User"}
|
||||
avatarUrl={assigneeUserProfile?.image ?? null}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
|
||||
249
ui/src/components/Layout.test.tsx
Normal file
249
ui/src/components/Layout.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
// @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 { Layout } from "./Layout";
|
||||
|
||||
const mockHealthApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
let currentPathname = "/PAP/dashboard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Outlet: () => <div>Outlet content</div>,
|
||||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
|
||||
useNavigate: () => mockNavigate,
|
||||
useNavigationType: () => "PUSH",
|
||||
useParams: () => ({ companyPrefix: "PAP" }),
|
||||
}));
|
||||
|
||||
vi.mock("./CompanyRail", () => ({
|
||||
CompanyRail: () => <div>Company rail</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./Sidebar", () => ({
|
||||
Sidebar: () => <div>Main company nav</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./InstanceSidebar", () => ({
|
||||
InstanceSidebar: () => <div>Instance sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./CompanySettingsSidebar", () => ({
|
||||
CompanySettingsSidebar: () => <div>Company settings sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./BreadcrumbBar", () => ({
|
||||
BreadcrumbBar: () => <div>Breadcrumbs</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./PropertiesPanel", () => ({
|
||||
PropertiesPanel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./CommandPalette", () => ({
|
||||
CommandPalette: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewIssueDialog", () => ({
|
||||
NewIssueDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewProjectDialog", () => ({
|
||||
NewProjectDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewGoalDialog", () => ({
|
||||
NewGoalDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewAgentDialog", () => ({
|
||||
NewAgentDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./KeyboardShortcutsCheatsheet", () => ({
|
||||
KeyboardShortcutsCheatsheet: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./ToastViewport", () => ({
|
||||
ToastViewport: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./MobileBottomNav", () => ({
|
||||
MobileBottomNav: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./WorktreeBanner", () => ({
|
||||
WorktreeBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./DevRestartBanner", () => ({
|
||||
DevRestartBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAccountMenu", () => ({
|
||||
SidebarAccountMenu: () => <div>Account menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
openOnboarding: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
usePanel: () => ({
|
||||
togglePanelVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
loading: false,
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
selectedCompanyId: "company-1",
|
||||
selectionSource: "manual",
|
||||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
sidebarOpen: true,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
toggleSidebar: vi.fn(),
|
||||
isMobile: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useKeyboardShortcuts", () => ({
|
||||
useKeyboardShortcuts: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useCompanyPageMemory", () => ({
|
||||
useCompanyPageMemory: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../api/health", () => ({
|
||||
healthApi: mockHealthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/company-selection", () => ({
|
||||
shouldSyncCompanySelectionFromRoute: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/instance-settings", () => ({
|
||||
DEFAULT_INSTANCE_SETTINGS_PATH: "/instance/settings/general",
|
||||
normalizeRememberedInstanceSettingsPath: (value: string | null | undefined) =>
|
||||
value ?? "/instance/settings/general",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/main-content-focus", () => ({
|
||||
scheduleMainContentFocus: () => () => undefined,
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Layout", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPathname = "/PAP/dashboard";
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
version: "1.2.3",
|
||||
});
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not render the deployment explainer in the shared layout", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockHealthApi.get).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Breadcrumbs");
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("Authenticated private");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Sign-in is required and this instance is intended for private-network access.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the company settings sidebar on company settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -17,12 +17,12 @@ import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
@@ -34,15 +34,12 @@ import {
|
||||
} from "../lib/instance-settings";
|
||||
import {
|
||||
resetNavigationScroll,
|
||||
SIDEBAR_SCROLL_RESET_STATE,
|
||||
shouldResetScrollOnNavigation,
|
||||
} from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
|
||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||
|
||||
@@ -67,12 +64,12 @@ export function Layout() {
|
||||
selectionSource,
|
||||
setSelectedCompanyId,
|
||||
} = useCompany();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
|
||||
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const previousPathname = useRef<string | null>(null);
|
||||
@@ -80,7 +77,6 @@ export function Layout() {
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
@@ -341,53 +337,19 @@ export function Layout() {
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<CompanyRail />
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
instanceSettingsTarget={instanceSettingsTarget}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
@@ -399,54 +361,20 @@ export function Layout() {
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
instanceSettingsTarget={instanceSettingsTarget}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, buildUserMentionHref } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -75,17 +75,19 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain('alt="Org chart"');
|
||||
});
|
||||
|
||||
it("renders agent, project, and skill mentions as chips", () => {
|
||||
it("renders user, agent, project, and skill mentions as chips", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/company/settings/access"');
|
||||
expect(html).toContain('data-mention-kind="user"');
|
||||
expect(html).toContain('href="/agents/agent-123"');
|
||||
expect(html).toContain('data-mention-kind="agent"');
|
||||
expect(html).toContain("--paperclip-mention-icon-mask");
|
||||
|
||||
@@ -225,6 +225,8 @@ export function MarkdownBody({
|
||||
? `/projects/${parsed.projectId}`
|
||||
: parsed.kind === "skill"
|
||||
? `/skills/${parsed.skillId}`
|
||||
: parsed.kind === "user"
|
||||
? "/company/settings/access"
|
||||
: `/agents/${parsed.agentId}`;
|
||||
return (
|
||||
<a
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
thematicBreakPlugin,
|
||||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildUserMentionHref } from "@paperclipai/shared";
|
||||
import { Boxes, User } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
||||
@@ -48,11 +48,12 @@ import { useEditorAutocomplete, type SkillCommandOption } from "../context/Edito
|
||||
export interface MentionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
kind?: "agent" | "project";
|
||||
kind?: "agent" | "project" | "user";
|
||||
agentId?: string;
|
||||
agentIcon?: string | null;
|
||||
projectId?: string;
|
||||
projectColor?: string | null;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/* ---- Editor props ---- */
|
||||
@@ -354,6 +355,9 @@ function mentionMarkdown(option: MentionOption): string {
|
||||
if (option.kind === "project" && option.projectId) {
|
||||
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
|
||||
}
|
||||
if (option.kind === "user" && option.userId) {
|
||||
return `[@${option.name}](${buildUserMentionHref(option.userId)}) `;
|
||||
}
|
||||
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
||||
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
|
||||
}
|
||||
@@ -400,6 +404,9 @@ function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string)
|
||||
if (option.kind === "project" && option.projectId) {
|
||||
return parsed.kind === "project" && parsed.projectId === option.projectId;
|
||||
}
|
||||
if (option.kind === "user" && option.userId) {
|
||||
return parsed.kind === "user" && parsed.userId === option.userId;
|
||||
}
|
||||
|
||||
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
||||
return parsed.kind === "agent" && parsed.agentId === agentId;
|
||||
@@ -527,6 +534,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const agentId = mention.agentId ?? mention.id.replace(/^agent:/, "");
|
||||
map.set(`agent:${agentId}`, mention);
|
||||
}
|
||||
if (mention.kind === "user" && mention.userId) {
|
||||
map.set(`user:${mention.userId}`, mention);
|
||||
}
|
||||
if (mention.kind === "project" && mention.projectId) {
|
||||
map.set(`project:${mention.projectId}`, mention);
|
||||
}
|
||||
@@ -717,6 +727,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.kind === "user") {
|
||||
applyMentionChipDecoration(link, parsed);
|
||||
continue;
|
||||
}
|
||||
|
||||
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
|
||||
applyMentionChipDecoration(link, {
|
||||
...parsed,
|
||||
@@ -1098,7 +1113,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={mentionMenuPosition ?? undefined}
|
||||
style={{
|
||||
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
|
||||
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
|
||||
}}
|
||||
>
|
||||
{filteredMentions.map((option, i) => (
|
||||
<button
|
||||
@@ -1125,6 +1143,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
||||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||
/>
|
||||
) : option.kind === "user" ? (
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<AgentIcon
|
||||
icon={option.agentIcon}
|
||||
@@ -1137,6 +1157,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
Project
|
||||
</span>
|
||||
)}
|
||||
{option.kind === "user" && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
User
|
||||
</span>
|
||||
)}
|
||||
{option.kind === "skill" && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Skill
|
||||
|
||||
@@ -8,8 +8,10 @@ import { issuesApi } from "../api/issues";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
@@ -353,6 +355,11 @@ export function NewIssueDialog() {
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(effectiveCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(effectiveCompanyId!),
|
||||
enabled: Boolean(effectiveCompanyId) && newIssueOpen,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
@@ -379,30 +386,12 @@ export function NewIssueDialog() {
|
||||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||
);
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
agentIcon: agent.icon,
|
||||
});
|
||||
}
|
||||
for (const project of orderedProjects) {
|
||||
options.push({
|
||||
id: `project:${project.id}`,
|
||||
name: project.name,
|
||||
kind: "project",
|
||||
projectId: project.id,
|
||||
projectColor: project.color,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents, orderedProjects]);
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
projects: orderedProjects,
|
||||
members: companyMembers?.users,
|
||||
});
|
||||
}, [agents, companyMembers?.users, orderedProjects]);
|
||||
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey:
|
||||
@@ -868,6 +857,7 @@ export function NewIssueDialog() {
|
||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||
() => [
|
||||
...currentUserAssigneeOption(currentUserId),
|
||||
...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }),
|
||||
...sortAgentsByRecency(
|
||||
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||
recentAssigneeIds,
|
||||
@@ -877,7 +867,7 @@ export function NewIssueDialog() {
|
||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
],
|
||||
[agents, currentUserId, recentAssigneeIds],
|
||||
[agents, companyMembers?.users, currentUserId, recentAssigneeIds],
|
||||
);
|
||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { accessApi } from "../api/access";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -75,22 +77,18 @@ export function NewProjectDialog() {
|
||||
enabled: !!selectedCompanyId && newProjectOpen,
|
||||
});
|
||||
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newProjectOpen,
|
||||
});
|
||||
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
agentIcon: agent.icon,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents]);
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
members: companyMembers?.users,
|
||||
});
|
||||
}, [agents, companyMembers?.users]);
|
||||
|
||||
const createProject = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
@@ -50,15 +51,7 @@ export function Sidebar() {
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
{selectedCompany?.brandColor && (
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm shrink-0 ml-1"
|
||||
style={{ backgroundColor: selectedCompany.brandColor }}
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
</span>
|
||||
<SidebarCompanyMenu />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
117
ui/src/components/SidebarAccountMenu.test.tsx
Normal file
117
ui/src/components/SidebarAccountMenu.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
// @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 { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
signInEmail: vi.fn(),
|
||||
signUpEmail: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
const mockToggleTheme = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/ThemeContext", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "dark",
|
||||
toggleTheme: mockToggleTheme,
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("SidebarAccountMenu", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: "https://example.com/jane.png",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the signed-in user and opens the account card menu", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
instanceSettingsTarget="/instance/settings/general"
|
||||
version="1.2.3"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Jane Example");
|
||||
expect(container.textContent).not.toContain("jane@example.com");
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open account menu"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Edit profile");
|
||||
expect(document.body.textContent).toContain("Documentation");
|
||||
expect(document.body.textContent).toContain("Paperclip v1.2.3");
|
||||
expect(document.body.textContent).toContain("jane@example.com");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
227
ui/src/components/SidebarAccountMenu.tsx
Normal file
227
ui/src/components/SidebarAccountMenu.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
LogOut,
|
||||
type LucideIcon,
|
||||
Moon,
|
||||
Settings,
|
||||
Sun,
|
||||
UserRoundPen,
|
||||
} from "lucide-react";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const PROFILE_SETTINGS_PATH = "/instance/settings/profile";
|
||||
const DOCS_URL = "https://docs.paperclip.ing/";
|
||||
|
||||
interface SidebarAccountMenuProps {
|
||||
deploymentMode?: DeploymentMode;
|
||||
instanceSettingsTarget: string;
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
interface MenuActionProps {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
external?: 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, description, icon: Icon, onClick, href, external = 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";
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer" 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,
|
||||
instanceSettingsTarget,
|
||||
version,
|
||||
}: SidebarAccountMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = session?.user.name?.trim() || "Board";
|
||||
const secondaryLabel =
|
||||
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
||||
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
|
||||
const initials = deriveInitials(displayName);
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-r border-border bg-background px-3 py-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px] font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
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="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
>
|
||||
<div className="h-24 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]" />
|
||||
<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">
|
||||
<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="flex items-center gap-2">
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{accountBadge}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
||||
{version ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
description="Update your display name and avatar."
|
||||
icon={UserRoundPen}
|
||||
href={PROFILE_SETTINGS_PATH}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Instance settings"
|
||||
description="Jump back to the last settings page you opened."
|
||||
icon={Settings}
|
||||
href={instanceSettingsTarget}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Documentation"
|
||||
description="Open Paperclip docs in a new tab."
|
||||
icon={BookOpen}
|
||||
href={DOCS_URL}
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<MenuAction
|
||||
label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
description="Toggle the app appearance."
|
||||
icon={theme === "dark" ? Sun : Moon}
|
||||
onClick={() => {
|
||||
toggleTheme();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
{deploymentMode === "authenticated" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-destructive/10",
|
||||
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<LogOut className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
End this browser session.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
ui/src/components/SidebarCompanyMenu.test.tsx
Normal file
125
ui/src/components/SidebarCompanyMenu.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
// @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 { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
signInEmail: vi.fn(),
|
||||
signUpEmail: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: {
|
||||
id: "company-1",
|
||||
name: "Acme Labs",
|
||||
brandColor: "#3366ff",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("SidebarCompanyMenu", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
},
|
||||
});
|
||||
mockAuthApi.signOut.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the requested company actions and signs out through the dropdown", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenu />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Acme Labs");
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open Acme Labs menu"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||
expect(document.body.textContent).toContain("Company settings");
|
||||
expect(document.body.textContent).toContain("Sign out");
|
||||
|
||||
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Sign out"));
|
||||
expect(signOutButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
102
ui/src/components/SidebarCompanyMenu.tsx
Normal file
102
ui/src/components/SidebarCompanyMenu.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, LogOut, Settings, UserPlus } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
|
||||
export function SidebarCompanyMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompany } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
},
|
||||
});
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto flex-1 justify-start gap-1 px-2 py-1.5 text-left"
|
||||
aria-label={selectedCompany ? `Open ${selectedCompany.name} menu` : "Open company menu"}
|
||||
disabled={!selectedCompany}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{selectedCompany?.brandColor ? (
|
||||
<span
|
||||
className="size-4 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: selectedCompany.brandColor }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-sm font-bold text-foreground">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="truncate">
|
||||
{selectedCompany?.name ?? "Company"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings/invites" onClick={closeNavigationChrome}>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{selectedCompany ? `Invite people to ${selectedCompany.name}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings" onClick={closeNavigationChrome}>
|
||||
<Settings className="size-4" />
|
||||
<span>Company settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{session?.session ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
99
ui/src/components/access/CompanySettingsNav.test.tsx
Normal file
99
ui/src/components/access/CompanySettingsNav.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanySettingsNav, getCompanySettingsTab } from "./CompanySettingsNav";
|
||||
|
||||
let currentPathname = "/company/settings";
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const pageTabBarMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "" }),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
Tabs: ({ children }: { children: React.ReactNode }) => <div data-testid="tabs-root">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/PageTabBar", () => ({
|
||||
PageTabBar: (props: {
|
||||
items: Array<{ value: string; label: string }>;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}) => {
|
||||
pageTabBarMock(props);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="active-tab">{props.value}</div>
|
||||
<button type="button" onClick={() => props.onValueChange?.("invites")}>
|
||||
switch-tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CompanySettingsNav", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPathname = "/company/settings";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps company settings routes to the expected shared tab value", () => {
|
||||
expect(getCompanySettingsTab("/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
|
||||
});
|
||||
|
||||
it("renders the active tab and navigates when a different tab is selected", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CompanySettingsNav />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("access");
|
||||
expect(pageTabBarMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: "access",
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "access", label: "Access" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/company/settings/invites");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
46
ui/src/components/access/CompanySettingsNav.tsx
Normal file
46
ui/src/components/access/CompanySettingsNav.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
|
||||
const items = [
|
||||
{ value: "general", label: "General", href: "/company/settings" },
|
||||
{ value: "access", label: "Access", href: "/company/settings/access" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
] as const;
|
||||
|
||||
type CompanySettingsTab = (typeof items)[number]["value"];
|
||||
|
||||
export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
if (pathname.includes("/company/settings/access")) {
|
||||
return "access";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/invites")) {
|
||||
return "invites";
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
export function CompanySettingsNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeTab = getCompanySettingsTab(location.pathname);
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const nextTab = items.find((item) => item.value === value);
|
||||
if (!nextTab || nextTab.value === activeTab) return;
|
||||
navigate(nextTab.href);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<PageTabBar
|
||||
items={items.map(({ value, label }) => ({ value, label }))}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
align="start"
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
19
ui/src/components/access/ModeBadge.tsx
Normal file
19
ui/src/components/access/ModeBadge.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function ModeBadge({
|
||||
deploymentMode,
|
||||
deploymentExposure,
|
||||
}: {
|
||||
deploymentMode?: DeploymentMode;
|
||||
deploymentExposure?: DeploymentExposure;
|
||||
}) {
|
||||
if (!deploymentMode) return null;
|
||||
|
||||
const label =
|
||||
deploymentMode === "local_trusted"
|
||||
? "Local trusted"
|
||||
: `Authenticated ${deploymentExposure ?? "private"}`;
|
||||
|
||||
return <Badge variant="outline">{label}</Badge>;
|
||||
}
|
||||
@@ -113,11 +113,23 @@ export function ToggleField({
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
{hint && <HintIcon text={hint} />}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
<button
|
||||
data-slot="toggle"
|
||||
data-testid={toggleTestId}
|
||||
/>
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
checked ? "bg-green-600" : "bg-muted"
|
||||
)}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
checked ? "translate-x-4.5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,55 +670,3 @@ describe("LiveUpdatesProvider run lifecycle toasts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider socket helpers", () => {
|
||||
it("waits for the selected company object to catch up before connecting", () => {
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull();
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-2")).toBeNull();
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-1")).toBe("company-1");
|
||||
});
|
||||
|
||||
it("defers close until onopen for sockets that are still connecting", () => {
|
||||
const socket = {
|
||||
readyState: 0,
|
||||
onopen: (() => undefined) as (() => void) | null,
|
||||
onmessage: (() => undefined) as (() => void) | null,
|
||||
onerror: (() => undefined) as (() => void) | null,
|
||||
onclose: (() => undefined) as (() => void) | null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "provider_unmount");
|
||||
|
||||
expect(socket.close).not.toHaveBeenCalled();
|
||||
expect(socket.onmessage).toBeNull();
|
||||
expect(socket.onclose).toBeNull();
|
||||
expect(socket.onopen).toBeTypeOf("function");
|
||||
expect(socket.onerror).toBeTypeOf("function");
|
||||
|
||||
socket.onopen?.();
|
||||
|
||||
expect(socket.close).toHaveBeenCalledWith(1000, "provider_unmount");
|
||||
expect(socket.onopen).toBeNull();
|
||||
expect(socket.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("closes open sockets immediately without leaving handlers behind", () => {
|
||||
const socket = {
|
||||
readyState: 1,
|
||||
onopen: (() => undefined) as (() => void) | null,
|
||||
onmessage: (() => undefined) as (() => void) | null,
|
||||
onerror: (() => undefined) as (() => void) | null,
|
||||
onclose: (() => undefined) as (() => void) | null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "stale_connection");
|
||||
|
||||
expect(socket.close).toHaveBeenCalledWith(1000, "stale_connection");
|
||||
expect(socket.onopen).toBeNull();
|
||||
expect(socket.onmessage).toBeNull();
|
||||
expect(socket.onerror).toBeNull();
|
||||
expect(socket.onclose).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@
|
||||
import type { Agent, Issue, IssueComment, LiveEvent } from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "../api/activity";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import type { CompanyUserDirectoryResponse } from "../api/access";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompany } from "./CompanyContext";
|
||||
@@ -52,6 +53,19 @@ function resolveAgentName(
|
||||
return agent?.name ?? null;
|
||||
}
|
||||
|
||||
function resolveUserName(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
): string | null {
|
||||
const directory = queryClient.getQueryData<CompanyUserDirectoryResponse>(
|
||||
queryKeys.access.companyUserDirectory(companyId),
|
||||
);
|
||||
if (!directory) return null;
|
||||
const entry = directory.users.find((u) => u.principalId === userId);
|
||||
return entry?.user?.name?.trim() || entry?.user?.email?.trim() || null;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max - 1) + "\u2026";
|
||||
@@ -68,7 +82,7 @@ function resolveActorLabel(
|
||||
}
|
||||
if (actorType === "system") return "System";
|
||||
if (actorType === "user" && actorId) {
|
||||
return "Board";
|
||||
return resolveUserName(queryClient, companyId, actorId) ?? "Board";
|
||||
}
|
||||
return "Someone";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { accessApi } from "../api/access";
|
||||
import { ApiError } from "../api/client";
|
||||
import { inboxDismissalsApi } from "../api/inboxDismissals";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { authApi } from "../api/auth";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
@@ -139,6 +140,10 @@ export function useReadInboxItems() {
|
||||
export function useInboxBadge(companyId: string | null | undefined) {
|
||||
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey } = useInboxDismissals(companyId);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
|
||||
const { data: approvals = [] } = useQuery({
|
||||
queryKey: queryKeys.approvals.list(companyId!),
|
||||
@@ -180,6 +185,7 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
||||
});
|
||||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const { data: heartbeatRuns = [] } = useQuery({
|
||||
queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT],
|
||||
@@ -197,7 +203,8 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
||||
mineIssues,
|
||||
dismissedAlerts,
|
||||
dismissedAtByKey,
|
||||
currentUserId,
|
||||
}),
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey, currentUserId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "./company-members";
|
||||
|
||||
type ActivityDetails = Record<string, unknown> | null | undefined;
|
||||
|
||||
@@ -16,6 +17,7 @@ type ActivityIssueReference = {
|
||||
|
||||
interface ActivityFormatOptions {
|
||||
agentMap?: Map<string, Agent>;
|
||||
userProfileMap?: Map<string, CompanyUserProfile>;
|
||||
currentUserId?: string | null;
|
||||
}
|
||||
|
||||
@@ -118,9 +120,11 @@ function readIssueReferences(details: ActivityDetails, key: string): ActivityIss
|
||||
return value.filter(isActivityIssueReference);
|
||||
}
|
||||
|
||||
function formatUserLabel(userId: string | null | undefined, currentUserId?: string | null): string {
|
||||
function formatUserLabel(userId: string | null | undefined, options: ActivityFormatOptions = {}): string {
|
||||
if (!userId || userId === "local-board") return "Board";
|
||||
if (currentUserId && userId === currentUserId) return "You";
|
||||
if (options.currentUserId && userId === options.currentUserId) return "You";
|
||||
const profile = options.userProfileMap?.get(userId);
|
||||
if (profile) return profile.label;
|
||||
return `user ${userId.slice(0, 5)}`;
|
||||
}
|
||||
|
||||
@@ -129,7 +133,7 @@ function formatParticipantLabel(participant: ActivityParticipant, options: Activ
|
||||
const agentId = participant.agentId ?? "";
|
||||
return options.agentMap?.get(agentId)?.name ?? "agent";
|
||||
}
|
||||
return formatUserLabel(participant.userId, options.currentUserId);
|
||||
return formatUserLabel(participant.userId, options);
|
||||
}
|
||||
|
||||
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
|
||||
@@ -167,7 +171,20 @@ function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatIssueUpdatedAction(details: ActivityDetails): string | null {
|
||||
function formatAssigneeName(details: ActivityDetails, options: ActivityFormatOptions): string | null {
|
||||
if (!details) return null;
|
||||
const agentId = details.assigneeAgentId;
|
||||
const userId = details.assigneeUserId;
|
||||
if (typeof agentId === "string" && agentId) {
|
||||
return options.agentMap?.get(agentId)?.name ?? "agent";
|
||||
}
|
||||
if (typeof userId === "string" && userId) {
|
||||
return formatUserLabel(userId, options);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatIssueUpdatedAction(details: ActivityDetails, options: ActivityFormatOptions = {}): string | null {
|
||||
if (!details) return null;
|
||||
const previous = asRecord(details._previous) ?? {};
|
||||
const parts: string[] = [];
|
||||
@@ -189,7 +206,8 @@ function formatIssueUpdatedAction(details: ActivityDetails): string | null {
|
||||
);
|
||||
}
|
||||
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
||||
parts.push(details.assigneeAgentId || details.assigneeUserId ? "assigned the issue" : "unassigned the issue");
|
||||
const assigneeName = formatAssigneeName(details, options);
|
||||
parts.push(assigneeName ? `assigned the issue to ${assigneeName}` : "unassigned the issue");
|
||||
}
|
||||
if (details.title !== undefined) parts.push("updated the title");
|
||||
if (details.description !== undefined) parts.push("updated the description");
|
||||
@@ -266,7 +284,7 @@ export function formatIssueActivityAction(
|
||||
options: ActivityFormatOptions = {},
|
||||
): string {
|
||||
if (action === "issue.updated") {
|
||||
const issueUpdatedAction = formatIssueUpdatedAction(details);
|
||||
const issueUpdatedAction = formatIssueUpdatedAction(details, options);
|
||||
if (issueUpdatedAction) return issueUpdatedAction;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +74,16 @@ export function currentUserAssigneeOption(currentUserId: string | null | undefin
|
||||
export function formatAssigneeUserLabel(
|
||||
userId: string | null | undefined,
|
||||
currentUserId: string | null | undefined,
|
||||
userLabels?: ReadonlyMap<string, string> | Record<string, string> | null,
|
||||
): string | null {
|
||||
if (!userId) return null;
|
||||
if (currentUserId && userId === currentUserId) return "You";
|
||||
if (userLabels) {
|
||||
const label = userLabels instanceof Map
|
||||
? userLabels.get(userId)
|
||||
: (userLabels as Record<string, string>)[userId];
|
||||
if (typeof label === "string" && label.trim()) return label;
|
||||
}
|
||||
if (userId === "local-board") return "Board";
|
||||
return userId.slice(0, 5);
|
||||
}
|
||||
|
||||
105
ui/src/lib/company-members.test.ts
Normal file
105
ui/src/lib/company-members.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CompanyMember, CompanyUserDirectoryEntry } from "@/api/access";
|
||||
import {
|
||||
buildCompanyUserInlineOptions,
|
||||
buildCompanyUserLabelMap,
|
||||
buildCompanyUserProfileMap,
|
||||
buildMarkdownMentionOptions,
|
||||
} from "./company-members";
|
||||
|
||||
const activeMember = (overrides: Partial<CompanyMember>): CompanyMember => ({
|
||||
id: overrides.id ?? "member-1",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
principalType: "user",
|
||||
principalId: overrides.principalId ?? "user-1",
|
||||
status: overrides.status ?? "active",
|
||||
membershipRole: overrides.membershipRole ?? "operator",
|
||||
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
|
||||
user: overrides.user === undefined
|
||||
? { id: overrides.principalId ?? "user-1", name: "Taylor", email: "taylor@example.com", image: null }
|
||||
: overrides.user,
|
||||
grants: overrides.grants ?? [],
|
||||
});
|
||||
|
||||
describe("company-members helpers", () => {
|
||||
it("builds labels from company member profiles", () => {
|
||||
const labels = buildCompanyUserLabelMap([
|
||||
activeMember({ principalId: "user-1", user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null } }),
|
||||
activeMember({ id: "member-2", principalId: "local-board", user: null }),
|
||||
]);
|
||||
|
||||
expect(labels.get("user-1")).toBe("Taylor");
|
||||
expect(labels.get("local-board")).toBe("Board");
|
||||
});
|
||||
|
||||
it("builds user profiles with labels and avatars", () => {
|
||||
const profiles = buildCompanyUserProfileMap([
|
||||
activeMember({
|
||||
principalId: "user-1",
|
||||
user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: "https://example.com/taylor.png" },
|
||||
}),
|
||||
activeMember({ id: "member-2", principalId: "local-board", user: null }),
|
||||
]);
|
||||
|
||||
expect(profiles.get("user-1")).toEqual({
|
||||
label: "Taylor",
|
||||
image: "https://example.com/taylor.png",
|
||||
});
|
||||
expect(profiles.get("local-board")).toEqual({
|
||||
label: "Board",
|
||||
image: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds inline options for active users and excludes requested ids", () => {
|
||||
const options = buildCompanyUserInlineOptions([
|
||||
activeMember({ principalId: "user-1", user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null } }),
|
||||
activeMember({ id: "member-2", principalId: "user-2", user: { id: "user-2", name: "Jordan", email: "jordan@example.com", image: null } }),
|
||||
activeMember({ id: "member-3", principalId: "user-3", status: "suspended" }),
|
||||
], { excludeUserIds: ["user-1"] });
|
||||
|
||||
expect(options).toEqual([
|
||||
{
|
||||
id: "user:user-2",
|
||||
label: "Jordan",
|
||||
searchText: "Jordan jordan@example.com user-2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes human users in markdown mention options", () => {
|
||||
const options = buildMarkdownMentionOptions({
|
||||
members: [activeMember({ principalId: "user-1", user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null } })],
|
||||
agents: [{ id: "agent-1", name: "CodexCoder", status: "active", icon: "code" }],
|
||||
projects: [{ id: "project-1", name: "Paperclip App", color: "#336699" }],
|
||||
});
|
||||
|
||||
expect(options).toEqual([
|
||||
{ id: "user:user-1", name: "Taylor", kind: "user", userId: "user-1" },
|
||||
{ id: "agent:agent-1", name: "CodexCoder", kind: "agent", agentId: "agent-1", agentIcon: "code" },
|
||||
{ id: "project:project-1", name: "Paperclip App", kind: "project", projectId: "project-1", projectColor: "#336699" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts read-only directory entries for assignee and mention helpers", () => {
|
||||
const users: CompanyUserDirectoryEntry[] = [
|
||||
{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null },
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildCompanyUserInlineOptions(users)).toEqual([
|
||||
{
|
||||
id: "user:user-1",
|
||||
label: "Taylor",
|
||||
searchText: "Taylor taylor@example.com user-1",
|
||||
},
|
||||
]);
|
||||
expect(buildMarkdownMentionOptions({ members: users })).toEqual([
|
||||
{ id: "user:user-1", name: "Taylor", kind: "user", userId: "user-1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
116
ui/src/lib/company-members.ts
Normal file
116
ui/src/lib/company-members.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { CompanyMember, CompanyUserDirectoryEntry } from "@/api/access";
|
||||
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||
import type { MentionOption } from "@/components/MarkdownEditor";
|
||||
import type { Agent, Project } from "@paperclipai/shared";
|
||||
|
||||
export interface CompanyUserProfile {
|
||||
label: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
type CompanyUserRecord = Pick<CompanyMember, "principalId" | "status" | "user">
|
||||
| CompanyUserDirectoryEntry;
|
||||
|
||||
function fallbackUserLabel(userId: string): string {
|
||||
if (userId === "local-board") return "Board";
|
||||
return userId.slice(0, 5);
|
||||
}
|
||||
|
||||
function baseMemberLabel(member: Pick<CompanyUserRecord, "principalId" | "user">): string {
|
||||
const name = member.user?.name?.trim();
|
||||
if (name) return name;
|
||||
const email = member.user?.email?.trim();
|
||||
if (email) return email;
|
||||
return fallbackUserLabel(member.principalId);
|
||||
}
|
||||
|
||||
function activeUniqueMembers(members: CompanyUserRecord[] | null | undefined) {
|
||||
const byId = new Map<string, CompanyUserRecord>();
|
||||
for (const member of members ?? []) {
|
||||
if (member.status !== "active") continue;
|
||||
if (!byId.has(member.principalId)) {
|
||||
byId.set(member.principalId, member);
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => baseMemberLabel(left).localeCompare(baseMemberLabel(right)));
|
||||
}
|
||||
|
||||
export function buildCompanyUserLabelMap(members: CompanyUserRecord[] | null | undefined): Map<string, string> {
|
||||
const labels = new Map<string, string>();
|
||||
for (const member of members ?? []) {
|
||||
labels.set(member.principalId, baseMemberLabel(member));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function buildCompanyUserProfileMap(
|
||||
members: CompanyUserRecord[] | null | undefined,
|
||||
): Map<string, CompanyUserProfile> {
|
||||
const profiles = new Map<string, CompanyUserProfile>();
|
||||
for (const member of members ?? []) {
|
||||
profiles.set(member.principalId, {
|
||||
label: baseMemberLabel(member),
|
||||
image: member.user?.image ?? null,
|
||||
});
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
export function buildCompanyUserInlineOptions(
|
||||
members: CompanyUserRecord[] | null | undefined,
|
||||
options?: { excludeUserIds?: Iterable<string | null | undefined> },
|
||||
): InlineEntityOption[] {
|
||||
const exclude = new Set(
|
||||
[...(options?.excludeUserIds ?? [])].filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
|
||||
return activeUniqueMembers(members)
|
||||
.filter((member) => !exclude.has(member.principalId))
|
||||
.map((member) => ({
|
||||
id: `user:${member.principalId}`,
|
||||
label: baseMemberLabel(member),
|
||||
searchText: [member.user?.name, member.user?.email, member.principalId].filter(Boolean).join(" "),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCompanyUserMentionOptions(
|
||||
members: CompanyUserRecord[] | null | undefined,
|
||||
): MentionOption[] {
|
||||
return activeUniqueMembers(members).map((member) => ({
|
||||
id: `user:${member.principalId}`,
|
||||
name: baseMemberLabel(member),
|
||||
kind: "user",
|
||||
userId: member.principalId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildMarkdownMentionOptions(args: {
|
||||
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon">> | null | undefined;
|
||||
projects?: Array<Pick<Project, "id" | "name" | "color">> | null | undefined;
|
||||
members?: CompanyUserRecord[] | null | undefined;
|
||||
}): MentionOption[] {
|
||||
const options: MentionOption[] = [
|
||||
...buildCompanyUserMentionOptions(args.members),
|
||||
...[...(args.agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((agent) => ({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent" as const,
|
||||
agentId: agent.id,
|
||||
agentIcon: agent.icon,
|
||||
})),
|
||||
...[...(args.projects ?? [])]
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((project) => ({
|
||||
id: `project:${project.id}`,
|
||||
name: project.name,
|
||||
kind: "project" as const,
|
||||
projectId: project.id,
|
||||
projectColor: project.color,
|
||||
})),
|
||||
];
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -9,47 +9,15 @@ import {
|
||||
describe("company routes", () => {
|
||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/issues")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||
"/PAP/execution-workspaces/workspace-123",
|
||||
);
|
||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123/issues", "PAP")).toBe(
|
||||
"/PAP/execution-workspaces/workspace-123/issues",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||
"/execution-workspaces/workspace-123",
|
||||
);
|
||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe(
|
||||
"/execution-workspaces/workspace-123/configuration",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for https://github.com/paperclipai/paperclip/issues/2910
|
||||
*
|
||||
* The Export and Import links on the Company Settings page used plain
|
||||
* `<a href="/company/export">` anchors which bypass the router's Link
|
||||
* wrapper. Without the wrapper, the company prefix is never applied and
|
||||
* the links resolve to `/company/export` instead of `/:prefix/company/export`,
|
||||
* producing a "Company not found" error.
|
||||
*
|
||||
* The fix replaces the `<a>` elements with the prefix-aware `<Link>` from
|
||||
* `@/lib/router`. These tests assert that the underlying `applyCompanyPrefix`
|
||||
* utility (used by that Link) correctly rewrites the export/import paths.
|
||||
*/
|
||||
it("applies company prefix to /company/export", () => {
|
||||
expect(applyCompanyPrefix("/company/export", "PAP")).toBe("/PAP/company/export");
|
||||
});
|
||||
|
||||
it("applies company prefix to /company/import", () => {
|
||||
expect(applyCompanyPrefix("/company/import", "PAP")).toBe("/PAP/company/import");
|
||||
});
|
||||
|
||||
it("does not double-apply the prefix if already present", () => {
|
||||
expect(applyCompanyPrefix("/PAP/company/export", "PAP")).toBe("/PAP/company/export");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
saveInboxIssueColumns,
|
||||
saveInboxWorkItemGroupBy,
|
||||
saveLastInboxTab,
|
||||
shouldShowCompanyAlerts,
|
||||
shouldResetInboxWorkspaceGrouping,
|
||||
shouldShowInboxSection,
|
||||
type InboxWorkItem,
|
||||
@@ -298,7 +299,10 @@ describe("inbox helpers", () => {
|
||||
|
||||
it("counts the same inbox sources the badge uses", () => {
|
||||
const result = computeInboxBadgeData({
|
||||
approvals: [makeApproval("pending"), makeApproval("approved")],
|
||||
approvals: [
|
||||
{ ...makeApproval("pending"), requestedByUserId: "user-1" },
|
||||
{ ...makeApproval("approved"), requestedByUserId: "user-2" },
|
||||
],
|
||||
joinRequests: [makeJoinRequest("join-1")],
|
||||
dashboard,
|
||||
heartbeatRuns: [
|
||||
@@ -309,10 +313,11 @@ describe("inbox helpers", () => {
|
||||
mineIssues: [makeIssue("1", true)],
|
||||
dismissedAlerts: new Set<string>(),
|
||||
dismissedAtByKey: new Map<string, number>(),
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
inbox: 6,
|
||||
inbox: 5,
|
||||
approvals: 1,
|
||||
failedRuns: 2,
|
||||
joinRequests: 1,
|
||||
@@ -330,6 +335,7 @@ describe("inbox helpers", () => {
|
||||
mineIssues: [],
|
||||
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
|
||||
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -351,10 +357,12 @@ describe("inbox helpers", () => {
|
||||
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
|
||||
dismissedAlerts: new Set<string>(),
|
||||
dismissedAtByKey: new Map(),
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(result.mineIssues).toBe(1);
|
||||
expect(result.inbox).toBe(3);
|
||||
expect(result.inbox).toBe(1);
|
||||
expect(result.alerts).toBe(2);
|
||||
});
|
||||
|
||||
it("resurfaces non-issue items when they change after dismissal", () => {
|
||||
@@ -393,21 +401,25 @@ describe("inbox helpers", () => {
|
||||
expect(issues).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows recent approvals in updated order and unread approvals as actionable only", () => {
|
||||
it("shows only my approvals on mine, while recent and unread stay company-wide", () => {
|
||||
const approvals = [
|
||||
makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"),
|
||||
makeApprovalWithTimestamps("approval-pending", "pending", "2026-03-11T01:00:00.000Z"),
|
||||
makeApprovalWithTimestamps(
|
||||
"approval-revision",
|
||||
"revision_requested",
|
||||
"2026-03-11T03:00:00.000Z",
|
||||
),
|
||||
{
|
||||
...makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"),
|
||||
requestedByUserId: "user-1",
|
||||
},
|
||||
{
|
||||
...makeApprovalWithTimestamps("approval-pending", "pending", "2026-03-11T01:00:00.000Z"),
|
||||
requestedByUserId: "user-2",
|
||||
},
|
||||
{
|
||||
...makeApprovalWithTimestamps("approval-revision", "revision_requested", "2026-03-11T03:00:00.000Z"),
|
||||
decidedByUserId: "user-1",
|
||||
},
|
||||
];
|
||||
|
||||
expect(getApprovalsForTab(approvals, "mine", "all").map((approval) => approval.id)).toEqual([
|
||||
expect(getApprovalsForTab(approvals, "mine", "all", "user-1").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
"approval-approved",
|
||||
"approval-pending",
|
||||
]);
|
||||
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
@@ -423,6 +435,44 @@ describe("inbox helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unrelated approvals out of a new user's badge and mine tab", () => {
|
||||
const approvals = [
|
||||
{ ...makeApproval("pending"), requestedByUserId: "user-2" },
|
||||
{ ...makeApproval("revision_requested"), decidedByUserId: "user-3" },
|
||||
];
|
||||
|
||||
expect(getApprovalsForTab(approvals, "mine", "all", "user-1")).toEqual([]);
|
||||
|
||||
const result = computeInboxBadgeData({
|
||||
approvals,
|
||||
joinRequests: [],
|
||||
dashboard,
|
||||
heartbeatRuns: [],
|
||||
mineIssues: [],
|
||||
dismissedAlerts: new Set<string>(),
|
||||
dismissedAtByKey: new Map(),
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(result.approvals).toBe(0);
|
||||
});
|
||||
|
||||
it("does not count company-wide alerts in the personal inbox badge", () => {
|
||||
const result = computeInboxBadgeData({
|
||||
approvals: [],
|
||||
joinRequests: [],
|
||||
dashboard,
|
||||
heartbeatRuns: [],
|
||||
mineIssues: [],
|
||||
dismissedAlerts: new Set<string>(),
|
||||
dismissedAtByKey: new Map(),
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(result.alerts).toBe(2);
|
||||
expect(result.inbox).toBe(0);
|
||||
});
|
||||
|
||||
it("mixes approvals into the inbox feed by most recent activity", () => {
|
||||
const newerIssue = makeIssue("1", true);
|
||||
newerIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
@@ -614,6 +664,13 @@ describe("inbox helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows company alerts only on the all tab", () => {
|
||||
expect(shouldShowCompanyAlerts("mine")).toBe(false);
|
||||
expect(shouldShowCompanyAlerts("recent")).toBe(false);
|
||||
expect(shouldShowCompanyAlerts("unread")).toBe(false);
|
||||
expect(shouldShowCompanyAlerts("all")).toBe(true);
|
||||
});
|
||||
|
||||
it("limits recent touched issues before unread badge counting", () => {
|
||||
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
||||
const issue = makeIssue(String(index + 1), index < 3);
|
||||
|
||||
@@ -625,6 +625,10 @@ export function isMineInboxTab(tab: InboxTab): boolean {
|
||||
return tab === "mine";
|
||||
}
|
||||
|
||||
export function shouldShowCompanyAlerts(tab: InboxTab): boolean {
|
||||
return tab === "all";
|
||||
}
|
||||
|
||||
export function resolveInboxSelectionIndex(
|
||||
previousIndex: number,
|
||||
itemCount: number,
|
||||
@@ -695,12 +699,20 @@ export function getApprovalsForTab(
|
||||
approvals: Approval[],
|
||||
tab: InboxTab,
|
||||
filter: InboxApprovalFilter,
|
||||
currentUserId?: string | null,
|
||||
): Approval[] {
|
||||
const sortedApprovals = [...approvals].sort(
|
||||
(a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt),
|
||||
);
|
||||
|
||||
if (tab === "mine" || tab === "recent") return sortedApprovals;
|
||||
if (tab === "mine") {
|
||||
if (!currentUserId) return [];
|
||||
return sortedApprovals.filter(
|
||||
(approval) =>
|
||||
approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId,
|
||||
);
|
||||
}
|
||||
if (tab === "recent") return sortedApprovals;
|
||||
if (tab === "unread") {
|
||||
return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status));
|
||||
}
|
||||
@@ -1005,6 +1017,7 @@ export function computeInboxBadgeData({
|
||||
mineIssues,
|
||||
dismissedAlerts,
|
||||
dismissedAtByKey,
|
||||
currentUserId,
|
||||
}: {
|
||||
approvals: Approval[];
|
||||
joinRequests: JoinRequest[];
|
||||
@@ -1013,9 +1026,12 @@ export function computeInboxBadgeData({
|
||||
mineIssues: Issue[];
|
||||
dismissedAlerts: Set<string>;
|
||||
dismissedAtByKey: ReadonlyMap<string, number>;
|
||||
currentUserId?: string | null;
|
||||
}): InboxBadgeData {
|
||||
const actionableApprovals = approvals.filter(
|
||||
(approval) =>
|
||||
!!currentUserId &&
|
||||
(approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId) &&
|
||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
||||
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
|
||||
).length;
|
||||
@@ -1040,7 +1056,8 @@ export function computeInboxBadgeData({
|
||||
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
||||
|
||||
return {
|
||||
inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues + alerts,
|
||||
// The inbox badge reflects personal/actionable work, not company-wide health alerts.
|
||||
inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues,
|
||||
approvals: actionableApprovals,
|
||||
failedRuns,
|
||||
joinRequests: visibleJoinRequests,
|
||||
|
||||
36
ui/src/lib/invite-memory.ts
Normal file
36
ui/src/lib/invite-memory.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const PENDING_INVITE_STORAGE_KEY = "paperclip:pending-invite-token";
|
||||
|
||||
function canUseStorage() {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
export function rememberPendingInviteToken(token: string) {
|
||||
const normalized = token.trim();
|
||||
if (!normalized || !canUseStorage()) return;
|
||||
try {
|
||||
window.localStorage.setItem(PENDING_INVITE_STORAGE_KEY, normalized);
|
||||
} catch {
|
||||
// Ignore storage failures and keep the invite flow usable.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingInviteToken(expectedToken?: string) {
|
||||
if (!canUseStorage()) return;
|
||||
try {
|
||||
const current = window.localStorage.getItem(PENDING_INVITE_STORAGE_KEY);
|
||||
if (expectedToken && current !== expectedToken.trim()) return;
|
||||
window.localStorage.removeItem(PENDING_INVITE_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function getRememberedInvitePath() {
|
||||
if (!canUseStorage()) return null;
|
||||
try {
|
||||
const token = window.localStorage.getItem(PENDING_INVITE_STORAGE_KEY)?.trim();
|
||||
return token ? `/invite/${token}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,27 @@ describe("buildAssistantPartsFromTranscript", () => {
|
||||
});
|
||||
|
||||
describe("buildIssueChatMessages", () => {
|
||||
it("uses the company user label for current-user comments instead of collapsing to You", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [createComment({ authorUserId: "user-1" })],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
userLabelMap: new Map([["user-1", "Dotta"]]),
|
||||
});
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
metadata: {
|
||||
custom: {
|
||||
authorName: "Dotta",
|
||||
authorUserId: "user-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("orders events before comments and appends active live runs as running assistant messages", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
|
||||
const comments = [
|
||||
|
||||
@@ -270,11 +270,16 @@ function authorNameForComment(
|
||||
comment: IssueChatComment,
|
||||
agentMap?: Map<string, Agent>,
|
||||
currentUserId?: string | null,
|
||||
userLabelMap?: ReadonlyMap<string, string> | null,
|
||||
) {
|
||||
if (comment.authorAgentId) {
|
||||
return agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8);
|
||||
}
|
||||
return formatAssigneeUserLabel(comment.authorUserId ?? null, currentUserId) ?? "You";
|
||||
const authorUserId = comment.authorUserId ?? null;
|
||||
if (!authorUserId) return "You";
|
||||
const userLabel = userLabelMap?.get(authorUserId)?.trim();
|
||||
if (userLabel) return userLabel;
|
||||
return formatAssigneeUserLabel(authorUserId, currentUserId, userLabelMap) ?? "You";
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
@@ -285,12 +290,13 @@ function createCommentMessage(args: {
|
||||
comment: IssueChatComment;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
}): ThreadMessage {
|
||||
const { comment, agentMap, currentUserId, companyId, projectId } = args;
|
||||
const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args;
|
||||
const createdAt = toDate(comment.createdAt);
|
||||
const authorName = authorNameForComment(comment, agentMap, currentUserId);
|
||||
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap);
|
||||
const custom = {
|
||||
kind: "comment",
|
||||
commentId: comment.id,
|
||||
@@ -335,13 +341,14 @@ function createTimelineEventMessage(args: {
|
||||
event: IssueTimelineEvent;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
}) {
|
||||
const { event, agentMap, currentUserId } = args;
|
||||
const { event, agentMap, currentUserId, userLabelMap } = args;
|
||||
const actorName = event.actorType === "agent"
|
||||
? (agentMap?.get(event.actorId)?.name ?? event.actorId.slice(0, 8))
|
||||
: event.actorType === "system"
|
||||
? "System"
|
||||
: (formatAssigneeUserLabel(event.actorId, currentUserId) ?? "Board");
|
||||
: (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board");
|
||||
|
||||
const lines: string[] = [`${actorName} updated this issue`];
|
||||
if (event.statusChange) {
|
||||
@@ -352,10 +359,10 @@ function createTimelineEventMessage(args: {
|
||||
if (event.assigneeChange) {
|
||||
const from = event.assigneeChange.from.agentId
|
||||
? (agentMap?.get(event.assigneeChange.from.agentId)?.name ?? event.assigneeChange.from.agentId.slice(0, 8))
|
||||
: (formatAssigneeUserLabel(event.assigneeChange.from.userId, currentUserId) ?? "Unassigned");
|
||||
: (formatAssigneeUserLabel(event.assigneeChange.from.userId, currentUserId, userLabelMap) ?? "Unassigned");
|
||||
const to = event.assigneeChange.to.agentId
|
||||
? (agentMap?.get(event.assigneeChange.to.agentId)?.name ?? event.assigneeChange.to.agentId.slice(0, 8))
|
||||
: (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId) ?? "Unassigned");
|
||||
: (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId, userLabelMap) ?? "Unassigned");
|
||||
lines.push(`Assignee: ${from} -> ${to}`);
|
||||
}
|
||||
|
||||
@@ -743,6 +750,7 @@ export function buildIssueChatMessages(args: {
|
||||
projectId?: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
@@ -758,6 +766,7 @@ export function buildIssueChatMessages(args: {
|
||||
projectId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
} = args;
|
||||
|
||||
const orderedMessages: MessageWithOrder[] = [];
|
||||
@@ -766,7 +775,7 @@ export function buildIssueChatMessages(args: {
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(comment.createdAt),
|
||||
order: 1,
|
||||
message: createCommentMessage({ comment, agentMap, currentUserId, companyId, projectId }),
|
||||
message: createCommentMessage({ comment, agentMap, currentUserId, userLabelMap, companyId, projectId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -774,7 +783,7 @@ export function buildIssueChatMessages(args: {
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(event.createdAt),
|
||||
order: 0,
|
||||
message: createTimelineEventMessage({ event, agentMap, currentUserId }),
|
||||
message: createTimelineEventMessage({ event, agentMap, currentUserId, userLabelMap }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared";
|
||||
import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, parseUserMentionHref } from "@paperclipai/shared";
|
||||
import { getAgentIcon } from "./agent-icons";
|
||||
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
|
||||
|
||||
@@ -14,6 +14,10 @@ export type ParsedMentionChip =
|
||||
projectId: string;
|
||||
color: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "user";
|
||||
userId: string;
|
||||
}
|
||||
| {
|
||||
kind: "skill";
|
||||
skillId: string;
|
||||
@@ -41,6 +45,14 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null {
|
||||
};
|
||||
}
|
||||
|
||||
const user = parseUserMentionHref(href);
|
||||
if (user) {
|
||||
return {
|
||||
kind: "user",
|
||||
userId: user.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const skill = parseSkillMentionHref(href);
|
||||
if (skill) {
|
||||
return {
|
||||
@@ -100,6 +112,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
|
||||
"paperclip-mention-chip",
|
||||
"paperclip-mention-chip--agent",
|
||||
"paperclip-mention-chip--project",
|
||||
"paperclip-mention-chip--user",
|
||||
"paperclip-mention-chip--skill",
|
||||
"paperclip-project-mention-chip",
|
||||
);
|
||||
|
||||
@@ -78,37 +78,35 @@ export function buildProjectWorkspaceSummaries(input: {
|
||||
})) continue;
|
||||
|
||||
const existing = summaries.get(`execution:${executionWorkspace.id}`);
|
||||
const nextIssues = existing?.issues ?? [];
|
||||
nextIssues.push(issue);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||
key: `execution:${executionWorkspace.id}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceId: executionWorkspace.id,
|
||||
workspaceName: executionWorkspace.name,
|
||||
cwd: executionWorkspace.cwd ?? null,
|
||||
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||
lastUpdatedAt: maxDate(
|
||||
executionWorkspace.lastUsedAt,
|
||||
executionWorkspace.updatedAt,
|
||||
issue.updatedAt,
|
||||
),
|
||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: executionWorkspace.id,
|
||||
executionWorkspaceStatus: executionWorkspace.status,
|
||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(
|
||||
executionWorkspace.config?.workspaceRuntime
|
||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||
),
|
||||
issues: nextIssues,
|
||||
});
|
||||
} else {
|
||||
existing.lastUpdatedAt = maxDate(existing.lastUpdatedAt, issue.updatedAt);
|
||||
}
|
||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||
key: `execution:${executionWorkspace.id}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceId: executionWorkspace.id,
|
||||
workspaceName: executionWorkspace.name,
|
||||
cwd: executionWorkspace.cwd ?? null,
|
||||
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||
lastUpdatedAt: maxDate(
|
||||
existing?.lastUpdatedAt,
|
||||
executionWorkspace.lastUsedAt,
|
||||
executionWorkspace.updatedAt,
|
||||
issue.updatedAt,
|
||||
),
|
||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: executionWorkspace.id,
|
||||
executionWorkspaceStatus: executionWorkspace.status,
|
||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(
|
||||
executionWorkspace.config?.workspaceRuntime
|
||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||
),
|
||||
issues: nextIssues,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -117,30 +115,27 @@ export function buildProjectWorkspaceSummaries(input: {
|
||||
if (!projectWorkspace) continue;
|
||||
|
||||
const existing = summaries.get(`project:${projectWorkspace.id}`);
|
||||
const nextIssues = existing?.issues ?? [];
|
||||
nextIssues.push(issue);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
summaries.set(`project:${projectWorkspace.id}`, {
|
||||
key: `project:${projectWorkspace.id}`,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(projectWorkspace.updatedAt, issue.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: nextIssues,
|
||||
});
|
||||
} else {
|
||||
existing.lastUpdatedAt = maxDate(existing.lastUpdatedAt, issue.updatedAt);
|
||||
}
|
||||
summaries.set(`project:${projectWorkspace.id}`, {
|
||||
key: `project:${projectWorkspace.id}`,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: nextIssues,
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectWorkspace of input.project.workspaces) {
|
||||
@@ -170,17 +165,8 @@ export function buildProjectWorkspaceSummaries(input: {
|
||||
});
|
||||
}
|
||||
|
||||
const result = [...summaries.values()];
|
||||
// Sort issues within each summary once (instead of on every insertion)
|
||||
const issueTime = (issue: Issue) => new Date(issue.updatedAt).getTime();
|
||||
for (const summary of result) {
|
||||
if (summary.issues.length > 1) {
|
||||
summary.issues.sort((a, b) => issueTime(b) - issueTime(a));
|
||||
}
|
||||
}
|
||||
result.sort((a, b) => {
|
||||
return [...summaries.values()].sort((a, b) => {
|
||||
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -90,9 +90,16 @@ export const queryKeys = {
|
||||
issues: (approvalId: string) => ["approvals", "issues", approvalId] as const,
|
||||
},
|
||||
access: {
|
||||
invites: (companyId: string, state: string = "all", limit: number = 20) =>
|
||||
["access", "invites", "paginated-v1", companyId, state, limit] as const,
|
||||
joinRequests: (companyId: string, status: string = "pending_approval") =>
|
||||
["access", "join-requests", companyId, status] as const,
|
||||
companyMembers: (companyId: string) => ["access", "company-members", companyId] as const,
|
||||
companyUserDirectory: (companyId: string) => ["access", "company-user-directory", companyId] as const,
|
||||
adminUsers: (query: string) => ["access", "admin-users", query] as const,
|
||||
userCompanyAccess: (userId: string) => ["access", "user-company-access", userId] as const,
|
||||
invite: (token: string) => ["access", "invite", token] as const,
|
||||
currentBoardAccess: ["access", "current-board-access"] as const,
|
||||
},
|
||||
auth: {
|
||||
session: ["auth", "session"] as const,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -60,6 +62,17 @@ export function Activity() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const userProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
|
||||
const agentMap = useMemo(() => {
|
||||
const map = new Map<string, Agent>();
|
||||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
@@ -129,6 +142,7 @@ export function Activity() {
|
||||
key={event.id}
|
||||
event={event}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
entityNameMap={entityNameMap}
|
||||
entityTitleMap={entityTitleMap}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { authApi } from "../api/auth";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { getRememberedInvitePath } from "../lib/invite-memory";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AsciiArtAnimation } from "@/components/AsciiArtAnimation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
@@ -19,7 +20,10 @@ export function AuthPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams]);
|
||||
const nextPath = useMemo(
|
||||
() => searchParams.get("next") || getRememberedInvitePath() || "/",
|
||||
[searchParams],
|
||||
);
|
||||
const { data: session, isLoading: isSessionLoading } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
|
||||
219
ui/src/pages/CompanyAccess.test.tsx
Normal file
219
ui/src/pages/CompanyAccess.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// @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 { CompanyAccess } from "./CompanyAccess";
|
||||
|
||||
const listMembersMock = vi.hoisted(() => vi.fn());
|
||||
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const updateMemberAccessMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
listMembers: (companyId: string) => listMembersMock(companyId),
|
||||
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status),
|
||||
updateMember: vi.fn(),
|
||||
updateMemberPermissions: vi.fn(),
|
||||
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
|
||||
updateMemberAccessMock(companyId, memberId, input),
|
||||
approveJoinRequest: vi.fn(),
|
||||
rejectJoinRequest: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompanyAccess", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listMembersMock.mockResolvedValue({
|
||||
members: [
|
||||
{
|
||||
id: "member-1",
|
||||
companyId: "company-1",
|
||||
principalType: "user",
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
createdAt: "2026-04-10T00:00:00.000Z",
|
||||
updatedAt: "2026-04-10T00:00:00.000Z",
|
||||
user: {
|
||||
id: "user-1",
|
||||
email: "codexcoder@paperclip.local",
|
||||
name: "Codex Coder",
|
||||
image: null,
|
||||
},
|
||||
grants: [],
|
||||
},
|
||||
],
|
||||
access: {
|
||||
currentUserRole: "owner",
|
||||
canManageMembers: true,
|
||||
canInviteUsers: true,
|
||||
canApproveJoinRequests: true,
|
||||
},
|
||||
});
|
||||
listJoinRequestsMock.mockResolvedValue([
|
||||
{
|
||||
id: "join-1",
|
||||
requestType: "human",
|
||||
createdAt: "2026-04-10T00:00:00.000Z",
|
||||
requesterUser: {
|
||||
id: "user-2",
|
||||
email: "board@paperclip.local",
|
||||
name: "Board User",
|
||||
image: null,
|
||||
},
|
||||
requestEmailSnapshot: "board@paperclip.local",
|
||||
requestingUserId: "user-2",
|
||||
invite: {
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: "operator",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "join-2",
|
||||
requestType: "agent",
|
||||
createdAt: "2026-04-10T00:00:00.000Z",
|
||||
agentName: "Codex Worker",
|
||||
adapterType: "codex_local",
|
||||
capabilities: "Implements code changes",
|
||||
invite: {
|
||||
allowedJoinTypes: "agent",
|
||||
humanRole: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
updateMemberAccessMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the page human-focused and explains implicit versus explicit grants", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyAccess />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Manage company user memberships");
|
||||
expect(container.textContent).toContain("Humans");
|
||||
expect(container.textContent).toContain("Pending human joins");
|
||||
expect(container.textContent).toContain("User account");
|
||||
expect(container.textContent).not.toContain("Agents");
|
||||
expect(container.textContent).not.toContain("Pending agent joins");
|
||||
expect(container.textContent).not.toContain("Open join request queue");
|
||||
expect(container.textContent).not.toContain("Manage invites");
|
||||
expect(container.textContent).not.toContain("Active user accounts");
|
||||
expect(container.textContent).not.toContain("Suspended user accounts");
|
||||
expect(container.textContent).not.toContain("Pending user joins");
|
||||
|
||||
const editButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Edit",
|
||||
);
|
||||
expect(editButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Implicit grants from role");
|
||||
expect(document.body.textContent).toContain("Owner currently includes these permissions automatically.");
|
||||
expect(document.body.textContent).toContain(
|
||||
"Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves member role, status, and grants in one request", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyAccess />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const editButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Edit",
|
||||
);
|
||||
expect(editButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Save access",
|
||||
);
|
||||
expect(saveButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", {
|
||||
membershipRole: "owner",
|
||||
status: "active",
|
||||
grants: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
476
ui/src/pages/CompanyAccess.tsx
Normal file
476
ui/src/pages/CompanyAccess.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, PERMISSION_KEYS, type PermissionKey } from "@paperclipai/shared";
|
||||
import { ShieldCheck, Users } from "lucide-react";
|
||||
import { accessApi, type CompanyMember } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const permissionLabels: Record<PermissionKey, string> = {
|
||||
"agents:create": "Create agents",
|
||||
"users:invite": "Invite humans and agents",
|
||||
"users:manage_permissions": "Manage members and grants",
|
||||
"tasks:assign": "Assign tasks",
|
||||
"tasks:assign_scope": "Assign scoped tasks",
|
||||
"joins:approve": "Approve join requests",
|
||||
};
|
||||
|
||||
function formatGrantSummary(member: CompanyMember) {
|
||||
if (member.grants.length === 0) return "No explicit grants";
|
||||
return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", ");
|
||||
}
|
||||
|
||||
const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>, PermissionKey[]> = {
|
||||
owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"],
|
||||
admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"],
|
||||
operator: ["tasks:assign"],
|
||||
viewer: [],
|
||||
};
|
||||
|
||||
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
|
||||
return role ? implicitRoleGrantMap[role] : [];
|
||||
}
|
||||
|
||||
export function CompanyAccess() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
|
||||
const [draftStatus, setDraftStatus] = useState<CompanyMember["status"]>("active");
|
||||
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Access" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyMembers(selectedCompanyId ?? ""),
|
||||
queryFn: () => accessApi.listMembers(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const joinRequestsQuery = useQuery({
|
||||
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"),
|
||||
queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"),
|
||||
enabled: !!selectedCompanyId && !!membersQuery.data?.access.canApproveJoinRequests,
|
||||
});
|
||||
|
||||
const refreshAccessData = async () => {
|
||||
if (!selectedCompanyId) return;
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId, "pending_approval") });
|
||||
};
|
||||
|
||||
const updateMemberMutation = useMutation({
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: CompanyMember["status"]; grants: PermissionKey[] }) => {
|
||||
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
|
||||
membershipRole: input.membershipRole,
|
||||
status: input.status,
|
||||
grants: input.grants.map((permissionKey) => ({ permissionKey })),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setEditingMemberId(null);
|
||||
await refreshAccessData();
|
||||
pushToast({
|
||||
title: "Member updated",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to update member",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const approveJoinRequestMutation = useMutation({
|
||||
mutationFn: (requestId: string) => accessApi.approveJoinRequest(selectedCompanyId!, requestId),
|
||||
onSuccess: async () => {
|
||||
await refreshAccessData();
|
||||
pushToast({
|
||||
title: "Join request approved",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to approve join request",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rejectJoinRequestMutation = useMutation({
|
||||
mutationFn: (requestId: string) => accessApi.rejectJoinRequest(selectedCompanyId!, requestId),
|
||||
onSuccess: async () => {
|
||||
await refreshAccessData();
|
||||
pushToast({
|
||||
title: "Join request rejected",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to reject join request",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const editingMember = useMemo(
|
||||
() => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null,
|
||||
[editingMemberId, membersQuery.data?.members],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingMember) return;
|
||||
setDraftRole(editingMember.membershipRole);
|
||||
setDraftStatus(editingMember.status);
|
||||
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
|
||||
}, [editingMember]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="text-sm text-muted-foreground">Select a company to manage access.</div>;
|
||||
}
|
||||
|
||||
if (membersQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading company access…</div>;
|
||||
}
|
||||
|
||||
if (membersQuery.error) {
|
||||
const message =
|
||||
membersQuery.error instanceof ApiError && membersQuery.error.status === 403
|
||||
? "You do not have permission to manage company members."
|
||||
: membersQuery.error instanceof Error
|
||||
? membersQuery.error.message
|
||||
: "Failed to load company members.";
|
||||
return <div className="text-sm text-destructive">{message}</div>;
|
||||
}
|
||||
|
||||
const members = membersQuery.data?.members ?? [];
|
||||
const access = membersQuery.data?.access;
|
||||
const pendingHumanJoinRequests =
|
||||
joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? [];
|
||||
const joinRequestActionPending =
|
||||
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
|
||||
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
|
||||
const implicitGrantSet = new Set(implicitGrantKeys);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Access</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{access && !access.currentUserRole && (
|
||||
<div className="rounded-xl border border-amber-500/40 px-4 py-3 text-sm text-amber-200">
|
||||
This account can manage access here through instance-admin privileges, but it does not currently hold an active company membership.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Humans</h2>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage human company memberships, status, and grants here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{access?.canApproveJoinRequests && pendingHumanJoinRequests.length > 0 ? (
|
||||
<div className="space-y-3 rounded-xl border border-border px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Pending human joins</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review human join requests before they become active company members.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{pendingHumanJoinRequests.length} pending</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{pendingHumanJoinRequests.map((request) => (
|
||||
<PendingJoinRequestCard
|
||||
key={request.id}
|
||||
title={
|
||||
request.requesterUser?.name ||
|
||||
request.requestEmailSnapshot ||
|
||||
request.requestingUserId ||
|
||||
"Unknown human requester"
|
||||
}
|
||||
subtitle={
|
||||
request.requesterUser?.email ||
|
||||
request.requestEmailSnapshot ||
|
||||
request.requestingUserId ||
|
||||
"No email available"
|
||||
}
|
||||
context={
|
||||
request.invite
|
||||
? `${request.invite.allowedJoinTypes} join invite${request.invite.humanRole ? ` • default role ${request.invite.humanRole}` : ""}`
|
||||
: "Invite metadata unavailable"
|
||||
}
|
||||
detail={`Submitted ${new Date(request.createdAt).toLocaleString()}`}
|
||||
approveLabel="Approve human"
|
||||
rejectLabel="Reject human"
|
||||
disabled={joinRequestActionPending}
|
||||
onApprove={() => approveJoinRequestMutation.mutate(request.id)}
|
||||
onReject={() => rejectJoinRequestMutation.mutate(request.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div>User account</div>
|
||||
<div>Role</div>
|
||||
<div>Status</div>
|
||||
<div>Grants</div>
|
||||
<div className="text-right">Action</div>
|
||||
</div>
|
||||
{members.length === 0 ? (
|
||||
<div className="px-4 py-8 text-sm text-muted-foreground">No user memberships found for this company yet.</div>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{member.user?.email || member.principalId}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{member.membershipRole
|
||||
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
|
||||
: "Unset"}
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
|
||||
{member.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
||||
<div className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={!!editingMember} onOpenChange={(open) => !open && setEditingMemberId(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingMember && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">Company role</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2"
|
||||
value={draftRole ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraftRole((event.target.value || null) as CompanyMember["membershipRole"])
|
||||
}
|
||||
>
|
||||
<option value="">Unset</option>
|
||||
{Object.entries(HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">Membership status</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2"
|
||||
value={draftStatus}
|
||||
onChange={(event) =>
|
||||
setDraftStatus(event.target.value as CompanyMember["status"])
|
||||
}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Grants</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border px-3 py-3">
|
||||
<div className="text-sm font-medium">Implicit grants from role</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{draftRole
|
||||
? `${HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole]} currently includes these permissions automatically.`
|
||||
: "No role is selected, so this member has no implicit grants right now."}
|
||||
</p>
|
||||
{implicitGrantKeys.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{implicitGrantKeys.map((permissionKey) => (
|
||||
<Badge key={permissionKey} variant="outline">
|
||||
{permissionLabels[permissionKey]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{PERMISSION_KEYS.map((permissionKey) => (
|
||||
<label
|
||||
key={permissionKey}
|
||||
className="flex items-start gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={draftGrants.has(permissionKey)}
|
||||
onCheckedChange={(checked) => {
|
||||
setDraftGrants((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(permissionKey);
|
||||
else next.delete(permissionKey);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium">{permissionLabels[permissionKey]}</span>
|
||||
<span className="block text-xs text-muted-foreground">{permissionKey}</span>
|
||||
{implicitGrantSet.has(permissionKey) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Included implicitly by the {draftRole ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole] : "selected"} role. Add an explicit grant only if it should stay after the role changes.
|
||||
</span>
|
||||
) : null}
|
||||
{draftGrants.has(permissionKey) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Stored explicitly for this member.
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingMemberId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!editingMember) return;
|
||||
updateMemberMutation.mutate({
|
||||
memberId: editingMember.id,
|
||||
membershipRole: draftRole,
|
||||
status: draftStatus,
|
||||
grants: [...draftGrants],
|
||||
});
|
||||
}}
|
||||
disabled={updateMemberMutation.isPending}
|
||||
>
|
||||
{updateMemberMutation.isPending ? "Saving…" : "Save access"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingJoinRequestCard({
|
||||
title,
|
||||
subtitle,
|
||||
context,
|
||||
detail,
|
||||
detailSecondary,
|
||||
approveLabel,
|
||||
rejectLabel,
|
||||
disabled,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
context: string;
|
||||
detail: string;
|
||||
detailSecondary?: string;
|
||||
approveLabel: string;
|
||||
rejectLabel: string;
|
||||
disabled: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{subtitle}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{context}</div>
|
||||
<div className="text-sm text-muted-foreground">{detail}</div>
|
||||
{detailSecondary ? <div className="text-sm text-muted-foreground">{detailSecondary}</div> : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onReject} disabled={disabled}>
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
<Button type="button" onClick={onApprove} disabled={disabled}>
|
||||
{approveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
ui/src/pages/CompanyInvites.test.tsx
Normal file
267
ui/src/pages/CompanyInvites.test.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanyInvites } from "./CompanyInvites";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const listInvitesMock = vi.hoisted(() => vi.fn());
|
||||
const createCompanyInviteMock = vi.hoisted(() => vi.fn());
|
||||
const revokeInviteMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
|
||||
const clipboardWriteTextMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
listInvites: (companyId: string, options?: unknown) => listInvitesMock(companyId, options),
|
||||
createCompanyInvite: (companyId: string, input: unknown) =>
|
||||
createCompanyInviteMock(companyId, input),
|
||||
revokeInvite: (inviteId: string) => revokeInviteMock(inviteId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompanyInvites", () => {
|
||||
let container: HTMLDivElement;
|
||||
const inviteHistory = Array.from({ length: 25 }, (_, index) => {
|
||||
const inviteNumber = 25 - index;
|
||||
const isActive = inviteNumber === 25;
|
||||
return {
|
||||
id: `invite-${inviteNumber}`,
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
tokenHash: `hash-${inviteNumber}`,
|
||||
allowedJoinTypes: "human",
|
||||
defaultsPayload: null,
|
||||
expiresAt: "2026-04-20T00:00:00.000Z",
|
||||
invitedByUserId: "user-1",
|
||||
revokedAt: null,
|
||||
acceptedAt: isActive ? null : "2026-04-11T00:00:00.000Z",
|
||||
createdAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`,
|
||||
updatedAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`,
|
||||
companyName: "Paperclip",
|
||||
humanRole: isActive ? "operator" : "viewer",
|
||||
inviteMessage: null,
|
||||
state: isActive ? "active" : "accepted",
|
||||
invitedByUser: {
|
||||
id: "user-1",
|
||||
name: `Board User ${inviteNumber}`,
|
||||
email: `board${inviteNumber}@paperclip.local`,
|
||||
image: null,
|
||||
},
|
||||
relatedJoinRequestId: isActive ? "join-1" : null,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
listInvitesMock.mockImplementation((_companyId: string, options?: { limit?: number; offset?: number }) => {
|
||||
const limit = options?.limit ?? 20;
|
||||
const offset = options?.offset ?? 0;
|
||||
const invites = inviteHistory.slice(offset, offset + limit);
|
||||
const nextOffset = offset + invites.length < inviteHistory.length ? offset + invites.length : null;
|
||||
return Promise.resolve({ invites, nextOffset });
|
||||
});
|
||||
|
||||
createCompanyInviteMock.mockResolvedValue({
|
||||
inviteUrl: "https://paperclip.local/invite/new-token",
|
||||
onboardingTextUrl: null,
|
||||
onboardingTextPath: null,
|
||||
humanRole: "viewer",
|
||||
allowedJoinTypes: "human",
|
||||
});
|
||||
|
||||
revokeInviteMock.mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(globalThis.navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: clipboardWriteTextMock },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a human-only invite flow and keeps invite history in a table", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyInvites />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company Invites");
|
||||
expect(container.textContent).toContain("Create invite");
|
||||
expect(container.textContent).toContain("Invite history");
|
||||
expect(container.textContent).toContain("Board User 25");
|
||||
expect(container.textContent).toContain("Board User 21");
|
||||
expect(container.textContent).not.toContain("Board User 20");
|
||||
expect(container.textContent).toContain("Review request");
|
||||
expect(container.textContent).toContain("View more");
|
||||
expect(container.textContent).not.toContain("Human or agent");
|
||||
expect(container.textContent).not.toContain("Invite message");
|
||||
expect(container.textContent).not.toContain("Latest generated invite");
|
||||
expect(container.textContent).not.toContain("Active invites");
|
||||
expect(container.textContent).not.toContain("Consumed invites");
|
||||
expect(container.textContent).not.toContain("Expired invites");
|
||||
expect(container.textContent).not.toContain("OpenClaw shortcut");
|
||||
|
||||
expect(container.textContent).toContain("Choose a role");
|
||||
expect(container.textContent).toContain("Each invite link is single-use.");
|
||||
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
|
||||
expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants.");
|
||||
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
|
||||
|
||||
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "View more",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
viewMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 5 });
|
||||
expect(container.textContent).toContain("Board User 20");
|
||||
expect(container.textContent).toContain("Board User 16");
|
||||
expect(container.textContent).toContain("View more");
|
||||
|
||||
await act(async () => {
|
||||
const viewerRadio = container.querySelector('input[type="radio"][value="viewer"]') as HTMLInputElement | null;
|
||||
viewerRadio?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
viewerRadio?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const createButton = buttons.find((button) => button.textContent === "Create invite");
|
||||
const revokeButton = buttons.find((button) => button.textContent === "Revoke");
|
||||
|
||||
expect(createButton).toBeTruthy();
|
||||
expect(revokeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", {
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: "viewer",
|
||||
agentMessage: null,
|
||||
});
|
||||
expect(clipboardWriteTextMock).toHaveBeenCalledWith("https://paperclip.local/invite/new-token");
|
||||
expect(container.textContent).toContain("Latest invite link");
|
||||
expect(container.textContent).toContain("This URL includes the current Paperclip domain returned by the server.");
|
||||
expect(container.textContent).toContain("https://paperclip.local/invite/new-token");
|
||||
expect(container.textContent).toContain("Open invite");
|
||||
expect(pushToastMock).toHaveBeenCalledWith({
|
||||
title: "Invite created",
|
||||
body: "Invite ready below and copied to clipboard.",
|
||||
tone: "success",
|
||||
});
|
||||
|
||||
const inviteFieldButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("https://paperclip.local/invite/new-token"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
inviteFieldButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(clipboardWriteTextMock).toHaveBeenCalledTimes(2);
|
||||
expect(container.textContent).toContain("Copied");
|
||||
|
||||
await act(async () => {
|
||||
revokeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(revokeInviteMock).toHaveBeenCalledWith("invite-25");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores legacy cached invite arrays and refetches paginated history", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(["access", "invites", "company-1", "all"], inviteHistory.slice(0, 2));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyInvites />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Board User 25");
|
||||
expect(container.textContent).not.toContain("Board User 20");
|
||||
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
|
||||
expect(queryClient.getQueryData(queryKeys.access.invites("company-1", "all", 5))).toMatchObject({
|
||||
pages: [
|
||||
{
|
||||
invites: expect.any(Array),
|
||||
nextOffset: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
374
ui/src/pages/CompanyInvites.tsx
Normal file
374
ui/src/pages/CompanyInvites.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, MailPlus } from "lucide-react";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const inviteRoleOptions = [
|
||||
{
|
||||
value: "viewer",
|
||||
label: "Viewer",
|
||||
description: "Can view company work and follow along without operational permissions.",
|
||||
gets: "No built-in grants.",
|
||||
},
|
||||
{
|
||||
value: "operator",
|
||||
label: "Operator",
|
||||
description: "Recommended for people who need to help run work without managing access.",
|
||||
gets: "Can assign tasks.",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin",
|
||||
description: "Recommended for operators who need to invite people, create agents, and approve joins.",
|
||||
gets: "Can create agents, invite users, assign tasks, and approve join requests.",
|
||||
},
|
||||
{
|
||||
value: "owner",
|
||||
label: "Owner",
|
||||
description: "Full company access, including membership and permission management.",
|
||||
gets: "Everything in Admin, plus managing members and permission grants.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const INVITE_HISTORY_PAGE_SIZE = 5;
|
||||
|
||||
function isInviteHistoryRow(value: unknown): value is Awaited<ReturnType<typeof accessApi.listInvites>>["invites"][number] {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
return "id" in value && "state" in value && "createdAt" in value;
|
||||
}
|
||||
|
||||
export function CompanyInvites() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator");
|
||||
const [latestInviteUrl, setLatestInviteUrl] = useState<string | null>(null);
|
||||
const [latestInviteCopied, setLatestInviteCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestInviteCopied) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLatestInviteCopied(false);
|
||||
}, 1600);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [latestInviteCopied]);
|
||||
|
||||
async function copyInviteUrl(url: string) {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the unavailable message below.
|
||||
}
|
||||
|
||||
pushToast({
|
||||
title: "Clipboard unavailable",
|
||||
body: "Copy the invite URL manually from the field below.",
|
||||
tone: "warn",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Invites" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", INVITE_HISTORY_PAGE_SIZE);
|
||||
const invitesQuery = useInfiniteQuery({
|
||||
queryKey: inviteHistoryQueryKey,
|
||||
queryFn: ({ pageParam }) =>
|
||||
accessApi.listInvites(selectedCompanyId!, {
|
||||
limit: INVITE_HISTORY_PAGE_SIZE,
|
||||
offset: pageParam,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
|
||||
});
|
||||
const inviteHistory = useMemo(
|
||||
() =>
|
||||
invitesQuery.data?.pages.flatMap((page) =>
|
||||
Array.isArray(page?.invites) ? page.invites.filter(isInviteHistoryRow) : [],
|
||||
) ?? [],
|
||||
[invitesQuery.data?.pages],
|
||||
);
|
||||
|
||||
const createInviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||
allowedJoinTypes: "human",
|
||||
humanRole,
|
||||
agentMessage: null,
|
||||
}),
|
||||
onSuccess: async (invite) => {
|
||||
setLatestInviteUrl(invite.inviteUrl);
|
||||
setLatestInviteCopied(false);
|
||||
const copied = await copyInviteUrl(invite.inviteUrl);
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
|
||||
pushToast({
|
||||
title: "Invite created",
|
||||
body: copied ? "Invite ready below and copied to clipboard." : "Invite ready below.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to create invite",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (inviteId: string) => accessApi.revokeInvite(inviteId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
|
||||
pushToast({ title: "Invite revoked", tone: "success" });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to revoke invite",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="text-sm text-muted-foreground">Select a company to manage invites.</div>;
|
||||
}
|
||||
|
||||
if (invitesQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading invites…</div>;
|
||||
}
|
||||
|
||||
if (invitesQuery.error) {
|
||||
const message =
|
||||
invitesQuery.error instanceof ApiError && invitesQuery.error.status === 403
|
||||
? "You do not have permission to manage company invites."
|
||||
: invitesQuery.error instanceof Error
|
||||
? invitesQuery.error.message
|
||||
: "Failed to load invites.";
|
||||
return <div className="text-sm text-destructive">{message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MailPlus className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Invites</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Create human invite links for company access. New invite links are copied to your clipboard when they are generated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-border p-5">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Create invite</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate a human invite link and choose the default access it should request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-medium">Choose a role</legend>
|
||||
<div className="rounded-xl border border-border">
|
||||
{inviteRoleOptions.map((option, index) => {
|
||||
const checked = humanRole === option.value;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex cursor-pointer gap-3 px-4 py-4 ${index > 0 ? "border-t border-border" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="invite-role"
|
||||
value={option.value}
|
||||
checked={checked}
|
||||
onChange={() => setHumanRole(option.value)}
|
||||
className="mt-1 h-4 w-4 border-border text-foreground"
|
||||
/>
|
||||
<span className="min-w-0 space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
{option.value === "operator" ? (
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
Default
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block max-w-2xl text-sm text-muted-foreground">{option.description}</span>
|
||||
<span className="block text-sm text-foreground">{option.gets}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => createInviteMutation.mutate()} disabled={createInviteMutation.isPending}>
|
||||
{createInviteMutation.isPending ? "Creating…" : "Create invite"}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Invite history below keeps the audit trail.</span>
|
||||
</div>
|
||||
|
||||
{latestInviteUrl ? (
|
||||
<div className="space-y-3 rounded-lg border border-border px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">Latest invite link</div>
|
||||
{latestInviteCopied ? (
|
||||
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Copied
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This URL includes the current Paperclip domain returned by the server.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const copied = await copyInviteUrl(latestInviteUrl);
|
||||
setLatestInviteCopied(copied);
|
||||
}}
|
||||
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all transition-colors hover:bg-background"
|
||||
>
|
||||
{latestInviteUrl}
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={latestInviteUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open invite
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Invite history</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review invite status, role, inviter, and any linked join request.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/inbox/requests" className="text-sm underline underline-offset-4">
|
||||
Open join request queue
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{inviteHistory.length === 0 ? (
|
||||
<div className="border-t border-border px-5 py-8 text-sm text-muted-foreground">
|
||||
No invites have been created for this company yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">State</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Role</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Invited by</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Created</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Join request</th>
|
||||
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inviteHistory.map((invite) => (
|
||||
<tr key={invite.id} className="border-b border-border last:border-b-0">
|
||||
<td className="px-5 py-3 align-top">
|
||||
<span className="inline-flex rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{formatInviteState(invite.state)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 align-top">{invite.humanRole ?? "—"}</td>
|
||||
<td className="px-5 py-3 align-top">
|
||||
<div>{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}</div>
|
||||
{invite.invitedByUser?.email && invite.invitedByUser.name ? (
|
||||
<div className="text-xs text-muted-foreground">{invite.invitedByUser.email}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-5 py-3 align-top text-muted-foreground">
|
||||
{new Date(invite.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-5 py-3 align-top">
|
||||
{invite.relatedJoinRequestId ? (
|
||||
<Link to="/inbox/requests" className="underline underline-offset-4">
|
||||
Review request
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right align-top">
|
||||
{invite.state === "active" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => revokeMutation.mutate(invite.id)}
|
||||
disabled={revokeMutation.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Inactive</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{invitesQuery.hasNextPage ? (
|
||||
<div className="flex justify-center border-t border-border px-5 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => invitesQuery.fetchNextPage()}
|
||||
disabled={invitesQuery.isFetchingNextPage}
|
||||
>
|
||||
{invitesQuery.isFetchingNextPage ? "Loading more…" : "View more"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatInviteState(state: "active" | "accepted" | "expired" | "revoked") {
|
||||
return state.charAt(0).toUpperCase() + state.slice(1);
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { accessApi } from "../api/access";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
@@ -82,6 +84,17 @@ export function Dashboard() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const userProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
|
||||
const recentIssues = issues ? getRecentIssues(issues) : [];
|
||||
const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]);
|
||||
|
||||
@@ -320,6 +333,7 @@ export function Dashboard() {
|
||||
key={event.id}
|
||||
event={event}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
entityNameMap={entityNameMap}
|
||||
entityTitleMap={entityTitleMap}
|
||||
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
|
||||
|
||||
@@ -5,7 +5,14 @@ import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FailedRunInboxRow, InboxGroupHeader, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
|
||||
import type { CompanyJoinRequest } from "../api/access";
|
||||
import {
|
||||
FailedRunInboxRow,
|
||||
InboxGroupHeader,
|
||||
InboxIssueMetaLeading,
|
||||
InboxIssueTrailingColumns,
|
||||
formatJoinRequestInboxLabel,
|
||||
} from "./Inbox";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
||||
@@ -62,6 +69,44 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
};
|
||||
}
|
||||
|
||||
function createJoinRequest(
|
||||
overrides: Partial<CompanyJoinRequest> = {},
|
||||
): CompanyJoinRequest {
|
||||
return {
|
||||
id: "join-1",
|
||||
inviteId: "invite-1",
|
||||
companyId: "company-1",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestIp: "127.0.0.1",
|
||||
requestingUserId: "user-1",
|
||||
requestEmailSnapshot: "joiner@example.com",
|
||||
agentName: null,
|
||||
adapterType: null,
|
||||
capabilities: null,
|
||||
agentDefaultsPayload: null,
|
||||
claimSecretExpiresAt: null,
|
||||
claimSecretConsumedAt: null,
|
||||
createdAgentId: null,
|
||||
approvedByUserId: null,
|
||||
approvedAt: null,
|
||||
rejectedByUserId: null,
|
||||
rejectedAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
requesterUser: {
|
||||
id: "user-1",
|
||||
name: "Jordan Example",
|
||||
email: "joiner@example.com",
|
||||
image: null,
|
||||
},
|
||||
approvedByUser: null,
|
||||
rejectedByUser: null,
|
||||
invite: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FailedRunInboxRow", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -246,6 +291,26 @@ describe("InboxIssueTrailingColumns", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJoinRequestInboxLabel", () => {
|
||||
it("shows the human requester's name and email when available", () => {
|
||||
expect(formatJoinRequestInboxLabel(createJoinRequest())).toBe(
|
||||
"Jordan Example (joiner@example.com)",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the email snapshot when the requester profile is missing", () => {
|
||||
expect(
|
||||
formatJoinRequestInboxLabel(
|
||||
createJoinRequest({
|
||||
requesterUser: null,
|
||||
requestEmailSnapshot: "snapshot@example.com",
|
||||
requestingUserId: null,
|
||||
}),
|
||||
),
|
||||
).toBe("snapshot@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxGroupHeader", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import {
|
||||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
@@ -134,6 +135,7 @@ import {
|
||||
type InboxIssueColumn,
|
||||
type InboxKeyboardNavEntry,
|
||||
saveLastInboxTab,
|
||||
shouldShowCompanyAlerts,
|
||||
shouldShowInboxSection,
|
||||
type InboxGroupedSection,
|
||||
type InboxTab,
|
||||
@@ -184,6 +186,39 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function nonEmptyLabel(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function formatJoinRequestInboxLabel(
|
||||
joinRequest: Pick<
|
||||
JoinRequest,
|
||||
"requestType" | "agentName" | "requestEmailSnapshot" | "requestingUserId"
|
||||
> & {
|
||||
requesterUser?: {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
} | null;
|
||||
},
|
||||
) {
|
||||
if (joinRequest.requestType !== "human") {
|
||||
return `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
|
||||
}
|
||||
|
||||
const requesterName = nonEmptyLabel(joinRequest.requesterUser?.name);
|
||||
const requesterEmail =
|
||||
nonEmptyLabel(joinRequest.requesterUser?.email) ??
|
||||
nonEmptyLabel(joinRequest.requestEmailSnapshot);
|
||||
const requesterId = nonEmptyLabel(joinRequest.requestingUserId);
|
||||
|
||||
if (requesterName && requesterEmail) return `${requesterName} (${requesterEmail})`;
|
||||
if (requesterEmail) return requesterEmail;
|
||||
if (requesterName) return requesterName;
|
||||
if (requesterId) return requesterId;
|
||||
return "Human join request";
|
||||
}
|
||||
|
||||
|
||||
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
||||
|
||||
@@ -510,10 +545,7 @@ function JoinRequestInboxRow({
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const label =
|
||||
joinRequest.requestType === "human"
|
||||
? "Human join request"
|
||||
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
|
||||
const label = formatJoinRequestInboxLabel(joinRequest);
|
||||
const showUnreadSlot = unreadState !== null;
|
||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||
|
||||
@@ -787,8 +819,22 @@ export function Inbox() {
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const companyUserLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const companyUserProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const visibleMineIssues = useMemo(
|
||||
@@ -960,14 +1006,14 @@ export function Inbox() {
|
||||
}, [liveRuns]);
|
||||
|
||||
const approvalsToRender = useMemo(() => {
|
||||
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
|
||||
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId);
|
||||
if (tab === "mine") {
|
||||
filtered = filtered.filter(
|
||||
(a) => !isInboxEntityDismissed(dismissedAtByKey, `approval:${a.id}`, a.updatedAt),
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}, [approvals, tab, allApprovalFilter, dismissedAtByKey]);
|
||||
}, [approvals, tab, allApprovalFilter, currentUserId, dismissedAtByKey]);
|
||||
const showJoinRequestsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||
const showTouchedCategory =
|
||||
@@ -1745,12 +1791,15 @@ export function Inbox() {
|
||||
}
|
||||
|
||||
const hasRunFailures = failedRuns.length > 0;
|
||||
const showCompanyAlerts = shouldShowCompanyAlerts(tab) && showAlertsCategory;
|
||||
const showAggregateAgentError =
|
||||
showCompanyAlerts &&
|
||||
!!dashboard &&
|
||||
dashboard.agents.error > 0 &&
|
||||
!hasRunFailures &&
|
||||
!dismissedAlerts.has("alert:agent-errors");
|
||||
const showBudgetAlert =
|
||||
showCompanyAlerts &&
|
||||
!!dashboard &&
|
||||
dashboard.costs.monthBudgetCents > 0 &&
|
||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||
@@ -1760,10 +1809,10 @@ export function Inbox() {
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
showOnMine: hasAlerts,
|
||||
showOnRecent: hasAlerts,
|
||||
showOnUnread: hasAlerts,
|
||||
showOnAll: showAlertsCategory && hasAlerts,
|
||||
showOnMine: false,
|
||||
showOnRecent: false,
|
||||
showOnUnread: false,
|
||||
showOnAll: hasAlerts,
|
||||
});
|
||||
|
||||
const visibleSections = [
|
||||
@@ -2064,6 +2113,9 @@ export function Inbox() {
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
const isArchiving = archivingIssueIds.has(issue.id);
|
||||
const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
const assigneeUserProfile = issue.assigneeUserId
|
||||
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
|
||||
: null;
|
||||
return (
|
||||
<IssueRow
|
||||
key={`issue:${issue.id}`}
|
||||
@@ -2140,6 +2192,12 @@ export function Inbox() {
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
assigneeUserName={
|
||||
formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, companyUserLabelMap)
|
||||
?? assigneeUserProfile?.label
|
||||
?? null
|
||||
}
|
||||
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
|
||||
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
|
||||
|
||||
245
ui/src/pages/InstanceAccess.tsx
Normal file
245
ui/src/pages/InstanceAccess.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Shield, ShieldCheck } from "lucide-react";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export function InstanceAccess() {
|
||||
const { companies } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedCompanyIds, setSelectedCompanyIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Instance Settings", href: "/instance/settings/general" },
|
||||
{ label: "Access" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: queryKeys.access.adminUsers(search),
|
||||
queryFn: () => accessApi.searchAdminUsers(search),
|
||||
});
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => usersQuery.data?.find((user) => user.id === selectedUserId) ?? null,
|
||||
[selectedUserId, usersQuery.data],
|
||||
);
|
||||
|
||||
const userAccessQuery = useQuery({
|
||||
queryKey: queryKeys.access.userCompanyAccess(selectedUserId ?? ""),
|
||||
queryFn: () => accessApi.getUserCompanyAccess(selectedUserId!),
|
||||
enabled: !!selectedUserId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUserId && usersQuery.data?.[0]) {
|
||||
setSelectedUserId(usersQuery.data[0].id);
|
||||
}
|
||||
}, [selectedUserId, usersQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userAccessQuery.data) return;
|
||||
setSelectedCompanyIds(
|
||||
new Set(userAccessQuery.data.companyAccess.map((membership) => membership.companyId)),
|
||||
);
|
||||
}, [userAccessQuery.data]);
|
||||
|
||||
const updateCompanyAccessMutation = useMutation({
|
||||
mutationFn: () => accessApi.setUserCompanyAccess(selectedUserId!, [...selectedCompanyIds]),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.userCompanyAccess(selectedUserId!) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.adminUsers(search) });
|
||||
pushToast({ title: "Company access updated", tone: "success" });
|
||||
},
|
||||
});
|
||||
|
||||
const setAdminMutation = useMutation({
|
||||
mutationFn: async (makeAdmin: boolean) => {
|
||||
if (!selectedUserId) throw new Error("No user selected");
|
||||
if (makeAdmin) return accessApi.promoteInstanceAdmin(selectedUserId);
|
||||
return accessApi.demoteInstanceAdmin(selectedUserId);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.adminUsers(search) });
|
||||
if (selectedUserId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.userCompanyAccess(selectedUserId) });
|
||||
}
|
||||
pushToast({ title: "Instance role updated", tone: "success" });
|
||||
},
|
||||
});
|
||||
|
||||
if (usersQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading instance users…</div>;
|
||||
}
|
||||
|
||||
if (usersQuery.error) {
|
||||
const message =
|
||||
usersQuery.error instanceof ApiError && usersQuery.error.status === 403
|
||||
? "Instance admin access is required to manage users."
|
||||
: usersQuery.error instanceof Error
|
||||
? usersQuery.error.message
|
||||
: "Failed to load users.";
|
||||
return <div className="text-sm text-destructive">{message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Instance Access</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Search users, manage instance-admin status, and control which companies they can access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<section className="space-y-4 rounded-xl border border-border bg-card p-4">
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-medium">Search users</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search by name or email"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{(usersQuery.data ?? []).map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left transition-colors ${
|
||||
user.id === selectedUserId
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{user.name || user.email || user.id}</div>
|
||||
<div className="truncate text-sm text-muted-foreground">{user.email || user.id}</div>
|
||||
</div>
|
||||
{user.isInstanceAdmin ? (
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-600" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{user.activeCompanyMembershipCount} active company memberships
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
{!selectedUserId ? (
|
||||
<div className="text-sm text-muted-foreground">Select a user to inspect instance access.</div>
|
||||
) : userAccessQuery.isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading user access…</div>
|
||||
) : userAccessQuery.error ? (
|
||||
<div className="text-sm text-destructive">
|
||||
{userAccessQuery.error instanceof Error ? userAccessQuery.error.message : "Failed to load user access."}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-semibold">
|
||||
{selectedUser?.name || selectedUser?.email || selectedUserId}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedUser?.email || selectedUserId}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={selectedUser?.isInstanceAdmin ? "outline" : "default"}
|
||||
onClick={() => setAdminMutation.mutate(!(selectedUser?.isInstanceAdmin ?? false))}
|
||||
disabled={setAdminMutation.isPending}
|
||||
>
|
||||
{selectedUser?.isInstanceAdmin ? "Remove instance admin" : "Promote to instance admin"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Company access</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Toggle company membership for this user. New access defaults to an active operator membership.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{companies.map((company) => (
|
||||
<label
|
||||
key={company.id}
|
||||
className="flex items-start gap-3 rounded-lg border border-border px-3 py-3"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedCompanyIds.has(company.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedCompanyIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(company.id);
|
||||
else next.delete(company.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium">{company.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{company.issuePrefix}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => updateCompanyAccessMutation.mutate()}
|
||||
disabled={updateCompanyAccessMutation.isPending}
|
||||
>
|
||||
{updateCompanyAccessMutation.isPending ? "Saving…" : "Save company access"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">Current memberships</h2>
|
||||
<div className="space-y-2">
|
||||
{(userAccessQuery.data?.companyAccess ?? []).map((membership) => (
|
||||
<div
|
||||
key={membership.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{membership.companyName || membership.companyId}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{membership.membershipRole || "unset"} • {membership.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(membership.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { LogOut, SlidersHorizontal } from "lucide-react";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { ModeBadge } from "@/components/access/ModeBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -44,6 +46,11 @@ export function InstanceGeneralSettings() {
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
});
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const updateGeneralMutation = useMutation({
|
||||
mutationFn: instanceSettingsApi.updateGeneral,
|
||||
@@ -93,6 +100,39 @@ export function InstanceGeneralSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold">Deployment and auth</h2>
|
||||
<ModeBadge
|
||||
deploymentMode={healthQuery.data?.deploymentMode}
|
||||
deploymentExposure={healthQuery.data?.deploymentExposure}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{healthQuery.data?.deploymentMode === "local_trusted"
|
||||
? "Local trusted mode is optimized for a local operator. Browser requests run as local board context and no sign-in is required."
|
||||
: healthQuery.data?.deploymentExposure === "public"
|
||||
? "Authenticated public mode requires sign-in for board access and is intended for public URLs."
|
||||
: "Authenticated private mode requires sign-in and is intended for LAN, VPN, or other private-network deployments."}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<StatusBox
|
||||
label="Auth readiness"
|
||||
value={healthQuery.data?.authReady ? "Ready" : "Not ready"}
|
||||
/>
|
||||
<StatusBox
|
||||
label="Bootstrap status"
|
||||
value={healthQuery.data?.bootstrapStatus === "bootstrap_pending" ? "Setup required" : "Ready"}
|
||||
/>
|
||||
<StatusBox
|
||||
label="Bootstrap invite"
|
||||
value={healthQuery.data?.bootstrapInviteActive ? "Active" : "None"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
@@ -330,3 +370,12 @@ export function InstanceGeneralSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background px-3 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="mt-2 text-sm font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
657
ui/src/pages/InviteLanding.test.tsx
Normal file
657
ui/src/pages/InviteLanding.test.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { InviteLandingPage } from "./InviteLanding";
|
||||
|
||||
const getInviteMock = vi.hoisted(() => vi.fn());
|
||||
const acceptInviteMock = vi.hoisted(() => vi.fn());
|
||||
const getSessionMock = vi.hoisted(() => vi.fn());
|
||||
const signInEmailMock = vi.hoisted(() => vi.fn());
|
||||
const signUpEmailMock = vi.hoisted(() => vi.fn());
|
||||
const healthGetMock = vi.hoisted(() => vi.fn());
|
||||
const listCompaniesMock = vi.hoisted(() => vi.fn());
|
||||
const setSelectedCompanyIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/access", () => ({
|
||||
accessApi: {
|
||||
getInvite: (token: string) => getInviteMock(token),
|
||||
acceptInvite: (token: string, input: unknown) => acceptInviteMock(token, input),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: {
|
||||
getSession: () => getSessionMock(),
|
||||
signInEmail: (input: unknown) => signInEmailMock(input),
|
||||
signUpEmail: (input: unknown) => signUpEmailMock(input),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/health", () => ({
|
||||
healthApi: {
|
||||
get: () => healthGetMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/companies", () => ({
|
||||
companiesApi: {
|
||||
list: () => listCompaniesMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: null,
|
||||
selectedCompanyId: null,
|
||||
companies: [],
|
||||
selectionSource: "manual",
|
||||
loading: false,
|
||||
error: null,
|
||||
setSelectedCompanyId: setSelectedCompanyIdMock,
|
||||
reloadCompanies: vi.fn(),
|
||||
createCompany: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("InviteLandingPage", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
fillStyle: "",
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
})),
|
||||
});
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => "data:image/png;base64,stub"),
|
||||
});
|
||||
|
||||
getInviteMock.mockResolvedValue({
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
|
||||
companyBrandColor: "#114488",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "both",
|
||||
humanRole: "operator",
|
||||
expiresAt: "2027-03-07T00:10:00.000Z",
|
||||
inviteMessage: "Welcome aboard.",
|
||||
});
|
||||
acceptInviteMock.mockReset();
|
||||
healthGetMock.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
});
|
||||
listCompaniesMock.mockResolvedValue([]);
|
||||
getSessionMock.mockResolvedValue(null);
|
||||
signInEmailMock.mockResolvedValue(undefined);
|
||||
signUpEmailMock.mockResolvedValue(undefined);
|
||||
setSelectedCompanyIdMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults invite auth to account creation and guides existing users back to sign in", async () => {
|
||||
signUpEmailMock.mockRejectedValue(
|
||||
Object.assign(new Error("User already exists. Use another email."), {
|
||||
code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
|
||||
status: 422,
|
||||
}),
|
||||
);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("You've been invited to join Paperclip");
|
||||
expect(container.textContent).toContain("Join Acme Robotics");
|
||||
expect(container.textContent).toContain("Create account");
|
||||
expect(container.textContent).toContain("I already have an account");
|
||||
expect(container.textContent).toContain("Message from inviter");
|
||||
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
|
||||
const inviteLogo = container.querySelector('img[alt="Acme Robotics logo"]');
|
||||
expect(inviteLogo).not.toBeNull();
|
||||
expect(inviteLogo?.className).toContain("object-contain");
|
||||
expect(container.querySelector('input[name="name"]')).not.toBeNull();
|
||||
|
||||
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null;
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
|
||||
expect(nameInput).not.toBeNull();
|
||||
expect(emailInput).not.toBeNull();
|
||||
expect(passwordInput).not.toBeNull();
|
||||
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
expect(inputValueSetter).toBeTypeOf("function");
|
||||
|
||||
await act(async () => {
|
||||
inputValueSetter!.call(nameInput, "Jane Example");
|
||||
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
nameInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
inputValueSetter!.call(emailInput, "jane@example.com");
|
||||
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
inputValueSetter!.call(passwordInput, "supersecret");
|
||||
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
|
||||
expect(authForm).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(signUpEmailMock).toHaveBeenCalledWith({
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
password: "supersecret",
|
||||
});
|
||||
expect(container.textContent).toContain("An account already exists for jane@example.com. Sign in below to continue with this invite.");
|
||||
expect(container.querySelector('input[name="name"]')).toBeNull();
|
||||
expect(container.textContent).toContain("Sign in to continue");
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("turns invalid sign-in responses into a clear invite-specific message", async () => {
|
||||
signInEmailMock.mockRejectedValue(
|
||||
Object.assign(new Error("Invalid email or password"), {
|
||||
code: "INVALID_EMAIL_OR_PASSWORD",
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
expect(inputValueSetter).toBeTypeOf("function");
|
||||
|
||||
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "I already have an account",
|
||||
);
|
||||
expect(existingAccountButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
|
||||
expect(emailInput).not.toBeNull();
|
||||
expect(passwordInput).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
inputValueSetter!.call(emailInput, "jane@example.com");
|
||||
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
inputValueSetter!.call(passwordInput, "wrongpass");
|
||||
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
|
||||
expect(authForm).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(signInEmailMock).toHaveBeenCalledWith({
|
||||
email: "jane@example.com",
|
||||
password: "wrongpass",
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-accepts the invite after account creation and redirects into the company", async () => {
|
||||
getSessionMock.mockResolvedValueOnce(null);
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
acceptInviteMock.mockResolvedValue({
|
||||
id: "join-1",
|
||||
companyId: "company-1",
|
||||
requestType: "human",
|
||||
status: "approved",
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
expect(inputValueSetter).toBeTypeOf("function");
|
||||
|
||||
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null;
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
|
||||
expect(nameInput).not.toBeNull();
|
||||
expect(emailInput).not.toBeNull();
|
||||
expect(passwordInput).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
inputValueSetter!.call(nameInput, "Jane Example");
|
||||
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputValueSetter!.call(emailInput, "jane@example.com");
|
||||
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputValueSetter!.call(passwordInput, "supersecret");
|
||||
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
|
||||
expect(authForm).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(signUpEmailMock).toHaveBeenCalledWith({
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
password: "supersecret",
|
||||
});
|
||||
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
|
||||
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the pending approval page with the company icon and linked access instructions", async () => {
|
||||
acceptInviteMock.mockResolvedValue({
|
||||
id: "join-1",
|
||||
companyId: "company-1",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
});
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
|
||||
expect(container.textContent).toContain("Request to join Acme Robotics");
|
||||
expect(container.textContent).toContain("A company admin must approve your request to join.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Access to approve your request.",
|
||||
);
|
||||
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("http://localhost/company/settings/access");
|
||||
|
||||
const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
|
||||
(link) => link.textContent === "Company Settings → Access",
|
||||
);
|
||||
expect(approvalLinks).toHaveLength(2);
|
||||
const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
|
||||
for (const link of approvalLinks) {
|
||||
expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the waiting-for-approval state on refresh for an accepted invite", async () => {
|
||||
getInviteMock.mockResolvedValue({
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
|
||||
companyBrandColor: "#114488",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "both",
|
||||
humanRole: "operator",
|
||||
expiresAt: "2027-03-07T00:10:00.000Z",
|
||||
inviteMessage: "Welcome aboard.",
|
||||
joinRequestStatus: "pending_approval",
|
||||
joinRequestType: "human",
|
||||
});
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(acceptInviteMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Your request is still awaiting approval.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Access to approve your request.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects straight to the company after sign-in when the user already has access", async () => {
|
||||
getSessionMock.mockResolvedValueOnce(null);
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
expect(inputValueSetter).toBeTypeOf("function");
|
||||
|
||||
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "I already have an account",
|
||||
);
|
||||
expect(existingAccountButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
|
||||
expect(emailInput).not.toBeNull();
|
||||
expect(passwordInput).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
inputValueSetter!.call(emailInput, "jane@example.com");
|
||||
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputValueSetter!.call(passwordInput, "supersecret");
|
||||
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
|
||||
expect(authForm).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(signInEmailMock).toHaveBeenCalledWith({
|
||||
email: "jane@example.com",
|
||||
password: "supersecret",
|
||||
});
|
||||
expect(acceptInviteMock).not.toHaveBeenCalled();
|
||||
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the generated company icon when the invite logo fails to load", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const logo = container.querySelector('img[alt="Acme Robotics logo"]') as HTMLImageElement | null;
|
||||
expect(logo).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
logo?.dispatchEvent(new Event("error"));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('img[alt="Acme Robotics logo"]')).toBeNull();
|
||||
expect(container.querySelector('img[aria-hidden="true"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the membership check before showing invite acceptance to signed-in users", async () => {
|
||||
let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null;
|
||||
acceptInviteMock.mockResolvedValue({
|
||||
id: "join-1",
|
||||
companyId: "company-1",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
});
|
||||
listCompaniesMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<Array<{ id: string; name: string }>>((resolve) => {
|
||||
resolveCompanies = resolve;
|
||||
}),
|
||||
);
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Checking your access...");
|
||||
expect(container.textContent).not.toContain("Accept company invite");
|
||||
expect(acceptInviteMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveCompanies?.([]);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
|
||||
expect(container.textContent).toContain("Request to join Acme Robotics");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,32 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { healthApi } from "../api/health";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
|
||||
type JoinType = "human" | "agent";
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { healthApi } from "../api/health";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatDate } from "../lib/utils";
|
||||
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
type AuthMode = "sign_in" | "sign_up";
|
||||
type AuthFeedback = { tone: "error" | "info"; message: string };
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
const ENABLED_INVITE_ADAPTERS = new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
]);
|
||||
|
||||
function readNestedString(value: unknown, path: string[]): string | null {
|
||||
let current: unknown = value;
|
||||
@@ -29,16 +37,198 @@ function readNestedString(value: unknown, path: string[]): string | null {
|
||||
return typeof current === "string" && current.trim().length > 0 ? current : null;
|
||||
}
|
||||
|
||||
const fieldClassName =
|
||||
"w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
|
||||
const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6";
|
||||
const modeButtonBaseClassName =
|
||||
"flex-1 border px-3 py-2 text-sm transition-colors";
|
||||
|
||||
function formatHumanRole(role: string | null | undefined) {
|
||||
if (!role) return null;
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
}
|
||||
|
||||
function getAuthErrorCode(error: unknown) {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === "string" && code.trim().length > 0 ? code : null;
|
||||
}
|
||||
|
||||
function getAuthErrorMessage(error: unknown) {
|
||||
if (!(error instanceof Error)) return null;
|
||||
const message = error.message.trim();
|
||||
return message.length > 0 ? message : null;
|
||||
}
|
||||
|
||||
function mapInviteAuthFeedback(
|
||||
error: unknown,
|
||||
authMode: AuthMode,
|
||||
email: string,
|
||||
): AuthFeedback {
|
||||
const code = getAuthErrorCode(error);
|
||||
const message = getAuthErrorMessage(error);
|
||||
const emailLabel = email.trim().length > 0 ? email.trim() : "that email";
|
||||
|
||||
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||
return {
|
||||
tone: "info",
|
||||
message: `An account already exists for ${emailLabel}. Sign in below to continue with this invite.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (code === "INVALID_EMAIL_OR_PASSWORD") {
|
||||
return {
|
||||
tone: "error",
|
||||
message:
|
||||
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
|
||||
};
|
||||
}
|
||||
|
||||
if (authMode === "sign_in" && message === "Request failed: 401") {
|
||||
return {
|
||||
tone: "error",
|
||||
message:
|
||||
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
|
||||
};
|
||||
}
|
||||
|
||||
if (authMode === "sign_up" && message === "Request failed: 422") {
|
||||
return {
|
||||
tone: "info",
|
||||
message: `An account may already exist for ${emailLabel}. Try signing in instead.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "error",
|
||||
message: message ?? "Authentication failed",
|
||||
};
|
||||
}
|
||||
|
||||
function isBootstrapAcceptancePayload(payload: unknown) {
|
||||
return Boolean(
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"bootstrapAccepted" in (payload as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
|
||||
function isApprovedHumanJoinPayload(payload: unknown, showsAgentForm: boolean) {
|
||||
if (!payload || typeof payload !== "object" || showsAgentForm) return false;
|
||||
const status = (payload as { status?: unknown }).status;
|
||||
return status === "approved";
|
||||
}
|
||||
|
||||
type AwaitingJoinApprovalPanelProps = {
|
||||
companyDisplayName: string;
|
||||
companyLogoUrl: string | null;
|
||||
companyBrandColor: string | null;
|
||||
invitedByUserName: string | null;
|
||||
claimSecret?: string | null;
|
||||
claimApiKeyPath?: string | null;
|
||||
onboardingTextUrl?: string | null;
|
||||
};
|
||||
|
||||
function InviteCompanyLogo({
|
||||
companyDisplayName,
|
||||
companyLogoUrl,
|
||||
companyBrandColor,
|
||||
className,
|
||||
}: {
|
||||
companyDisplayName: string;
|
||||
companyLogoUrl: string | null;
|
||||
companyBrandColor: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<CompanyPatternIcon
|
||||
companyName={companyDisplayName}
|
||||
logoUrl={companyLogoUrl}
|
||||
brandColor={companyBrandColor}
|
||||
logoFit="contain"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AwaitingJoinApprovalPanel({
|
||||
companyDisplayName,
|
||||
companyLogoUrl,
|
||||
companyBrandColor,
|
||||
invitedByUserName,
|
||||
claimSecret = null,
|
||||
claimApiKeyPath = null,
|
||||
onboardingTextUrl = null,
|
||||
}: AwaitingJoinApprovalPanelProps) {
|
||||
const approvalUrl = `${window.location.origin}/company/settings/access`;
|
||||
const approverLabel = invitedByUserName ?? "A company admin";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
||||
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6" data-testid="invite-pending-approval">
|
||||
<div className="flex items-center gap-3">
|
||||
<InviteCompanyLogo
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
className="h-12 w-12 border border-zinc-800 rounded-none"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">Request to join {companyDisplayName}</h1>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Your request is still awaiting approval. {approverLabel} must approve your request to join.
|
||||
</p>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<p className="text-xs text-zinc-500 mb-1">Approval page</p>
|
||||
<a
|
||||
href={approvalUrl}
|
||||
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
|
||||
>
|
||||
Company Settings → Access
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings → Access</a> to approve your request.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Refresh this page after you've been approved — you'll be redirected automatically.
|
||||
</p>
|
||||
</div>
|
||||
{claimSecret && claimApiKeyPath ? (
|
||||
<div className="mt-4 space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
|
||||
<div className="text-zinc-200">Claim secret</div>
|
||||
<div className="font-mono break-all">{claimSecret}</div>
|
||||
<div className="font-mono break-all">POST {claimApiKeyPath}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{onboardingTextUrl ? (
|
||||
<div className="mt-4 text-xs text-zinc-400">
|
||||
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteLandingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { setSelectedCompanyId } = useCompany();
|
||||
const params = useParams();
|
||||
const token = (params.token ?? "").trim();
|
||||
const [joinType, setJoinType] = useState<JoinType>("human");
|
||||
const [authMode, setAuthMode] = useState<AuthMode>("sign_up");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [agentName, setAgentName] = useState("");
|
||||
const [adapterType, setAdapterType] = useState<AgentAdapterType>("claude_local");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
const [result, setResult] = useState<{ kind: "bootstrap" | "join"; payload: unknown } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authFeedback, setAuthFeedback] = useState<AuthFeedback | null>(null);
|
||||
const [autoAcceptStarted, setAutoAcceptStarted] = useState(false);
|
||||
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
@@ -57,33 +247,85 @@ export function InviteLandingPage() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const invite = inviteQuery.data;
|
||||
const companyName = invite?.companyName?.trim() || null;
|
||||
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
|
||||
const availableJoinTypes = useMemo(() => {
|
||||
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
|
||||
if (allowedJoinTypes === "both") return ["human", "agent"] as JoinType[];
|
||||
return [allowedJoinTypes] as JoinType[];
|
||||
}, [invite?.inviteType, allowedJoinTypes]);
|
||||
const companiesQuery = useQuery({
|
||||
queryKey: queryKeys.companies.all,
|
||||
queryFn: () => companiesApi.list(),
|
||||
enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableJoinTypes.includes(joinType)) {
|
||||
setJoinType(availableJoinTypes[0] ?? "human");
|
||||
}
|
||||
}, [availableJoinTypes, joinType]);
|
||||
if (token) rememberPendingInviteToken(token);
|
||||
}, [token]);
|
||||
|
||||
const requiresAuthForHuman =
|
||||
joinType === "human" &&
|
||||
useEffect(() => {
|
||||
setAutoAcceptStarted(false);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companiesQuery.data || !inviteQuery.data?.companyId) return;
|
||||
const isMember = companiesQuery.data.some(
|
||||
(c) => c.id === inviteQuery.data!.companyId
|
||||
);
|
||||
if (isMember) {
|
||||
clearPendingInviteToken(token);
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [companiesQuery.data, inviteQuery.data, token, navigate]);
|
||||
|
||||
const invite = inviteQuery.data;
|
||||
const isCheckingExistingMembership =
|
||||
Boolean(sessionQuery.data) &&
|
||||
Boolean(invite?.companyId) &&
|
||||
companiesQuery.isLoading;
|
||||
const isCurrentMember =
|
||||
Boolean(invite?.companyId) &&
|
||||
Boolean(
|
||||
companiesQuery.data?.some((company) => company.id === invite?.companyId),
|
||||
);
|
||||
const companyName = invite?.companyName?.trim() || null;
|
||||
const companyDisplayName = companyName || "this Paperclip company";
|
||||
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
|
||||
const companyBrandColor = invite?.companyBrandColor?.trim() || null;
|
||||
const invitedByUserName = invite?.invitedByUserName?.trim() || null;
|
||||
const inviteMessage = invite?.inviteMessage?.trim() || null;
|
||||
const requestedHumanRole = formatHumanRole(invite?.humanRole);
|
||||
const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null;
|
||||
const inviteJoinRequestType = invite?.joinRequestType ?? null;
|
||||
const requiresHumanAccount =
|
||||
healthQuery.data?.deploymentMode === "authenticated" &&
|
||||
!sessionQuery.data;
|
||||
!sessionQuery.data &&
|
||||
invite?.allowedJoinTypes !== "agent";
|
||||
const showsAgentForm = invite?.inviteType !== "bootstrap_ceo" && invite?.allowedJoinTypes === "agent";
|
||||
const shouldAutoAcceptHumanInvite =
|
||||
Boolean(sessionQuery.data) &&
|
||||
!showsAgentForm &&
|
||||
invite?.inviteType !== "bootstrap_ceo" &&
|
||||
!inviteJoinRequestStatus &&
|
||||
!isCheckingExistingMembership &&
|
||||
!isCurrentMember &&
|
||||
!result &&
|
||||
error === null;
|
||||
const sessionLabel =
|
||||
sessionQuery.data?.user.name?.trim() ||
|
||||
sessionQuery.data?.user.email?.trim() ||
|
||||
"this account";
|
||||
|
||||
const authCanSubmit =
|
||||
email.trim().length > 0 &&
|
||||
password.trim().length > 0 &&
|
||||
(authMode === "sign_in" || (name.trim().length > 0 && password.trim().length >= 8));
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!invite) throw new Error("Invite not found");
|
||||
if (invite.inviteType === "bootstrap_ceo") {
|
||||
return accessApi.acceptInvite(token, { requestType: "human" });
|
||||
if (isCheckingExistingMembership) {
|
||||
throw new Error("Checking your company access. Try again in a moment.");
|
||||
}
|
||||
if (joinType === "human") {
|
||||
if (isCurrentMember) {
|
||||
throw new Error("This account already belongs to the company.");
|
||||
}
|
||||
if (invite.inviteType === "bootstrap_ceo" || invite.allowedJoinTypes !== "agent") {
|
||||
return accessApi.acceptInvite(token, { requestType: "human" });
|
||||
}
|
||||
return accessApi.acceptInvite(token, {
|
||||
@@ -95,17 +337,87 @@ export function InviteLandingPage() {
|
||||
},
|
||||
onSuccess: async (payload) => {
|
||||
setError(null);
|
||||
clearPendingInviteToken(token);
|
||||
const asBootstrap = isBootstrapAcceptancePayload(payload);
|
||||
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
const asBootstrap =
|
||||
payload && typeof payload === "object" && "bootstrapAccepted" in (payload as Record<string, unknown>);
|
||||
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
|
||||
if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) {
|
||||
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to accept invite");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoAcceptHumanInvite || autoAcceptStarted || acceptMutation.isPending) return;
|
||||
setAutoAcceptStarted(true);
|
||||
setError(null);
|
||||
acceptMutation.mutate();
|
||||
}, [acceptMutation, autoAcceptStarted, shouldAutoAcceptHumanInvite]);
|
||||
|
||||
const authMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (authMode === "sign_in") {
|
||||
await authApi.signInEmail({ email: email.trim(), password });
|
||||
return;
|
||||
}
|
||||
await authApi.signUpEmail({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setAuthFeedback(null);
|
||||
rememberPendingInviteToken(token);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
const companies = await queryClient.fetchQuery({
|
||||
queryKey: queryKeys.companies.all,
|
||||
queryFn: () => companiesApi.list(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (invite?.companyId && companies.some((company) => company.id === invite.companyId)) {
|
||||
clearPendingInviteToken(token);
|
||||
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
||||
navigate("/", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invite || invite.inviteType !== "bootstrap_ceo") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await acceptMutation.mutateAsync();
|
||||
if (isBootstrapAcceptancePayload(payload)) {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
const nextFeedback = mapInviteAuthFeedback(err, authMode, email);
|
||||
if (getAuthErrorCode(err) === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||
setAuthMode("sign_in");
|
||||
setPassword("");
|
||||
}
|
||||
setAuthFeedback(nextFeedback);
|
||||
},
|
||||
});
|
||||
|
||||
const joinButtonLabel = useMemo(() => {
|
||||
if (!invite) return "Continue";
|
||||
if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
|
||||
if (showsAgentForm) return "Submit request";
|
||||
return sessionQuery.data ? "Accept invite" : "Continue";
|
||||
}, [invite, sessionQuery.data, showsAgentForm]);
|
||||
|
||||
if (!token) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
|
||||
}
|
||||
@@ -114,10 +426,14 @@ export function InviteLandingPage() {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading invite...</div>;
|
||||
}
|
||||
|
||||
if (isCheckingExistingMembership) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Checking your access...</div>;
|
||||
}
|
||||
|
||||
if (inviteQuery.error || !invite) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="border border-border bg-card p-6" data-testid="invite-error">
|
||||
<h1 className="text-lg font-semibold">Invite not available</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This invite may be expired, revoked, or already used.
|
||||
@@ -127,17 +443,50 @@ export function InviteLandingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.kind === "bootstrap") {
|
||||
if (
|
||||
inviteJoinRequestStatus === "approved" &&
|
||||
inviteJoinRequestType === "human" &&
|
||||
isCurrentMember
|
||||
) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Opening company...</div>;
|
||||
}
|
||||
|
||||
if (inviteJoinRequestStatus === "pending_approval") {
|
||||
return (
|
||||
<AwaitingJoinApprovalPanel
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
invitedByUserName={invitedByUserName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (inviteJoinRequestStatus) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
|
||||
<div className="border border-border bg-card p-6" data-testid="invite-error">
|
||||
<h1 className="text-lg font-semibold">Invite not available</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The first instance admin is now configured. You can continue to the board.
|
||||
{inviteJoinRequestStatus === "rejected"
|
||||
? "This join request was not approved."
|
||||
: "This invite has already been used."}
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.kind === "bootstrap") {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
||||
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
|
||||
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
|
||||
<div className="mt-4">
|
||||
<Button asChild className="rounded-none">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -148,171 +497,330 @@ export function InviteLandingPage() {
|
||||
claimSecret?: string;
|
||||
claimApiKeyPath?: string;
|
||||
onboarding?: Record<string, unknown>;
|
||||
diagnostics?: Array<{
|
||||
code: string;
|
||||
level: "info" | "warn";
|
||||
message: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
};
|
||||
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
|
||||
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
|
||||
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
|
||||
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
|
||||
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
|
||||
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
|
||||
const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]);
|
||||
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
||||
const joinedNow = !showsAgentForm && payload.status === "approved";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold">Join request submitted</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Your request is pending admin approval. You will not have access until approved.
|
||||
</p>
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Request ID: <span className="font-mono">{payload.id}</span>
|
||||
joinedNow ? (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
||||
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<InviteCompanyLogo
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
className="h-12 w-12 border border-zinc-800 rounded-none"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">You joined the company</h1>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button asChild className="w-full rounded-none">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{claimSecret && claimApiKeyPath && (
|
||||
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">One-time claim secret (save now)</p>
|
||||
<p className="font-mono break-all">{claimSecret}</p>
|
||||
<p className="font-mono break-all">POST {claimApiKeyPath}</p>
|
||||
</div>
|
||||
)}
|
||||
{(onboardingSkillUrl || onboardingSkillPath || onboardingInstallPath) && (
|
||||
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Paperclip skill bootstrap</p>
|
||||
{onboardingSkillUrl && <p className="font-mono break-all">GET {onboardingSkillUrl}</p>}
|
||||
{!onboardingSkillUrl && onboardingSkillPath && <p className="font-mono break-all">GET {onboardingSkillPath}</p>}
|
||||
{onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>}
|
||||
</div>
|
||||
)}
|
||||
{(onboardingTextUrl || onboardingTextPath) && (
|
||||
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Agent-readable onboarding text</p>
|
||||
{onboardingTextUrl && <p className="font-mono break-all">GET {onboardingTextUrl}</p>}
|
||||
{!onboardingTextUrl && onboardingTextPath && <p className="font-mono break-all">GET {onboardingTextPath}</p>}
|
||||
</div>
|
||||
)}
|
||||
{diagnostics.length > 0 && (
|
||||
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Connectivity diagnostics</p>
|
||||
{diagnostics.map((diag, idx) => (
|
||||
<div key={`${diag.code}:${idx}`} className="space-y-0.5">
|
||||
<p className={diag.level === "warn" ? "text-amber-600 dark:text-amber-400" : undefined}>
|
||||
[{diag.level}] {diag.message}
|
||||
</p>
|
||||
{diag.hint && <p className="font-mono break-all">{diag.hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AwaitingJoinApprovalPanel
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
invitedByUserName={invitedByUserName}
|
||||
claimSecret={claimSecret}
|
||||
claimApiKeyPath={claimApiKeyPath}
|
||||
onboardingTextUrl={onboardingTextUrl}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{invite.inviteType === "bootstrap_ceo"
|
||||
? "Bootstrap your Paperclip instance"
|
||||
: companyName
|
||||
? `Join ${companyName}`
|
||||
: "Join this Paperclip company"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{invite.inviteType !== "bootstrap_ceo" && companyName
|
||||
? `You were invited to join ${companyName}. `
|
||||
: null}
|
||||
Invite expires {dateTime(invite.expiresAt)}.
|
||||
</p>
|
||||
|
||||
{invite.inviteType !== "bootstrap_ceo" && (
|
||||
<div className="mt-5 flex gap-2">
|
||||
{availableJoinTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setJoinType(type)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${
|
||||
joinType === type
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border bg-background text-foreground"
|
||||
}`}
|
||||
>
|
||||
Join as {type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Agent name</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
value={agentName}
|
||||
onChange={(event) => setAgentName(event.target.value)}
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<section className={`${panelClassName} space-y-6`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<InviteCompanyLogo
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
className="h-16 w-16 rounded-none border border-zinc-800"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Adapter type</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
value={adapterType}
|
||||
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
|
||||
>
|
||||
{joinAdapterOptions.map((type) => (
|
||||
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
|
||||
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Capabilities (optional)</span>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
rows={4}
|
||||
value={capabilities}
|
||||
onChange={(event) => setCapabilities(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresAuthForHuman && (
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||
Sign in or create an account before submitting a human join request.
|
||||
<div className="mt-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/auth?next=${encodeURIComponent(`/invite/${token}`)}`}>Sign in / Create account</Link>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-zinc-500">
|
||||
You've been invited to join Paperclip
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">
|
||||
{invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-300">
|
||||
{showsAgentForm
|
||||
? "Review the invite details, then submit the agent information below to start the join request."
|
||||
: requiresHumanAccount
|
||||
? "Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
|
||||
: "Your account is ready. Review the invite details, then accept it to continue."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Company</div>
|
||||
<div className="mt-1 text-sm text-zinc-100">{companyDisplayName}</div>
|
||||
</div>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Invited by</div>
|
||||
<div className="mt-1 text-sm text-zinc-100">{invitedByUserName ?? "Paperclip board"}</div>
|
||||
</div>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Requested access</div>
|
||||
<div className="mt-1 text-sm text-zinc-100">
|
||||
{showsAgentForm ? "Agent join request" : requestedHumanRole ?? "Company access"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Invite expires</div>
|
||||
<div className="mt-1 text-sm text-zinc-100">{formatDate(invite.expiresAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-5"
|
||||
disabled={
|
||||
acceptMutation.isPending ||
|
||||
(joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && agentName.trim().length === 0) ||
|
||||
requiresAuthForHuman
|
||||
}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending
|
||||
? "Submitting…"
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? "Accept bootstrap invite"
|
||||
: "Submit join request"}
|
||||
</Button>
|
||||
{inviteMessage ? (
|
||||
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-amber-200/80">Message from inviter</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50">{inviteMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sessionQuery.data ? (
|
||||
<div className="border border-emerald-500/40 bg-emerald-500/10 p-4 text-sm text-emerald-50">
|
||||
Signed in as <span className="font-medium">{sessionLabel}</span>.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className={`${panelClassName} h-fit`}>
|
||||
{showsAgentForm ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Submit agent details</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
This invite will create an approval request for a new agent in {companyDisplayName}.
|
||||
</p>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Agent name</span>
|
||||
<input
|
||||
className={fieldClassName}
|
||||
value={agentName}
|
||||
onChange={(event) => setAgentName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Adapter type</span>
|
||||
<select
|
||||
className={fieldClassName}
|
||||
value={adapterType}
|
||||
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
|
||||
>
|
||||
{joinAdapterOptions.map((type) => (
|
||||
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
|
||||
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Capabilities</span>
|
||||
<textarea
|
||||
className={fieldClassName}
|
||||
rows={4}
|
||||
value={capabilities}
|
||||
onChange={(event) => setCapabilities(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
||||
<Button
|
||||
className="w-full rounded-none"
|
||||
disabled={acceptMutation.isPending || agentName.trim().length === 0}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : requiresHumanAccount ? (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{authMode === "sign_up" ? "Create your account" : "Sign in to continue"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{authMode === "sign_up"
|
||||
? `Start with a Paperclip account. After that, you'll come right back here to accept the invite for ${companyDisplayName}.`
|
||||
: "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${modeButtonBaseClassName} ${
|
||||
authMode === "sign_up"
|
||||
? "border-zinc-100 bg-zinc-100 text-zinc-950"
|
||||
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setAuthFeedback(null);
|
||||
setAuthMode("sign_up");
|
||||
}}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${modeButtonBaseClassName} ${
|
||||
authMode === "sign_in"
|
||||
? "border-zinc-100 bg-zinc-100 text-zinc-950"
|
||||
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setAuthFeedback(null);
|
||||
setAuthMode("sign_in");
|
||||
}}
|
||||
>
|
||||
I already have an account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="post"
|
||||
action={authMode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (authMutation.isPending) return;
|
||||
if (!authCanSubmit) {
|
||||
setAuthFeedback({ tone: "error", message: "Please fill in all required fields." });
|
||||
return;
|
||||
}
|
||||
authMutation.mutate();
|
||||
}}
|
||||
data-testid="invite-inline-auth"
|
||||
>
|
||||
{authMode === "sign_up" ? (
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
className={fieldClassName}
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setAuthFeedback(null);
|
||||
}}
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className={fieldClassName}
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
setAuthFeedback(null);
|
||||
}}
|
||||
autoComplete="email"
|
||||
autoFocus={authMode === "sign_in"}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Password</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className={fieldClassName}
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setAuthFeedback(null);
|
||||
}}
|
||||
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</label>
|
||||
{authFeedback ? (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
authFeedback.tone === "info" ? "text-amber-300" : "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{authFeedback.message}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full rounded-none"
|
||||
disabled={authMutation.isPending}
|
||||
aria-disabled={!authCanSubmit || authMutation.isPending}
|
||||
>
|
||||
{authMutation.isPending
|
||||
? "Working..."
|
||||
: authMode === "sign_in"
|
||||
? "Sign in and continue"
|
||||
: "Create account and continue"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs leading-5 text-zinc-500">
|
||||
{authMode === "sign_up"
|
||||
? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user."
|
||||
: "No account yet? Switch back to create account so you can accept the invite with a new login."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{shouldAutoAcceptHumanInvite
|
||||
? "Submitting join request"
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? "Accept bootstrap invite"
|
||||
: "Accept company invite"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{shouldAutoAcceptHumanInvite
|
||||
? `Submitting your join request for ${companyDisplayName}.`
|
||||
: isCurrentMember
|
||||
? `This account already belongs to ${companyDisplayName}.`
|
||||
: `This will ${
|
||||
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}`
|
||||
}.`}
|
||||
</p>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
||||
{shouldAutoAcceptHumanInvite ? (
|
||||
<div className="text-sm text-zinc-400">
|
||||
{acceptMutation.isPending ? "Submitting request..." : "Finishing sign-in..."}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full rounded-none"
|
||||
disabled={acceptMutation.isPending || isCurrentMember}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
52
ui/src/pages/InviteUxLab.test.tsx
Normal file
52
ui/src/pages/InviteUxLab.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { InviteUxLab } from "./InviteUxLab";
|
||||
|
||||
vi.mock("@/components/CompanyPatternIcon", () => ({
|
||||
CompanyPatternIcon: ({ companyName }: { companyName: string }) => (
|
||||
<div aria-label={`${companyName} logo`}>{companyName}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("InviteUxLab", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the invite/signup review sections", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<InviteUxLab />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Invite and signup UX review surface");
|
||||
expect(container.textContent).toContain("/tests/ux/invites");
|
||||
expect(container.textContent).toContain("Landing state coverage");
|
||||
expect(container.textContent).toContain("Split-screen invite flows");
|
||||
expect(container.textContent).toContain("Approval and completion screens");
|
||||
expect(container.textContent).toContain("Auth page states");
|
||||
expect(container.textContent).toContain("Company invite management");
|
||||
expect(container.textContent).toContain("Create your account");
|
||||
expect(container.textContent).toContain("Invite history");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
927
ui/src/pages/InviteUxLab.tsx
Normal file
927
ui/src/pages/InviteUxLab.tsx
Normal file
@@ -0,0 +1,927 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
Link2,
|
||||
Loader2,
|
||||
MailPlus,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
const inviteRoleOptions = [
|
||||
{
|
||||
value: "viewer",
|
||||
label: "Viewer",
|
||||
description: "Can view company work and follow along without operational permissions.",
|
||||
gets: "No built-in grants.",
|
||||
},
|
||||
{
|
||||
value: "operator",
|
||||
label: "Operator",
|
||||
description: "Recommended for people who need to help run work without managing access.",
|
||||
gets: "Can assign tasks.",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin",
|
||||
description: "Recommended for operators who need to invite people, create agents, and approve joins.",
|
||||
gets: "Can create agents, invite users, assign tasks, and approve join requests.",
|
||||
},
|
||||
{
|
||||
value: "owner",
|
||||
label: "Owner",
|
||||
description: "Full company access, including membership and permission management.",
|
||||
gets: "Everything in Admin, plus managing members and permission grants.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const inviteHistory = [
|
||||
{
|
||||
id: "invite-active",
|
||||
state: "Active",
|
||||
humanRole: "operator",
|
||||
invitedBy: "Board User 25",
|
||||
email: "board25@paperclip.local",
|
||||
createdAt: "Apr 25, 2026, 9:00 AM",
|
||||
action: "Revoke",
|
||||
relatedLabel: "Review request",
|
||||
},
|
||||
{
|
||||
id: "invite-accepted",
|
||||
state: "Accepted",
|
||||
humanRole: "viewer",
|
||||
invitedBy: "Board User 24",
|
||||
email: "board24@paperclip.local",
|
||||
createdAt: "Apr 24, 2026, 8:15 AM",
|
||||
action: "Inactive",
|
||||
relatedLabel: "—",
|
||||
},
|
||||
{
|
||||
id: "invite-revoked",
|
||||
state: "Revoked",
|
||||
humanRole: "admin",
|
||||
invitedBy: "Board User 20",
|
||||
email: "board20@paperclip.local",
|
||||
createdAt: "Apr 20, 2026, 2:45 PM",
|
||||
action: "Inactive",
|
||||
relatedLabel: "—",
|
||||
},
|
||||
{
|
||||
id: "invite-expired",
|
||||
state: "Expired",
|
||||
humanRole: "owner",
|
||||
invitedBy: "Board User 19",
|
||||
email: "board19@paperclip.local",
|
||||
createdAt: "Apr 19, 2026, 7:10 PM",
|
||||
action: "Inactive",
|
||||
relatedLabel: "—",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const fieldClassName =
|
||||
"w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
|
||||
const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6";
|
||||
|
||||
function LabSection({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
accentClassName,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
accentClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-[28px] border border-border/70 bg-background/80 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
|
||||
accentClassName,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
tone = "default",
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
tone?: "default" | "warn" | "success" | "error";
|
||||
}) {
|
||||
const toneClassName = {
|
||||
default: "border-border/70 bg-background/85",
|
||||
warn: "border-amber-400/40 bg-amber-500/[0.08]",
|
||||
success: "border-emerald-400/40 bg-emerald-500/[0.08]",
|
||||
error: "border-rose-400/40 bg-rose-500/[0.08]",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<Card className={cn("rounded-[24px] shadow-none", toneClassName)}>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-current/10 bg-background/70 text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription className="mt-2 text-sm leading-6">{body}</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteLandingShell({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: ReactNode;
|
||||
right: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[28px] border border-zinc-800 bg-zinc-950 shadow-[0_30px_80px_rgba(2,6,23,0.55)]">
|
||||
<div className="grid gap-px bg-zinc-800 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||
<section className={cn(panelClassName, "space-y-6 bg-zinc-950")}>{left}</section>
|
||||
<section className={cn(panelClassName, "h-full bg-zinc-950")}>{right}</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteSummaryPanel({
|
||||
title,
|
||||
description,
|
||||
inviteMessage,
|
||||
requestedAccess,
|
||||
signedInLabel,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
inviteMessage?: string;
|
||||
requestedAccess: string;
|
||||
signedInLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<CompanyPatternIcon
|
||||
companyName="Acme Robotics"
|
||||
logoUrl="/api/invites/pcp_invite_test/logo"
|
||||
brandColor="#114488"
|
||||
className="h-16 w-16 rounded-none border border-zinc-800"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-zinc-500">You've been invited to join Paperclip</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold text-zinc-100">{title}</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-300">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MetaCard label="Company" value="Acme Robotics" />
|
||||
<MetaCard label="Invited by" value="Board User" />
|
||||
<MetaCard label="Requested access" value={requestedAccess} />
|
||||
<MetaCard label="Invite expires" value="Mar 7, 2027" />
|
||||
</div>
|
||||
|
||||
{inviteMessage ? (
|
||||
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-amber-200/80">Message from inviter</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50">{inviteMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{signedInLabel ? (
|
||||
<div className="border border-emerald-500/40 bg-emerald-500/10 p-4 text-sm text-emerald-50">
|
||||
Signed in as <span className="font-medium">{signedInLabel}</span>.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">{label}</div>
|
||||
<div className="mt-1 text-sm text-zinc-100">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineAuthPreview({
|
||||
mode,
|
||||
feedback,
|
||||
working,
|
||||
}: {
|
||||
mode: "sign_up" | "sign_in";
|
||||
feedback?: { tone: "info" | "error"; text: string };
|
||||
working?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-100">
|
||||
{mode === "sign_up" ? "Create your account" : "Sign in to continue"}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{mode === "sign_up"
|
||||
? "Start with a Paperclip account. After that, you'll come right back here to accept the invite for Acme Robotics."
|
||||
: "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 border px-3 py-2 text-sm transition-colors",
|
||||
mode === "sign_up"
|
||||
? "border-zinc-100 bg-zinc-100 text-zinc-950"
|
||||
: "border-zinc-800 text-zinc-300 hover:border-zinc-600",
|
||||
)}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 border px-3 py-2 text-sm transition-colors",
|
||||
mode === "sign_in"
|
||||
? "border-zinc-100 bg-zinc-100 text-zinc-950"
|
||||
: "border-zinc-800 text-zinc-300 hover:border-zinc-600",
|
||||
)}
|
||||
>
|
||||
I already have an account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4">
|
||||
{mode === "sign_up" ? (
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Name</span>
|
||||
<input name="name" className={fieldClassName} defaultValue="Jane Example" readOnly />
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Email</span>
|
||||
<input name="email" type="email" className={fieldClassName} defaultValue="jane@example.com" readOnly />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Password</span>
|
||||
<input name="password" type="password" className={fieldClassName} defaultValue="supersecret" readOnly />
|
||||
</label>
|
||||
{feedback ? (
|
||||
<p className={cn("text-xs", feedback.tone === "info" ? "text-amber-300" : "text-red-400")}>
|
||||
{feedback.text}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="button" className="w-full rounded-none" disabled={working}>
|
||||
{working ? "Working..." : mode === "sign_in" ? "Sign in and continue" : "Create account and continue"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs leading-5 text-zinc-500">
|
||||
{mode === "sign_up"
|
||||
? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user."
|
||||
: "No account yet? Switch back to create account so you can accept the invite with a new login."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRequestPreview() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-100">Submit agent details</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
This invite will create an approval request for a new agent in Acme Robotics.
|
||||
</p>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Agent name</span>
|
||||
<input className={fieldClassName} defaultValue="Acme Ops Agent" readOnly />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Adapter type</span>
|
||||
<select className={fieldClassName} defaultValue="codex_local" disabled>
|
||||
<option value="codex_local">Codex</option>
|
||||
<option value="claude_local">Claude Code</option>
|
||||
<option value="cursor">Cursor</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Capabilities</span>
|
||||
<textarea
|
||||
className={fieldClassName}
|
||||
rows={4}
|
||||
defaultValue="Reviews invites, triages requests, and keeps the board queue moving."
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
<Button type="button" className="w-full rounded-none">
|
||||
Submit request
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcceptInvitePreview({
|
||||
autoAccept,
|
||||
isCurrentMember,
|
||||
error,
|
||||
}: {
|
||||
autoAccept?: boolean;
|
||||
isCurrentMember?: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-100">Accept company invite</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{autoAccept
|
||||
? "Submitting your join request for Acme Robotics."
|
||||
: isCurrentMember
|
||||
? "This account already belongs to Acme Robotics."
|
||||
: "This will submit or complete your join request for Acme Robotics."}
|
||||
</p>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
||||
{autoAccept ? (
|
||||
<div className="text-sm text-zinc-400">Submitting request...</div>
|
||||
) : (
|
||||
<Button type="button" className="w-full rounded-none" disabled={isCurrentMember}>
|
||||
Accept invite
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteResultPreview({
|
||||
title,
|
||||
description,
|
||||
claimSecret,
|
||||
onboardingTextUrl,
|
||||
joinedNow = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
claimSecret?: string;
|
||||
onboardingTextUrl?: string;
|
||||
joinedNow?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6 text-zinc-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<CompanyPatternIcon
|
||||
companyName="Acme Robotics"
|
||||
logoUrl="/api/invites/pcp_invite_test/logo"
|
||||
brandColor="#114488"
|
||||
className="h-12 w-12 rounded-none border border-zinc-800"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-zinc-400">{description}</p>
|
||||
{joinedNow ? (
|
||||
<Button type="button" className="w-full rounded-none">
|
||||
Open board
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<p className="mb-1 text-xs text-zinc-500">Approval page</p>
|
||||
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/access">
|
||||
Company Settings → Access
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Refresh this page after you've been approved — you'll be redirected automatically.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{claimSecret ? (
|
||||
<div className="space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
|
||||
<div className="text-zinc-200">Claim secret</div>
|
||||
<div className="font-mono break-all">{claimSecret}</div>
|
||||
<div className="font-mono break-all">POST /api/agents/claim-api-key</div>
|
||||
</div>
|
||||
) : null}
|
||||
{onboardingTextUrl ? (
|
||||
<div className="text-xs text-zinc-400">
|
||||
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthScreenPreview({ mode, error }: { mode: "sign_in" | "sign_up"; error?: string }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[28px] border border-border/70 bg-background shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
|
||||
<div className="grid gap-px bg-border/60 md:grid-cols-2">
|
||||
<div className="flex min-h-[420px] flex-col justify-center bg-background px-8 py-10">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 flex items-center gap-2">
|
||||
<FlaskConical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Paperclip</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{mode === "sign_in"
|
||||
? "Use your email and password to access this instance."
|
||||
: "Create an account for this instance. Email confirmation is not required in v1."}
|
||||
</p>
|
||||
<div className="mt-6 space-y-4">
|
||||
{mode === "sign_up" ? (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-muted-foreground">Name</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
|
||||
defaultValue="Jane Example"
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-muted-foreground">Email</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
|
||||
defaultValue="jane@example.com"
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-muted-foreground">Password</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
|
||||
defaultValue="supersecret"
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<Button type="button" className="w-full">
|
||||
{mode === "sign_in" ? "Sign In" : "Create Account"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-5 text-sm text-muted-foreground">
|
||||
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
|
||||
<span className="font-medium text-foreground underline underline-offset-2">
|
||||
{mode === "sign_in" ? "Create one" : "Sign in"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden min-h-[420px] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(8,145,178,0.18),transparent_48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))] px-8 py-10 md:flex">
|
||||
<div className="max-w-sm space-y-4 text-zinc-200">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-500/[0.08] px-3 py-1 text-[10px] uppercase tracking-[0.22em] text-cyan-200">
|
||||
Auth preview
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">Side-by-side signup styling review</div>
|
||||
<p className="text-sm leading-6 text-zinc-400">
|
||||
This frame mirrors the production auth surface so spacing, label density, button treatments, and desktop composition are easy to compare.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyInvitesPreview() {
|
||||
return (
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
|
||||
<Card className="rounded-[28px] shadow-none">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MailPlus className="h-4 w-4" />
|
||||
Company Invites
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Create invite</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Generate a human invite link and choose the default access it should request.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-medium">Choose a role</legend>
|
||||
<div className="rounded-2xl border border-border">
|
||||
{inviteRoleOptions.map((option, index) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cn("flex cursor-default gap-3 px-4 py-4", index > 0 && "border-t border-border")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
readOnly
|
||||
checked={option.value === "operator"}
|
||||
className="mt-1 h-4 w-4 border-border text-foreground"
|
||||
/>
|
||||
<span className="min-w-0 space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
{option.value === "operator" ? (
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
Default
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block max-w-2xl text-sm text-muted-foreground">{option.description}</span>
|
||||
<span className="block text-sm text-foreground">{option.gets}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button">Create invite</Button>
|
||||
<span className="text-sm text-muted-foreground">Invite history below keeps the audit trail.</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-border px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Latest invite link</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This URL includes the current Paperclip domain returned by the server.
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Copied
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all"
|
||||
>
|
||||
https://paperclip.local/invite/new-token
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-[28px] shadow-none">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Invite history</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Review invite status, role, inviter, and any linked join request.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<a href="/inbox/requests" className="text-sm underline underline-offset-4">
|
||||
Open join request queue
|
||||
</a>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">State</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Role</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Invited by</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Created</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Join request</th>
|
||||
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inviteHistory.map((invite) => (
|
||||
<tr key={invite.id} className="border-b border-border last:border-b-0">
|
||||
<td className="px-5 py-3 align-top">
|
||||
<span className="inline-flex rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{invite.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 align-top">{invite.humanRole}</td>
|
||||
<td className="px-5 py-3 align-top">
|
||||
<div>{invite.invitedBy}</div>
|
||||
<div className="text-xs text-muted-foreground">{invite.email}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 align-top text-muted-foreground">{invite.createdAt}</td>
|
||||
<td className="px-5 py-3 align-top">
|
||||
{invite.relatedLabel === "Review request" ? (
|
||||
<a href="/inbox/requests" className="underline underline-offset-4">
|
||||
{invite.relatedLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{invite.relatedLabel}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right align-top">
|
||||
{invite.action === "Revoke" ? (
|
||||
<Button type="button" size="sm" variant="outline">
|
||||
Revoke
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Inactive</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border p-4">
|
||||
<div className="text-sm font-medium">Empty history state</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
No invites have been created for this company yet.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-rose-400/40 bg-rose-500/[0.07] p-4">
|
||||
<div className="text-sm font-medium text-foreground">Permission error</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
You do not have permission to manage company invites.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteUxLab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
|
||||
<div className="p-6 sm:p-7">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-500/25 bg-cyan-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-cyan-700 dark:text-cyan-300">
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
Invite UX Lab
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight">Invite and signup UX review surface</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
This page collects the current invite landing, signup, approval-result, and company invite-management states in one place so styling changes can be reviewed without recreating each backend condition by hand.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
/tests/ux/invites
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
signup + invite states
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
fixture-backed preview
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
|
||||
<div className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Covered states
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
"Invite loading, access-check, missing-token, and unavailable states",
|
||||
"Inline account creation and sign-in variants, including feedback/error copy",
|
||||
"Human accept, agent request, and auto-accept transitions",
|
||||
"Pending approval, joined-now, claim secret, and onboarding result screens",
|
||||
"Company invite creation, copied-link, history, empty, and permission-error states",
|
||||
].map((highlight) => (
|
||||
<div
|
||||
key={highlight}
|
||||
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{highlight}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Top-level states"
|
||||
title="Landing state coverage"
|
||||
description="Small cards for the fast-return invite states that do not render the full split-screen layout."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(59,130,246,0.05),transparent_30%),var(--background)]"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatusCard
|
||||
icon={<Loader2 className="h-4 w-4 animate-spin" />}
|
||||
title="Loading invite"
|
||||
body="Shown while invite summary, deployment mode, or auth session data is still loading."
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Clock3 className="h-4 w-4" />}
|
||||
title="Checking your access"
|
||||
body="Shown after sign-in while the app verifies whether the current user already belongs to the invited company."
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
title="Invalid invite token"
|
||||
body="The token is missing entirely, so the page short-circuits before any invite lookup."
|
||||
tone="error"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Link2 className="h-4 w-4" />}
|
||||
title="Invite not available"
|
||||
body="Used for expired, revoked, already-consumed, or otherwise missing invites."
|
||||
tone="warn"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<ShieldCheck className="h-4 w-4" />}
|
||||
title="Bootstrap complete"
|
||||
body="Result screen for bootstrap CEO invites after setup has been accepted successfully."
|
||||
tone="success"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
title="Auto-accept in progress"
|
||||
body="Signed-in human users skip the extra button click and move straight into join submission."
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
title="Already a member"
|
||||
body="Acceptance stays disabled and the page redirects into the company once membership is confirmed."
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<UserPlus className="h-4 w-4" />}
|
||||
title="Invite result surfaces"
|
||||
body="Both pending-approval and joined-now confirmations are included below with claim and onboarding extras."
|
||||
tone="success"
|
||||
/>
|
||||
</div>
|
||||
</LabSection>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Invite landing"
|
||||
title="Split-screen invite flows"
|
||||
description="These frames mirror the production invite surface closely enough to review spacing, hierarchy, and control states while keeping data fixture-driven."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(234,179,8,0.06),transparent_28%),var(--background)]"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<InviteLandingShell
|
||||
left={
|
||||
<InviteSummaryPanel
|
||||
title="Join Acme Robotics"
|
||||
description="Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
|
||||
inviteMessage="Welcome aboard."
|
||||
requestedAccess="Operator"
|
||||
/>
|
||||
}
|
||||
right={<InlineAuthPreview mode="sign_up" />}
|
||||
/>
|
||||
|
||||
<InviteLandingShell
|
||||
left={
|
||||
<InviteSummaryPanel
|
||||
title="Join Acme Robotics"
|
||||
description="Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
|
||||
inviteMessage="Welcome aboard."
|
||||
requestedAccess="Operator"
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<InlineAuthPreview
|
||||
mode="sign_in"
|
||||
feedback={{
|
||||
tone: "info",
|
||||
text: "An account already exists for jane@example.com. Sign in below to continue with this invite.",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InviteLandingShell
|
||||
left={
|
||||
<InviteSummaryPanel
|
||||
title="Join Acme Robotics"
|
||||
description="Your account is ready. Review the invite details, then accept it to continue."
|
||||
inviteMessage="Welcome aboard."
|
||||
requestedAccess="Operator"
|
||||
signedInLabel="Jane Example"
|
||||
/>
|
||||
}
|
||||
right={<AcceptInvitePreview autoAccept />}
|
||||
/>
|
||||
|
||||
<InviteLandingShell
|
||||
left={
|
||||
<InviteSummaryPanel
|
||||
title="Join Acme Robotics"
|
||||
description="Review the invite details, then submit the agent information below to start the join request."
|
||||
requestedAccess="Agent join request"
|
||||
/>
|
||||
}
|
||||
right={<AgentRequestPreview />}
|
||||
/>
|
||||
|
||||
<InviteLandingShell
|
||||
left={
|
||||
<InviteSummaryPanel
|
||||
title="Join Acme Robotics"
|
||||
description="Your account is ready. Review the invite details, then accept it to continue."
|
||||
requestedAccess="Operator"
|
||||
signedInLabel="Jane Example"
|
||||
/>
|
||||
}
|
||||
right={<AcceptInvitePreview error="This account already belongs to the company." isCurrentMember />}
|
||||
/>
|
||||
</div>
|
||||
</LabSection>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Result states"
|
||||
title="Approval and completion screens"
|
||||
description="These are the post-submit states returned from invite acceptance, including optional claim and onboarding metadata."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_30%),var(--background)]"
|
||||
>
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<InviteResultPreview
|
||||
title="Request to join Acme Robotics"
|
||||
description="Board User must approve your request to join."
|
||||
claimSecret="pcp_claim_secret_demo"
|
||||
onboardingTextUrl="/api/invites/pcp_invite_test/onboarding.txt"
|
||||
/>
|
||||
<InviteResultPreview
|
||||
title="You joined the company"
|
||||
description="Your account already matched the approved invite, so the board can be opened immediately."
|
||||
joinedNow
|
||||
/>
|
||||
<InviteResultPreview
|
||||
title="Request to join Acme Robotics"
|
||||
description="Ask them to visit Company Settings → Access to approve your request."
|
||||
/>
|
||||
</div>
|
||||
</LabSection>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Standalone auth"
|
||||
title="Auth page states"
|
||||
description="The general `/auth` page uses a different composition from invite landing. These previews keep both sign-in and sign-up variants visible."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<AuthScreenPreview mode="sign_in" error="Invalid email or password" />
|
||||
<AuthScreenPreview mode="sign_up" />
|
||||
</div>
|
||||
</LabSection>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Company settings"
|
||||
title="Company invite management"
|
||||
description="This section captures the board-side invite creation flow, copied-link state, audit table, and the edge states that are otherwise tedious to stage."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(244,114,182,0.06),transparent_28%),var(--background)]"
|
||||
>
|
||||
<CompanyInvitesPreview />
|
||||
</LabSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { approvalsApi } from "../api/approvals";
|
||||
import { activityApi, type RunForIssue } from "../api/activity";
|
||||
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -17,6 +18,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||
@@ -250,14 +252,17 @@ function mergeOptimisticFeedbackVote(
|
||||
];
|
||||
}
|
||||
|
||||
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
|
||||
function ActorIdentity({ evt, agentMap, userProfileMap }: { evt: ActivityEvent; agentMap: Map<string, Agent>; userProfileMap?: Map<string, import("../lib/company-members").CompanyUserProfile> }) {
|
||||
const id = evt.actorId;
|
||||
if (evt.actorType === "agent") {
|
||||
const agent = agentMap.get(id);
|
||||
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
|
||||
}
|
||||
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
|
||||
if (evt.actorType === "user") return <Identity name="Board" size="sm" />;
|
||||
if (evt.actorType === "user") {
|
||||
const profile = userProfileMap?.get(id);
|
||||
return <Identity name={profile?.label ?? "Board"} avatarUrl={profile?.image} size="sm" />;
|
||||
}
|
||||
return <Identity name={id || "Unknown"} size="sm" />;
|
||||
}
|
||||
|
||||
@@ -502,6 +507,8 @@ type IssueDetailChatTabProps = {
|
||||
feedbackTermsUrl: string | null;
|
||||
agentMap: Map<string, Agent>;
|
||||
currentUserId: string | null;
|
||||
userLabelMap: ReadonlyMap<string, string> | null;
|
||||
userProfileMap: ReadonlyMap<string, import("../lib/company-members").CompanyUserProfile> | null;
|
||||
draftKey: string;
|
||||
reassignOptions: Array<{ id: string; label: string; searchText?: string }>;
|
||||
currentAssigneeValue: string;
|
||||
@@ -538,6 +545,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
draftKey,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
@@ -682,6 +691,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
issueStatus={issueStatus}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
draftKey={draftKey}
|
||||
enableReassign
|
||||
reassignOptions={reassignOptions}
|
||||
@@ -713,6 +724,7 @@ type IssueDetailActivityTabProps = {
|
||||
issueId: string;
|
||||
agentMap: Map<string, Agent>;
|
||||
currentUserId: string | null;
|
||||
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
|
||||
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
|
||||
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
|
||||
};
|
||||
@@ -721,6 +733,7 @@ function IssueDetailActivityTab({
|
||||
issueId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
pendingApprovalAction,
|
||||
onApprovalAction,
|
||||
}: IssueDetailActivityTabProps) {
|
||||
@@ -837,8 +850,8 @@ function IssueDetailActivityTab({
|
||||
<div className="space-y-1.5">
|
||||
{activity.slice(0, 20).map((evt) => (
|
||||
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ActorIdentity evt={evt} agentMap={agentMap} />
|
||||
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
|
||||
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
|
||||
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
|
||||
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -976,6 +989,11 @@ export function IssueDetail() {
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
@@ -1027,31 +1045,21 @@ export function IssueDetail() {
|
||||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
return map;
|
||||
}, [agents]);
|
||||
const userProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
agentIcon: agent.icon,
|
||||
});
|
||||
}
|
||||
for (const project of orderedProjects) {
|
||||
options.push({
|
||||
id: `project:${project.id}`,
|
||||
name: project.name,
|
||||
kind: "project",
|
||||
projectId: project.id,
|
||||
projectColor: project.color,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents, orderedProjects]);
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
projects: orderedProjects,
|
||||
members: companyMembers?.users,
|
||||
});
|
||||
}, [agents, companyMembers?.users, orderedProjects]);
|
||||
|
||||
const resolvedProject = useMemo(
|
||||
() => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null),
|
||||
@@ -1085,6 +1093,7 @@ export function IssueDetail() {
|
||||
|
||||
const commentReassignOptions = useMemo(() => {
|
||||
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
||||
options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }));
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1095,7 +1104,7 @@ export function IssueDetail() {
|
||||
options.push({ id: `user:${currentUserId}`, label: "Me" });
|
||||
}
|
||||
return options;
|
||||
}, [agents, currentUserId]);
|
||||
}, [agents, companyMembers?.users, currentUserId]);
|
||||
|
||||
const actualAssigneeValue = useMemo(
|
||||
() => assigneeValueFromSelection(issue ?? {}),
|
||||
@@ -2628,6 +2637,8 @@ export function IssueDetail() {
|
||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
reassignOptions={commentReassignOptions}
|
||||
currentAssigneeValue={actualAssigneeValue}
|
||||
@@ -2652,6 +2663,7 @@ export function IssueDetail() {
|
||||
issueId={issue.id}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userProfileMap={userProfileMap}
|
||||
pendingApprovalAction={pendingApprovalAction}
|
||||
onApprovalAction={(approvalId, action) => {
|
||||
approvalDecision.mutate({ approvalId, action });
|
||||
|
||||
194
ui/src/pages/JoinRequestQueue.tsx
Normal file
194
ui/src/pages/JoinRequestQueue.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UserPlus2 } from "lucide-react";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export function JoinRequestQueue() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<"pending_approval" | "approved" | "rejected">("pending_approval");
|
||||
const [requestType, setRequestType] = useState<"all" | "human" | "agent">("all");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Inbox", href: "/inbox" },
|
||||
{ label: "Join Requests" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", `${status}:${requestType}`),
|
||||
queryFn: () =>
|
||||
accessApi.listJoinRequests(
|
||||
selectedCompanyId!,
|
||||
status,
|
||||
requestType === "all" ? undefined : requestType,
|
||||
),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (requestId: string) => accessApi.approveJoinRequest(selectedCompanyId!, requestId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId!) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!) });
|
||||
pushToast({ title: "Join request approved", tone: "success" });
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (requestId: string) => accessApi.rejectJoinRequest(selectedCompanyId!, requestId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
|
||||
pushToast({ title: "Join request rejected", tone: "success" });
|
||||
},
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="text-sm text-muted-foreground">Select a company to review join requests.</div>;
|
||||
}
|
||||
|
||||
if (requestsQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading join requests…</div>;
|
||||
}
|
||||
|
||||
if (requestsQuery.error) {
|
||||
const message =
|
||||
requestsQuery.error instanceof ApiError && requestsQuery.error.status === 403
|
||||
? "You do not have permission to review join requests for this company."
|
||||
: requestsQuery.error instanceof Error
|
||||
? requestsQuery.error.message
|
||||
: "Failed to load join requests.";
|
||||
return <div className="text-sm text-destructive">{message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPlus2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Join Request Queue</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Review human and agent join requests outside the mixed inbox feed. This queue uses the same approval mutations as the inline inbox cards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 rounded-xl border border-border bg-card p-4">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">Status</span>
|
||||
<select
|
||||
className="rounded-md border border-border bg-background px-3 py-2"
|
||||
value={status}
|
||||
onChange={(event) =>
|
||||
setStatus(event.target.value as "pending_approval" | "approved" | "rejected")
|
||||
}
|
||||
>
|
||||
<option value="pending_approval">Pending approval</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">Request type</span>
|
||||
<select
|
||||
className="rounded-md border border-border bg-background px-3 py-2"
|
||||
value={requestType}
|
||||
onChange={(event) =>
|
||||
setRequestType(event.target.value as "all" | "human" | "agent")
|
||||
}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="human">Human</option>
|
||||
<option value="agent">Agent</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(requestsQuery.data ?? []).length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground">
|
||||
No join requests match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
requestsQuery.data!.map((request) => (
|
||||
<div key={request.id} className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={request.status === "pending_approval" ? "secondary" : request.status === "approved" ? "outline" : "destructive"}>
|
||||
{request.status.replace("_", " ")}
|
||||
</Badge>
|
||||
<Badge variant="outline">{request.requestType}</Badge>
|
||||
{request.adapterType ? <Badge variant="outline">{request.adapterType}</Badge> : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-medium">
|
||||
{request.requestType === "human"
|
||||
? request.requesterUser?.name || request.requestEmailSnapshot || request.requestingUserId || "Unknown human requester"
|
||||
: request.agentName || "Unknown agent requester"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{request.requestType === "human"
|
||||
? request.requesterUser?.email || request.requestEmailSnapshot || request.requestingUserId
|
||||
: request.capabilities || request.requestIp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.status === "pending_approval" ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => rejectMutation.mutate(request.id)}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => approveMutation.mutate(request.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 text-sm text-muted-foreground md:grid-cols-2">
|
||||
<div className="rounded-lg border border-border bg-background px-3 py-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide">Invite context</div>
|
||||
<div className="mt-2">
|
||||
{request.invite
|
||||
? `${request.invite.allowedJoinTypes} join invite${request.invite.humanRole ? ` • default role ${request.invite.humanRole}` : ""}`
|
||||
: "Invite metadata unavailable"}
|
||||
</div>
|
||||
{request.invite?.inviteMessage ? (
|
||||
<div className="mt-2 text-foreground">{request.invite.inviteMessage}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background px-3 py-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide">Request details</div>
|
||||
<div className="mt-2">Submitted {new Date(request.createdAt).toLocaleString()}</div>
|
||||
<div>Source IP {request.requestIp}</div>
|
||||
{request.requestType === "agent" && request.capabilities ? <div>{request.capabilities}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
ui/src/pages/ProfileSettings.test.tsx
Normal file
133
ui/src/pages/ProfileSettings.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// @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 { ProfileSettings } from "./ProfileSettings";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
signInEmail: vi.fn(),
|
||||
signUpEmail: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAssetsApi = vi.hoisted(() => ({
|
||||
uploadImage: vi.fn(),
|
||||
uploadCompanyLogo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
assetsApi: mockAssetsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProfileSettings", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: "https://example.com/jane.png",
|
||||
},
|
||||
});
|
||||
mockAssetsApi.uploadImage.mockResolvedValue({
|
||||
assetId: "asset-1",
|
||||
contentPath: "/api/assets/asset-1/content",
|
||||
});
|
||||
mockAuthApi.updateProfile.mockImplementation(async (input: { name: string; image: string | null }) => ({
|
||||
id: "user-1",
|
||||
name: input.name,
|
||||
email: "jane@example.com",
|
||||
image: input.image,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uploads a clicked avatar into Paperclip storage and persists the returned asset path", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProfileSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("Avatar image URL");
|
||||
|
||||
const avatarInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(avatarInput).not.toBeNull();
|
||||
|
||||
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
|
||||
Object.defineProperty(avatarInput, "files", {
|
||||
configurable: true,
|
||||
value: [file],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
avatarInput?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockAssetsApi.uploadImage).toHaveBeenCalledWith("company-1", file, "profiles/user-1");
|
||||
expect(mockAuthApi.updateProfile).toHaveBeenCalledWith({
|
||||
name: "Jane Example",
|
||||
image: "/api/assets/asset-1/content",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
273
ui/src/pages/ProfileSettings.tsx
Normal file
273
ui/src/pages/ProfileSettings.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Camera, LoaderCircle, Save, Trash2, UserRoundPen } from "lucide-react";
|
||||
import type { AuthSession, CurrentUserProfile, UpdateCurrentUserProfile } from "@paperclipai/shared";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { assetsApi } from "@/api/assets";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export function ProfileSettings() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const avatarInputId = useId();
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [image, setImage] = useState("");
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Instance Settings" },
|
||||
{ label: "Profile" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = sessionQuery.data;
|
||||
if (!session) return;
|
||||
setName(session.user.name ?? "");
|
||||
setImage(session.user.image ?? "");
|
||||
}, [sessionQuery.data]);
|
||||
|
||||
function syncSessionProfile(profile: CurrentUserProfile) {
|
||||
queryClient.setQueryData<AuthSession | null>(queryKeys.auth.session, (current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
user: {
|
||||
...current.user,
|
||||
...profile,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function persistProfile(input: UpdateCurrentUserProfile) {
|
||||
const profile = await authApi.updateProfile(input);
|
||||
syncSessionProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
function resolveProfileName() {
|
||||
return name.trim() || sessionQuery.data?.user.name || "Board";
|
||||
}
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (input: UpdateCurrentUserProfile) => persistProfile(input),
|
||||
onSuccess: (profile) => {
|
||||
setActionError(null);
|
||||
setName(profile.name ?? "");
|
||||
setImage(profile.image ?? "");
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to update profile.");
|
||||
},
|
||||
});
|
||||
|
||||
const uploadAvatarMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company before uploading a profile avatar.");
|
||||
}
|
||||
|
||||
const asset = await assetsApi.uploadImage(
|
||||
selectedCompanyId,
|
||||
file,
|
||||
`profiles/${sessionQuery.data?.user.id ?? "board-user"}`,
|
||||
);
|
||||
return persistProfile({ name: resolveProfileName(), image: asset.contentPath });
|
||||
},
|
||||
onSuccess: (profile) => {
|
||||
setActionError(null);
|
||||
setName(profile.name ?? "");
|
||||
setImage(profile.image ?? "");
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to upload avatar.");
|
||||
},
|
||||
});
|
||||
|
||||
const removeAvatarMutation = useMutation({
|
||||
mutationFn: () => persistProfile({ name: resolveProfileName(), image: null }),
|
||||
onSuccess: (profile) => {
|
||||
setActionError(null);
|
||||
setName(profile.name ?? "");
|
||||
setImage(profile.image ?? "");
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to remove avatar.");
|
||||
},
|
||||
});
|
||||
|
||||
if (sessionQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading profile...</div>;
|
||||
}
|
||||
|
||||
if (sessionQuery.error || !sessionQuery.data) {
|
||||
return (
|
||||
<div className="text-sm text-destructive">
|
||||
{sessionQuery.error instanceof Error ? sessionQuery.error.message : "Failed to load profile."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentName = name.trim() || sessionQuery.data.user.name || "Board";
|
||||
const currentImage = image.trim() || null;
|
||||
const initials = deriveInitials(currentName);
|
||||
const isSavingProfile = updateMutation.isPending || uploadAvatarMutation.isPending || removeAvatarMutation.isPending;
|
||||
const uploadHint = selectedCompany
|
||||
? `Stored in Paperclip file storage for ${selectedCompany.name}.`
|
||||
: "Select a company to upload an avatar into Paperclip storage.";
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserRoundPen className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Profile</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control how your account appears in the sidebar and other board surfaces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-8">
|
||||
<div className="relative overflow-hidden rounded-[28px] border border-border/70 bg-card shadow-sm">
|
||||
<div className="absolute inset-x-0 top-0 h-32 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_58%,color-mix(in_oklab,hsl(var(--background))_76%,white_24%)_100%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22),transparent_34%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.08),transparent_36%)]" />
|
||||
<div className="relative p-6 pt-10">
|
||||
<div className="flex flex-wrap items-end gap-5 rounded-[24px] border border-border/70 bg-background/92 p-5 shadow-[0_18px_44px_-28px_rgba(0,0,0,0.45)] backdrop-blur-sm">
|
||||
<div className="space-y-3">
|
||||
<label
|
||||
htmlFor={avatarInputId}
|
||||
className="group relative block cursor-pointer rounded-full focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background"
|
||||
>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
id={avatarInputId}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
disabled={!selectedCompanyId || isSavingProfile}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadAvatarMutation.mutate(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span className="absolute inset-0 z-10 rounded-full bg-black/0 transition-colors group-hover:bg-black/14 group-focus-within:bg-black/14" />
|
||||
<span className="absolute bottom-1 right-1 z-20 flex size-9 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground shadow-sm">
|
||||
{uploadAvatarMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Camera className="size-4" />}
|
||||
</span>
|
||||
<Avatar size="lg" className="data-[size=lg]:size-24 ring-4 ring-background shadow-xl">
|
||||
{currentImage ? <AvatarImage src={currentImage} alt={currentName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={!selectedCompanyId || isSavingProfile}
|
||||
>
|
||||
{uploadAvatarMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Camera className="size-4" />}
|
||||
{currentImage ? "Change photo" : "Upload photo"}
|
||||
</Button>
|
||||
{currentImage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => removeAvatarMutation.mutate()}
|
||||
disabled={isSavingProfile}
|
||||
>
|
||||
{removeAvatarMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2 pb-1">
|
||||
<div>
|
||||
<h2 className="truncate text-2xl font-semibold text-foreground">{currentName}</h2>
|
||||
<p className="truncate text-sm text-muted-foreground">{sessionQuery.data.user.email ?? "No email"}</p>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Click the avatar to upload a new image. {uploadHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-6 md:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
updateMutation.mutate({ name: resolveProfileName(), image: image.trim() || null });
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Display name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
maxLength={120}
|
||||
placeholder="Board"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shown in the sidebar account footer and comment author surfaces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-email">Email</Label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
value={sessionQuery.data.user.email ?? ""}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Email is managed by your auth session and is read-only here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end">
|
||||
<Button type="submit" disabled={isSavingProfile || !name.trim()}>
|
||||
{updateMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
{updateMutation.isPending ? "Saving..." : "Save profile"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -363,7 +363,6 @@ export function ProjectDetail() {
|
||||
const experimentalSettingsQuery = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const {
|
||||
slots: pluginDetailSlots,
|
||||
|
||||
@@ -807,20 +807,11 @@ export function Routines() {
|
||||
bordered={false}
|
||||
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||
onSubmit={() => {
|
||||
if (!createRoutine.isPending && draft.title.trim()) {
|
||||
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
||||
createRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 space-y-3">
|
||||
<RoutineVariablesHint />
|
||||
<RoutineVariablesEditor
|
||||
title={draft.title}
|
||||
description={draft.description}
|
||||
value={draft.variables}
|
||||
onChange={(variables) => setDraft((current) => ({ ...current, variables }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 px-5 py-3">
|
||||
|
||||
Reference in New Issue
Block a user