mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Add access cleanup and user profile page (#4088)
## Thinking Path > - Paperclip is moving from a solo local operator model toward teams supervising AI-agent companies. > - Human access management and human-visible profile surfaces are part of that multiple-user path. > - The branch included related access cleanup, archived-member removal, permission protection, and a user profile page. > - These changes share company membership, user attribution, and access-service behavior. > - This pull request groups those human access/profile changes into one standalone branch. > - The benefit is safer member removal behavior and a first profile surface for user work, activity, and cost attribution. ## What Changed - Added archived company member removal support across shared contracts, server routes/services, and UI. - Protected company member removal with stricter permission checks and tests. - Added company user profile API, shared types, route wiring, client API, route, and UI page. - Simplified the user profile page visual design to a neutral typography-led layout. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/user-profile-routes.test.ts ui/src/pages/CompanyAccess.test.tsx --hookTimeout=30000` - `pnpm exec vitest run server/src/__tests__/user-profile-routes.test.ts --testTimeout=30000 --hookTimeout=30000` after an initial local embedded-Postgres hook timeout in the combined run. - Split integration check: merged after runtime/governance and dev-infra/backups with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: changes member removal permissions and adds a new user profile route with cross-table stats. - The profile page is a new UI surface and may need visual follow-up in browser QA. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e89d3f7e11
commit
d8b63a18e7
23 changed files with 2156 additions and 51 deletions
|
|
@ -14,6 +14,7 @@ import { Issues } from "./pages/Issues";
|
|||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { Routines } from "./pages/Routines";
|
||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||
import { Goals } from "./pages/Goals";
|
||||
import { GoalDetail } from "./pages/GoalDetail";
|
||||
|
|
@ -117,6 +118,7 @@ function boardRoutes() {
|
|||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="u/:userSlug" element={<UserProfile />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
|
||||
<Route path="tests/ux/invites" element={<InviteUxLab />} />
|
||||
|
|
@ -277,6 +279,7 @@ export function App() {
|
|||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="routines" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
|
|
|
|||
|
|
@ -120,12 +120,21 @@ export type CompanyMember = {
|
|||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
status: "pending" | "active" | "suspended";
|
||||
status: "pending" | "active" | "suspended" | "archived";
|
||||
membershipRole: HumanCompanyRole | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||
grants: CompanyMemberGrant[];
|
||||
removal?: {
|
||||
canArchive: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ArchiveCompanyMemberResponse = {
|
||||
member: CompanyMember;
|
||||
reassignedIssueCount: number;
|
||||
};
|
||||
|
||||
export type CompanyMembersResponse = {
|
||||
|
|
@ -205,7 +214,7 @@ export type UserCompanyAccessEntry = {
|
|||
companyId: string;
|
||||
principalType: "user";
|
||||
principalId: string;
|
||||
status: "pending" | "active" | "suspended";
|
||||
status: "pending" | "active" | "suspended" | "archived";
|
||||
membershipRole: HumanCompanyRole | "member" | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
|
@ -341,6 +350,17 @@ export const accessApi = {
|
|||
},
|
||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
|
||||
|
||||
archiveMember: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
reassignment?: {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
} | null;
|
||||
} = {},
|
||||
) => api.post<ArchiveCompanyMemberResponse>(`/companies/${companyId}/members/${memberId}/archive`, input),
|
||||
|
||||
approveJoinRequest: (companyId: string, requestId: string) =>
|
||||
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
|
||||
|
||||
|
|
|
|||
9
ui/src/api/userProfiles.ts
Normal file
9
ui/src/api/userProfiles.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { UserProfileResponse } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const userProfilesApi = {
|
||||
get: (companyId: string, userSlug: string) =>
|
||||
api.get<UserProfileResponse>(
|
||||
`/companies/${companyId}/users/${encodeURIComponent(userSlug)}/profile`,
|
||||
),
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type LucideIcon,
|
||||
Moon,
|
||||
Settings,
|
||||
UserRound,
|
||||
Sun,
|
||||
UserRoundPen,
|
||||
} from "lucide-react";
|
||||
|
|
@ -45,6 +46,20 @@ function deriveInitials(name: string) {
|
|||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) {
|
||||
const candidates = [name, email?.split("@")[0], email, id];
|
||||
for (const candidate of candidates) {
|
||||
const slug = candidate
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (slug) return slug;
|
||||
}
|
||||
return "me";
|
||||
}
|
||||
|
||||
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
|
||||
const className =
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
|
||||
|
|
@ -112,6 +127,7 @@ export function SidebarAccountMenu({
|
|||
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
||||
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
|
||||
const initials = deriveInitials(displayName);
|
||||
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
|
|
@ -164,6 +180,13 @@ export function SidebarAccountMenu({
|
|||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<MenuAction
|
||||
label="View profile"
|
||||
description="Open your activity, task, and usage ledger."
|
||||
icon={UserRound}
|
||||
href={profileHref}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
description="Update your display name and avatar."
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"usage",
|
||||
"activity",
|
||||
"inbox",
|
||||
"u",
|
||||
"design-guide",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ export const queryKeys = {
|
|||
providers: (companyId: string) => ["secret-providers", companyId] as const,
|
||||
},
|
||||
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||
userProfile: (companyId: string, userSlug: string) =>
|
||||
["user-profile", companyId, userSlug] as const,
|
||||
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
||||
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
|
||||
activity: (companyId: string) => ["activity", companyId] as const,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import { CompanyAccess } from "./CompanyAccess";
|
|||
const listMembersMock = vi.hoisted(() => vi.fn());
|
||||
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const updateMemberAccessMock = vi.hoisted(() => vi.fn());
|
||||
const archiveMemberMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const listIssuesMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
|
|
@ -18,11 +21,25 @@ vi.mock("@/api/access", () => ({
|
|||
updateMemberPermissions: vi.fn(),
|
||||
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
|
||||
updateMemberAccessMock(companyId, memberId, input),
|
||||
archiveMember: (companyId: string, memberId: string, input: unknown) =>
|
||||
archiveMemberMock(companyId, memberId, input),
|
||||
approveJoinRequest: vi.fn(),
|
||||
rejectJoinRequest: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: (companyId: string) => listAgentsMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/issues", () => ({
|
||||
issuesApi: {
|
||||
list: (companyId: string, filters: unknown) => listIssuesMock(companyId, filters),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
|
|
@ -73,6 +90,23 @@ describe("CompanyAccess", () => {
|
|||
},
|
||||
grants: [],
|
||||
},
|
||||
{
|
||||
id: "member-2",
|
||||
companyId: "company-1",
|
||||
principalType: "user",
|
||||
principalId: "user-2",
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
createdAt: "2026-04-10T00:00:00.000Z",
|
||||
updatedAt: "2026-04-10T00:00:00.000Z",
|
||||
user: {
|
||||
id: "user-2",
|
||||
email: "board@paperclip.local",
|
||||
name: "Board User",
|
||||
image: null,
|
||||
},
|
||||
grants: [],
|
||||
},
|
||||
],
|
||||
access: {
|
||||
currentUserRole: "owner",
|
||||
|
|
@ -113,6 +147,23 @@ describe("CompanyAccess", () => {
|
|||
},
|
||||
]);
|
||||
updateMemberAccessMock.mockResolvedValue({});
|
||||
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
|
||||
listAgentsMock.mockResolvedValue([
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Codex Worker",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
},
|
||||
]);
|
||||
listIssuesMock.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Assigned to removed user",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -216,4 +267,119 @@ describe("CompanyAccess", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a member with an issue reassignment target", 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 removeButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent?.includes("Remove"),
|
||||
);
|
||||
expect(removeButtons.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
removeButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Remove member");
|
||||
expect(document.body.textContent).toContain("Assigned to removed user");
|
||||
|
||||
const reassignmentSelect = document.body.querySelector("select");
|
||||
expect(reassignmentSelect).toBeTruthy();
|
||||
await act(async () => {
|
||||
reassignmentSelect!.value = "user:user-2";
|
||||
reassignmentSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Remove member",
|
||||
);
|
||||
expect(confirmButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(archiveMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
|
||||
reassignment: { assigneeAgentId: null, assigneeUserId: "user-2" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows protected member removal reasons from the API", async () => {
|
||||
listMembersMock.mockResolvedValueOnce({
|
||||
members: [
|
||||
{
|
||||
id: "member-admin",
|
||||
companyId: "company-1",
|
||||
principalType: "user",
|
||||
principalId: "admin-user",
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
createdAt: "2026-04-10T00:00:00.000Z",
|
||||
updatedAt: "2026-04-10T00:00:00.000Z",
|
||||
user: {
|
||||
id: "admin-user",
|
||||
email: "admin@paperclip.local",
|
||||
name: "Admin User",
|
||||
image: null,
|
||||
},
|
||||
grants: [],
|
||||
removal: {
|
||||
canArchive: false,
|
||||
reason: "Company admins cannot be removed from company access.",
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
currentUserRole: "owner",
|
||||
canManageMembers: true,
|
||||
canInviteUsers: true,
|
||||
canApproveJoinRequests: false,
|
||||
},
|
||||
});
|
||||
|
||||
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("Company admins cannot be removed from company access.");
|
||||
const removeButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Remove"),
|
||||
);
|
||||
expect(removeButton).toBeTruthy();
|
||||
expect(removeButton).toHaveProperty("disabled", true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
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 {
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
PERMISSION_KEYS,
|
||||
type Agent,
|
||||
type PermissionKey,
|
||||
} from "@paperclipai/shared";
|
||||
import { ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { accessApi, type CompanyMember } from "@/api/access";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
|
|
@ -41,6 +48,9 @@ const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>,
|
|||
viewer: [],
|
||||
};
|
||||
|
||||
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
|
||||
type EditableMemberStatus = "pending" | "active" | "suspended";
|
||||
|
||||
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
|
||||
return role ? implicitRoleGrantMap[role] : [];
|
||||
}
|
||||
|
|
@ -51,8 +61,10 @@ export function CompanyAccess() {
|
|||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
|
||||
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
|
||||
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
|
||||
const [draftStatus, setDraftStatus] = useState<CompanyMember["status"]>("active");
|
||||
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
|
||||
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -69,6 +81,12 @@ export function CompanyAccess() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? ""),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const joinRequestsQuery = useQuery({
|
||||
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"),
|
||||
queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"),
|
||||
|
|
@ -83,7 +101,7 @@ export function CompanyAccess() {
|
|||
};
|
||||
|
||||
const updateMemberMutation = useMutation({
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: CompanyMember["status"]; grants: PermissionKey[] }) => {
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => {
|
||||
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
|
||||
membershipRole: input.membershipRole,
|
||||
status: input.status,
|
||||
|
|
@ -147,14 +165,70 @@ export function CompanyAccess() {
|
|||
() => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null,
|
||||
[editingMemberId, membersQuery.data?.members],
|
||||
);
|
||||
const removingMember = useMemo(
|
||||
() => membersQuery.data?.members.find((member) => member.id === removingMemberId) ?? null,
|
||||
[removingMemberId, membersQuery.data?.members],
|
||||
);
|
||||
|
||||
const assignedIssuesQuery = useQuery({
|
||||
queryKey: ["access", "member-assigned-issues", selectedCompanyId ?? "", removingMember?.principalId ?? ""],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
assigneeUserId: removingMember!.principalId,
|
||||
status: reassignmentIssueStatuses,
|
||||
}),
|
||||
enabled: !!selectedCompanyId && !!removingMember,
|
||||
});
|
||||
|
||||
const archiveMemberMutation = useMutation({
|
||||
mutationFn: async (input: { memberId: string; target: string }) => {
|
||||
const reassignment =
|
||||
input.target.startsWith("agent:")
|
||||
? { assigneeAgentId: input.target.slice("agent:".length), assigneeUserId: null }
|
||||
: input.target.startsWith("user:")
|
||||
? { assigneeAgentId: null, assigneeUserId: input.target.slice("user:".length) }
|
||||
: null;
|
||||
return accessApi.archiveMember(selectedCompanyId!, input.memberId, { reassignment });
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setRemovingMemberId(null);
|
||||
setReassignmentTarget("__unassigned");
|
||||
await refreshAccessData();
|
||||
if (selectedCompanyId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listAssignedToMe(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
}
|
||||
pushToast({
|
||||
title: "Member removed",
|
||||
body:
|
||||
result.reassignedIssueCount > 0
|
||||
? `${result.reassignedIssueCount} assigned issue${result.reassignedIssueCount === 1 ? "" : "s"} cleaned up.`
|
||||
: undefined,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to remove member",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingMember) return;
|
||||
setDraftRole(editingMember.membershipRole);
|
||||
setDraftStatus(editingMember.status);
|
||||
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
|
||||
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
|
||||
}, [editingMember]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!removingMember) return;
|
||||
setReassignmentTarget("__unassigned");
|
||||
}, [removingMember]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="text-sm text-muted-foreground">Select a company to manage access.</div>;
|
||||
}
|
||||
|
|
@ -181,6 +255,14 @@ export function CompanyAccess() {
|
|||
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
|
||||
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
|
||||
const implicitGrantSet = new Set(implicitGrantKeys);
|
||||
const activeReassignmentUsers = members.filter(
|
||||
(member) =>
|
||||
member.status === "active" &&
|
||||
member.principalType === "user" &&
|
||||
member.id !== removingMemberId,
|
||||
);
|
||||
const activeReassignmentAgents = (agentsQuery.data ?? []).filter(isAssignableAgent);
|
||||
const assignedIssues = assignedIssuesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-8">
|
||||
|
|
@ -256,7 +338,7 @@ export function CompanyAccess() {
|
|||
) : 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 className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] 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>
|
||||
|
|
@ -266,33 +348,52 @@ export function CompanyAccess() {
|
|||
{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>
|
||||
members.map((member) => {
|
||||
const removalReason = member.removal?.reason ?? null;
|
||||
const canArchive = member.removal?.canArchive ?? true;
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] 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="space-y-1 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRemovingMemberId(member.id)}
|
||||
disabled={!canArchive}
|
||||
title={removalReason ?? undefined}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{removalReason ? (
|
||||
<div className="text-xs text-muted-foreground">{removalReason}</div>
|
||||
) : null}
|
||||
</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>
|
||||
|
|
@ -331,7 +432,7 @@ export function CompanyAccess() {
|
|||
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"])
|
||||
setDraftStatus(event.target.value as EditableMemberStatus)
|
||||
}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
|
|
@ -423,10 +524,109 @@ export function CompanyAccess() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!removingMember} onOpenChange={(open) => !open && setRemovingMemberId(null)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Archive {memberDisplayName(removingMember)} and move active assignments before hiding this user from assignment fields.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{removingMember && (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border px-3 py-3">
|
||||
<div className="text-sm font-medium">{memberDisplayName(removingMember)}</div>
|
||||
<div className="text-sm text-muted-foreground">{removingMember.user?.email || removingMember.principalId}</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{assignedIssuesQuery.isLoading
|
||||
? "Checking assigned issues..."
|
||||
: `${assignedIssues.length} open assigned issue${assignedIssues.length === 1 ? "" : "s"}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assignedIssues.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Issue reassignment</div>
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
value={reassignmentTarget}
|
||||
onChange={(event) => setReassignmentTarget(event.target.value)}
|
||||
>
|
||||
<option value="__unassigned">Leave unassigned</option>
|
||||
{activeReassignmentUsers.length > 0 ? (
|
||||
<optgroup label="Humans">
|
||||
{activeReassignmentUsers.map((member) => (
|
||||
<option key={member.id} value={`user:${member.principalId}`}>
|
||||
{memberDisplayName(member)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
) : null}
|
||||
{activeReassignmentAgents.length > 0 ? (
|
||||
<optgroup label="Agents">
|
||||
{activeReassignmentAgents.map((agent) => (
|
||||
<option key={agent.id} value={`agent:${agent.id}`}>
|
||||
{agent.name} ({agent.role})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
) : null}
|
||||
</select>
|
||||
<div className="max-h-36 overflow-auto rounded-lg border border-border">
|
||||
{assignedIssues.slice(0, 6).map((issue) => (
|
||||
<div key={issue.id} className="border-b border-border px-3 py-2 text-sm last:border-b-0">
|
||||
<div className="font-medium">{issue.identifier ?? issue.id.slice(0, 8)}</div>
|
||||
<div className="truncate text-muted-foreground">{issue.title}</div>
|
||||
</div>
|
||||
))}
|
||||
{assignedIssues.length > 6 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{assignedIssues.length - 6} more issue{assignedIssues.length - 6 === 1 ? "" : "s"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!removingMember) return;
|
||||
archiveMemberMutation.mutate({
|
||||
memberId: removingMember.id,
|
||||
target: reassignmentTarget,
|
||||
});
|
||||
}}
|
||||
disabled={archiveMemberMutation.isPending || assignedIssuesQuery.isLoading}
|
||||
>
|
||||
{archiveMemberMutation.isPending ? "Removing..." : "Remove member"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function memberDisplayName(member: CompanyMember | null) {
|
||||
if (!member) return "this member";
|
||||
return member.user?.name?.trim() || member.user?.email || member.principalId;
|
||||
}
|
||||
|
||||
function isAssignableAgent(agent: Agent) {
|
||||
return agent.status !== "terminated" && agent.status !== "pending_approval";
|
||||
}
|
||||
|
||||
function isEditableMemberStatus(status: CompanyMember["status"]): status is EditableMemberStatus {
|
||||
return status === "pending" || status === "active" || status === "suspended";
|
||||
}
|
||||
|
||||
function PendingJoinRequestCard({
|
||||
title,
|
||||
subtitle,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ export function InstanceAccess() {
|
|||
useEffect(() => {
|
||||
if (!userAccessQuery.data) return;
|
||||
setSelectedCompanyIds(
|
||||
new Set(userAccessQuery.data.companyAccess.map((membership) => membership.companyId)),
|
||||
new Set(
|
||||
userAccessQuery.data.companyAccess
|
||||
.filter((membership) => membership.status === "active")
|
||||
.map((membership) => membership.companyId),
|
||||
),
|
||||
);
|
||||
}, [userAccessQuery.data]);
|
||||
|
||||
|
|
|
|||
358
ui/src/pages/UserProfile.tsx
Normal file
358
ui/src/pages/UserProfile.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, UserRound } from "lucide-react";
|
||||
import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { userProfilesApi } from "../api/userProfiles";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
formatCents,
|
||||
formatDate,
|
||||
formatShortDate,
|
||||
formatTokens,
|
||||
issueUrl,
|
||||
providerDisplayName,
|
||||
relativeTime,
|
||||
} from "../lib/utils";
|
||||
|
||||
const NO_COMPANY = "__none__";
|
||||
|
||||
function initials(name: string | null | undefined) {
|
||||
const value = name?.trim() || "User";
|
||||
const parts = value.split(/\s+/).filter(Boolean);
|
||||
if (parts.length > 1) return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
|
||||
return value.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function totalTokens(stats: Pick<UserProfileWindowStats, "inputTokens" | "cachedInputTokens" | "outputTokens">) {
|
||||
return stats.inputTokens + stats.cachedInputTokens + stats.outputTokens;
|
||||
}
|
||||
|
||||
function completionRate(stats: UserProfileWindowStats) {
|
||||
if (stats.touchedIssues === 0) return "0%";
|
||||
return `${Math.round((stats.completedIssues / stats.touchedIssues) * 100)}%`;
|
||||
}
|
||||
|
||||
function HeroStat({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-2xl font-semibold tabular-nums sm:text-3xl">{value}</div>
|
||||
<div className="mt-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
{hint ? <div className="mt-0.5 text-xs text-muted-foreground/70">{hint}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowColumn({ stats }: { stats: UserProfileWindowStats }) {
|
||||
const tokens = totalTokens(stats);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 border-l border-border pl-5 first:border-l-0 first:pl-0">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h2 className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{stats.label}</h2>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">{completionRate(stats)} done</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-3">
|
||||
<Metric value={String(stats.touchedIssues)} label="Touched" />
|
||||
<Metric value={String(stats.completedIssues)} label="Completed" />
|
||||
<Metric value={String(stats.commentCount)} label="Comments" />
|
||||
<Metric value={String(stats.activityCount)} label="Actions" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-1.5 pt-3 text-xs tabular-nums text-muted-foreground">
|
||||
<span>Tokens</span>
|
||||
<span className="text-right text-foreground">{formatTokens(tokens)}</span>
|
||||
<span>Spend</span>
|
||||
<span className="text-right text-foreground">{formatCents(stats.costCents)}</span>
|
||||
<span>Created</span>
|
||||
<span className="text-right text-foreground">{stats.createdIssues}</span>
|
||||
<span>Open</span>
|
||||
<span className="text-right text-foreground">{stats.assignedOpenIssues}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageChart({ points }: { points: UserProfileDailyPoint[] }) {
|
||||
const totals = points.map((point) => totalTokens(point));
|
||||
const maxTokens = Math.max(1, ...totals);
|
||||
const maxCompleted = Math.max(1, ...points.map((point) => point.completedIssues));
|
||||
const totalTokensSum = totals.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-sm font-semibold">Last 14 days</h2>
|
||||
<div className="flex items-baseline gap-4 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums text-foreground">{formatTokens(totalTokensSum)}</span>
|
||||
<span>tokens total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-[repeat(14,minmax(0,1fr))] items-end gap-1.5 sm:gap-2">
|
||||
{points.map((point) => {
|
||||
const tokens = totalTokens(point);
|
||||
const heightPct = tokens === 0 ? 0 : Math.max(2, Math.round((tokens / maxTokens) * 100));
|
||||
const completedPct = point.completedIssues === 0
|
||||
? 0
|
||||
: Math.max(8, Math.round((point.completedIssues / maxCompleted) * 36));
|
||||
return (
|
||||
<div key={point.date} className="group flex h-36 flex-col justify-end">
|
||||
<div
|
||||
className="w-full bg-foreground/80 transition-opacity group-hover:bg-foreground"
|
||||
style={{ height: `${heightPct}%`, minHeight: tokens === 0 ? 1 : undefined }}
|
||||
title={`${formatShortDate(point.date)}: ${formatTokens(tokens)} tokens, ${point.completedIssues} completed`}
|
||||
/>
|
||||
{completedPct > 0 ? (
|
||||
<div
|
||||
className="mt-1 w-full rounded-full bg-emerald-500/80"
|
||||
style={{ height: 2, opacity: Math.min(1, 0.35 + completedPct / 100) }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1.5 text-[10px] tabular-nums text-muted-foreground sm:gap-2">
|
||||
{points.map((point, index) => (
|
||||
<div key={point.date} className="text-center">
|
||||
{index === 0 || index === 6 || index === 13 ? formatShortDate(point.date) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 bg-foreground/80" /> tokens / day
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-[3px] w-4 rounded-full bg-emerald-500/80" /> completions
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsageRow {
|
||||
key: string;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
function UsageList({
|
||||
title,
|
||||
empty,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
empty: string;
|
||||
rows: UsageRow[];
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{rows.length}</span>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="pt-4 text-sm text-muted-foreground">{empty}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{rows.map((row) => (
|
||||
<li key={row.key} className="grid gap-2 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{row.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{row.sublabel}</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4 text-xs tabular-nums sm:justify-end">
|
||||
<span className="text-muted-foreground">{formatTokens(totalTokens(row))}</span>
|
||||
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserProfile() {
|
||||
const { userSlug = "" } = useParams<{ userSlug: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const companyId = selectedCompanyId ?? NO_COMPANY;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.userProfile(companyId, userSlug),
|
||||
queryFn: () => userProfilesApi.get(companyId, userSlug),
|
||||
enabled: !!selectedCompanyId && !!userSlug,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]);
|
||||
}, [data?.user.name, setBreadcrumbs, userSlug]);
|
||||
|
||||
const allTime = data?.stats.find((entry) => entry.key === "all");
|
||||
const last7 = data?.stats.find((entry) => entry.key === "last7");
|
||||
const displayName = data?.user.name?.trim() || data?.user.email?.split("@")[0] || "User";
|
||||
|
||||
const agentUsageRows = useMemo<UsageRow[]>(
|
||||
() =>
|
||||
(data?.topAgents ?? []).map((row) => ({
|
||||
key: row.agentId ?? "unknown",
|
||||
label: row.agentName ?? (row.agentId ? row.agentId.slice(0, 8) : "unknown"),
|
||||
sublabel: "Issue-linked usage",
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
})),
|
||||
[data?.topAgents],
|
||||
);
|
||||
|
||||
const providerUsageRows = useMemo<UsageRow[]>(
|
||||
() =>
|
||||
(data?.topProviders ?? []).map((row) => ({
|
||||
key: `${row.provider}:${row.biller}:${row.model}`,
|
||||
label: `${providerDisplayName(row.provider)} / ${row.model}`,
|
||||
sublabel: `Billed through ${providerDisplayName(row.biller)}`,
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
})),
|
||||
[data?.topProviders],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={UserRound} message="Select a company to view user profiles." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="dashboard" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <EmptyState icon={AlertCircle} message="User profile not found for this company." />;
|
||||
}
|
||||
|
||||
const allTimeTokens = allTime ? totalTokens(allTime) : 0;
|
||||
const metaParts = [
|
||||
data.user.membershipRole ?? "member",
|
||||
data.user.membershipStatus,
|
||||
`joined ${formatDate(data.user.joinedAt)}`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-10">
|
||||
<section className="flex flex-col gap-7 border-b border-border pb-8">
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<Avatar className="size-16 border border-border" size="lg">
|
||||
{data.user.image ? <AvatarImage src={data.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback className="text-lg font-semibold">{initials(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h1 className="truncate text-2xl font-semibold">{displayName}</h1>
|
||||
<span className="text-sm text-muted-foreground">@{data.user.slug}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{data.user.email ? <span className="truncate">{data.user.email}</span> : null}
|
||||
{data.user.email ? <span aria-hidden>·</span> : null}
|
||||
<span>{metaParts.join(" · ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<HeroStat label="All-time tokens" value={formatTokens(allTimeTokens)} hint={formatCents(allTime?.costCents ?? 0) + " spent"} />
|
||||
<HeroStat label="Completed" value={String(allTime?.completedIssues ?? 0)} hint={allTime ? `${completionRate(allTime)} rate` : undefined} />
|
||||
<HeroStat label="Open assigned" value={String(allTime?.assignedOpenIssues ?? 0)} hint={`${allTime?.createdIssues ?? 0} created`} />
|
||||
<HeroStat label="7-day actions" value={String(last7?.activityCount ?? 0)} hint={`${last7?.commentCount ?? 0} comments`} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-8 border-b border-border pb-8 lg:grid-cols-3">
|
||||
{data.stats.map((entry) => <WindowColumn key={entry.key} stats={entry} />)}
|
||||
</section>
|
||||
|
||||
<UsageChart points={data.daily} />
|
||||
|
||||
<div className="grid gap-10 pt-2 xl:grid-cols-2">
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-sm font-semibold">Recent tasks</h2>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{data.recentIssues.length}</span>
|
||||
</div>
|
||||
{data.recentIssues.length === 0 ? (
|
||||
<div className="pt-4 text-sm text-muted-foreground">No touched tasks yet.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{data.recentIssues.map((issue) => (
|
||||
<li key={issue.id}>
|
||||
<Link
|
||||
to={issueUrl(issue)}
|
||||
className="grid gap-2 py-2.5 transition-colors hover:bg-accent/40 sm:grid-cols-[auto_1fr_auto] sm:items-center"
|
||||
>
|
||||
<span className="font-mono text-xs text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span className="truncate text-sm">{issue.title}</span>
|
||||
<span className="flex items-center gap-3 sm:justify-end">
|
||||
<StatusBadge status={issue.status} />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-sm font-semibold">Recent activity</h2>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{data.recentActivity.length}</span>
|
||||
</div>
|
||||
{data.recentActivity.length === 0 ? (
|
||||
<div className="pt-4 text-sm text-muted-foreground">No direct user actions recorded yet.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{data.recentActivity.map((event) => (
|
||||
<li key={event.id} className="grid gap-2 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm">{event.action.replaceAll("_", " ")}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{event.entityType} · {event.entityId.slice(0, 12)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">{relativeTime(event.createdAt)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-10 xl:grid-cols-2">
|
||||
<UsageList title="Agent attribution" empty="No issue-linked token usage yet." rows={agentUsageRows} />
|
||||
<UsageList title="Provider mix" empty="No provider usage attributed yet." rows={providerUsageRows} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue