From 38d2bf632621e21c44f5505274dbc3eee7ec044e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 08:13:10 -0500 Subject: [PATCH] feat(ui): add profile, access, and invite management flows --- ui/src/App.tsx | 10 + ui/src/api/access.ts | 139 ++++- ui/src/api/auth.ts | 62 +- .../CompanySettingsSidebar.test.tsx | 105 ++++ ui/src/components/CompanySettingsSidebar.tsx | 41 ++ ui/src/components/InstanceSidebar.tsx | 4 +- ui/src/components/Layout.test.tsx | 248 ++++++++ ui/src/components/Layout.tsx | 123 +--- ui/src/components/SidebarAccountMenu.test.tsx | 117 ++++ ui/src/components/SidebarAccountMenu.tsx | 227 +++++++ ui/src/components/access/ModeBadge.tsx | 19 + ui/src/lib/invite-memory.ts | 36 ++ ui/src/lib/queryKeys.ts | 5 + ui/src/pages/Auth.tsx | 6 +- ui/src/pages/CompanyInvites.test.tsx | 8 +- ui/src/pages/CompanyInvites.tsx | 42 +- ui/src/pages/InstanceAccess.tsx | 245 ++++++++ ui/src/pages/InstanceGeneralSettings.tsx | 49 ++ ui/src/pages/InviteLanding.test.tsx | 190 ++++++ ui/src/pages/InviteLanding.tsx | 562 ++++++++++++++---- ui/src/pages/JoinRequestQueue.tsx | 193 ++++++ ui/src/pages/ProfileSettings.test.tsx | 133 +++++ ui/src/pages/ProfileSettings.tsx | 273 +++++++++ 23 files changed, 2571 insertions(+), 266 deletions(-) create mode 100644 ui/src/components/CompanySettingsSidebar.test.tsx create mode 100644 ui/src/components/CompanySettingsSidebar.tsx create mode 100644 ui/src/components/Layout.test.tsx create mode 100644 ui/src/components/SidebarAccountMenu.test.tsx create mode 100644 ui/src/components/SidebarAccountMenu.tsx create mode 100644 ui/src/components/access/ModeBadge.tsx create mode 100644 ui/src/lib/invite-memory.ts create mode 100644 ui/src/pages/InstanceAccess.tsx create mode 100644 ui/src/pages/InviteLanding.test.tsx create mode 100644 ui/src/pages/JoinRequestQueue.tsx create mode 100644 ui/src/pages/ProfileSettings.test.tsx create mode 100644 ui/src/pages/ProfileSettings.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bb4a31e38c0..de51ab3f606 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -25,13 +25,17 @@ 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"; @@ -44,6 +48,7 @@ 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"; @@ -126,6 +131,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -176,6 +183,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -322,7 +330,9 @@ export function App() { } /> }> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 792278d279a..f24cd4fd9fe 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -1,4 +1,4 @@ -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"; @@ -7,6 +7,8 @@ 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; @@ -98,16 +100,29 @@ type CompanyInviteCreated = { inviteMessage?: string | null; }; +export type CompanyMemberGrant = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + permissionKey: PermissionKey; + scope: Record | 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; + membershipRole: HumanCompanyRole | null; createdAt: string; updatedAt: string; - user: { id: string; email: string | null; name: string | null } | null; + user: { id: string; email: string | null; name: string | null; image: string | null } | null; + grants: CompanyMemberGrant[]; }; export type CompanyMembersResponse = { @@ -116,9 +131,81 @@ export type CompanyMembersResponse = { currentUserRole: HumanCompanyRole | null; canManageMembers: boolean; canInviteUsers: boolean; + canApproveJoinRequests: boolean; }; }; +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 | 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 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 const accessApi = { createCompanyInvite: ( companyId: string, @@ -152,8 +239,24 @@ export const accessApi = { input, ), - listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") => - api.get(`/companies/${companyId}/join-requests?status=${status}`), + listInvites: ( + companyId: string, + state?: "active" | "revoked" | "accepted" | "expired", + ) => + api.get( + `/companies/${companyId}/invites${state ? `?state=${state}` : ""}`, + ), + + revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}), + + listJoinRequests: ( + companyId: string, + status: "pending_approval" | "approved" | "rejected" = "pending_approval", + requestType?: "human" | "agent", + ) => + api.get( + `/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`, + ), listMembers: (companyId: string) => api.get(`/companies/${companyId}/members`), @@ -167,6 +270,17 @@ export const accessApi = { }, ) => api.patch(`/companies/${companyId}/members/${memberId}`, input), + updateMemberPermissions: ( + companyId: string, + memberId: string, + input: { + grants: Array<{ + permissionKey: PermissionKey; + scope?: Record | null; + }>; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}/permissions`, input), + approveJoinRequest: (companyId: string, requestId: string) => api.post(`/companies/${companyId}/join-requests/${requestId}/approve`, {}), @@ -196,4 +310,19 @@ 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(`/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(`/admin/users/${userId}/company-access`), + + setUserCompanyAccess: (userId: string, companyIds: string[]) => + api.put(`/admin/users/${userId}/company-access`, { companyIds }), }; diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 5aa5b69ad22..01a3298de62 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,27 +1,18 @@ -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"; 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; - 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; - const user = userValue as Record; - 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).data); + return nested.success ? nested.data : null; } async function authPost(path: string, body: Record) { @@ -43,6 +34,20 @@ async function authPost(path: string, body: Record) { return payload; } +async function authPatch(path: string, body: Record, parse: (value: unknown) => T): Promise { + 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 new Error((payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`); + } + return parse(payload); +} + export const authApi = { getSession: async (): Promise => { const res = await fetch("/api/auth/get-session", { @@ -68,6 +73,21 @@ export const authApi = { await authPost("/sign-up/email", input); }, + getProfile: async (): Promise => { + 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 => + authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)), + signOut: async () => { await authPost("/sign-out", {}); }, diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx new file mode 100644 index 00000000000..8b33169357a --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; + +const sidebarNavItemMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + onClick, + }: { + children: React.ReactNode; + to: string; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + 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; + }) => { + sidebarNavItemMock(props); + return
{props.label}
; + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CompanySettingsSidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + 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); + + await act(async () => { + root.render(); + }); + + 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", + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/invites", + label: "Invites", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx new file mode 100644 index 00000000000..598b448a659 --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -0,0 +1,41 @@ +import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { Link } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { SidebarNavItem } from "./SidebarNavItem"; + +export function CompanySettingsSidebar() { + const { selectedCompany } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + return ( + + ); +} diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index 076b9702652..c9d8f2ebd78 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -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"; @@ -22,7 +22,9 @@ export function InstanceSidebar() {