feat(ui): add profile, access, and invite management flows

This commit is contained in:
Dotta committed 2026-04-11 08:13:10 -05:00
1 parent 0c07a8235f
commit 38d2bf6326
23 files changed
+2571 -266

No files matched your search

+10
View File
@@ -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() {
<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 />} />
@@ -176,6 +183,7 @@ 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 />} />
@@ -322,7 +330,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 />} />
+134 -5
View File
@@ -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<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;
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<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 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<JoinRequest[]>(`/companies/${companyId}/join-requests?status=${status}`),
listInvites: (
companyId: string,
state?: "active" | "revoked" | "accepted" | "expired",
) =>
api.get<CompanyInviteRecord[]>(
`/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<CompanyJoinRequest[]>(
`/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`,
),
listMembers: (companyId: string) =>
api.get<CompanyMembersResponse>(`/companies/${companyId}/members`),
@@ -167,6 +270,17 @@ export const accessApi = {
},
) => 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),
approveJoinRequest: (companyId: string, requestId: string) =>
api.post<JoinRequest>(`/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<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 }),
};
+41 -21
View File
@@ -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<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;
}
async function authPost(path: string, body: Record<string, unknown>) {
@@ -43,6 +34,20 @@ async function authPost(path: string, body: Record<string, unknown>) {
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 new Error((payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`);
}
return parse(payload);
}
export const authApi = {
getSession: async (): Promise<AuthSession | null> => {
const res = await fetch("/api/auth/get-session", {
@@ -68,6 +73,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", {});
},
@@ -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;
}) => (
<button type="button" data-to={to} onClick={onClick}>
{children}
</button>
),
}));
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 <div>{props.label}</div>;
},
}));
// 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(<CompanySettingsSidebar />);
});
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();
});
});
});
@@ -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 (
<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} end />
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
</div>
</nav>
</aside>
);
}
+3 -1
View File
@@ -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() {
<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} />
+248
View File
@@ -0,0 +1,248 @@
// @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: "" }),
useNavigate: () => mockNavigate,
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();
});
});
});
+27 -96
View File
@@ -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, useParams } from "@/lib/router";
import { Outlet, useLocation, useNavigate, 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";
@@ -36,8 +36,6 @@ 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";
@@ -62,17 +60,16 @@ export function Layout() {
selectionSource,
setSelectedCompanyId,
} = useCompany();
const { theme, toggleTheme } = useTheme();
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
const location = useLocation();
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
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();
@@ -310,52 +307,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}
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">
@@ -367,53 +331,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}
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>
)}
@@ -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
View 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>
);
}
+19
View 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>;
}
+36
View 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;
}
}
+5
View File
@@ -88,8 +88,13 @@ export const queryKeys = {
issues: (approvalId: string) => ["approvals", "issues", approvalId] as const,
},
access: {
invites: (companyId: string, state: string = "all") =>
["access", "invites", companyId, state] 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,
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,
},
auth: {
+5 -1
View File
@@ -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(),
+4 -4
View File
@@ -194,7 +194,6 @@ describe("CompanyInvites", () => {
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("Copy link");
expect(container.textContent).toContain("Open invite");
expect(pushToastMock).toHaveBeenCalledWith({
title: "Invite created",
@@ -202,16 +201,17 @@ describe("CompanyInvites", () => {
tone: "success",
});
const copyLinkButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Copy link"),
const inviteFieldButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("https://paperclip.local/invite/new-token"),
);
await act(async () => {
copyLinkButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
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 }));
+29 -13
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, ExternalLink, MailPlus } from "lucide-react";
import { Check, ExternalLink, MailPlus } from "lucide-react";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/button";
@@ -44,16 +44,20 @@ export function CompanyInvites() {
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);
pushToast({
title: "Invite copied",
body: "Invite link copied to clipboard.",
tone: "success",
});
return true;
}
} catch {
@@ -91,6 +95,7 @@ export function CompanyInvites() {
}),
onSuccess: async (invite) => {
setLatestInviteUrl(invite.inviteUrl);
setLatestInviteCopied(false);
const copied = await copyInviteUrl(invite.inviteUrl);
await queryClient.invalidateQueries({ queryKey: queryKeys.access.invites(selectedCompanyId!, "all") });
@@ -212,19 +217,30 @@ export function CompanyInvites() {
{latestInviteUrl ? (
<div className="space-y-3 rounded-lg border border-border px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium">Latest invite link</div>
<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>
<div className="rounded-md border border-border bg-background px-3 py-2 text-sm break-all">
<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}
</div>
</button>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => void copyInviteUrl(latestInviteUrl)}>
<Copy className="h-4 w-4" />
Copy link
</Button>
<Button size="sm" variant="outline" asChild>
<a href={latestInviteUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
+245
View 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>
);
}
+49
View File
@@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PatchInstanceGeneralSettings } 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";
@@ -38,6 +40,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,
@@ -86,6 +93,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">
@@ -221,3 +261,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>
);
}
+190
View File
@@ -0,0 +1,190 @@
// @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());
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("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: null,
selectedCompanyId: null,
companies: [],
selectionSource: "manual",
loading: false,
error: null,
setSelectedCompanyId: vi.fn(),
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/assets/logo-1/content",
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",
});
getSessionMock.mockResolvedValue(null);
signInEmailMock.mockResolvedValue(undefined);
signUpEmailMock.mockResolvedValue(undefined);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("keeps account signup inline and remembers the invite token while signing in", async () => {
getSessionMock
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.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();
expect(container.textContent).toContain("Join Acme Robotics");
expect(container.textContent).toContain("Personal access");
expect(container.textContent).toContain("Create account");
expect(container.textContent).not.toContain("Join as human");
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
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();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
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, "supersecret");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"] form') 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(signInEmailMock).toHaveBeenCalledWith({
email: "jane@example.com",
password: "supersecret",
});
expect(getSessionMock).toHaveBeenCalled();
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
await act(async () => {
root.unmount();
});
});
});
+437 -125
View File
@@ -1,20 +1,30 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
import { ArrowRight, CheckCircle2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
import { Link, useParams } from "@/lib/router";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { healthApi } from "../api/health";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { rememberPendingInviteToken, clearPendingInviteToken } from "../lib/invite-memory";
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";
type AuthMode = "sign_in" | "sign_up";
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
const ENABLED_INVITE_ADAPTERS = new Set([
"claude_local",
"codex_local",
"gemini_local",
"opencode_local",
"pi_local",
"cursor",
]);
function dateTime(value: string) {
return new Date(value).toLocaleString();
@@ -29,16 +39,37 @@ function readNestedString(value: unknown, path: string[]): string | null {
return typeof current === "string" && current.trim().length > 0 ? current : null;
}
function hexToRgba(hex: string | null | undefined, alpha: number) {
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) {
return `rgba(15, 118, 110, ${alpha})`;
}
const normalized = hex.replace("#", "");
const red = Number.parseInt(normalized.slice(0, 2), 16);
const green = Number.parseInt(normalized.slice(2, 4), 16);
const blue = Number.parseInt(normalized.slice(4, 6), 16);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function formatHumanRole(role: string | null | undefined) {
if (!role) return null;
return role.charAt(0).toUpperCase() + role.slice(1);
}
export function InviteLandingPage() {
const queryClient = useQueryClient();
const params = useParams();
const token = (params.token ?? "").trim();
const [joinType, setJoinType] = useState<JoinType>("human");
const [authMode, setAuthMode] = useState<AuthMode>("sign_in");
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 [authError, setAuthError] = useState<string | null>(null);
const healthQuery = useQuery({
queryKey: queryKeys.health,
@@ -57,14 +88,20 @@ export function InviteLandingPage() {
retry: false,
});
useEffect(() => {
if (token) rememberPendingInviteToken(token);
}, [token]);
const invite = inviteQuery.data;
const companyName = invite?.companyName?.trim() || null;
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
const companyBrandColor = invite?.companyBrandColor?.trim() || null;
const companyDisplayName = companyName || "this Paperclip company";
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]);
if (invite?.allowedJoinTypes === "both") return ["human", "agent"] as JoinType[];
return [(invite?.allowedJoinTypes ?? "human") as JoinType];
}, [invite?.allowedJoinTypes, invite?.inviteType]);
useEffect(() => {
if (!availableJoinTypes.includes(joinType)) {
@@ -72,10 +109,20 @@ export function InviteLandingPage() {
}
}, [availableJoinTypes, joinType]);
const requiresAuthForHuman =
joinType === "human" &&
const requiresAccount =
healthQuery.data?.deploymentMode === "authenticated" &&
!sessionQuery.data;
!sessionQuery.data &&
(invite?.inviteType === "bootstrap_ceo" || joinType === "human");
const sessionLabel =
sessionQuery.data?.user.name?.trim() ||
sessionQuery.data?.user.email?.trim() ||
"this account";
const requestedRole = formatHumanRole(invite?.humanRole);
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 () => {
@@ -95,6 +142,7 @@ export function InviteLandingPage() {
},
onSuccess: async (payload) => {
setError(null);
clearPendingInviteToken(token);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
const asBootstrap =
@@ -106,6 +154,29 @@ export function InviteLandingPage() {
},
});
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 () => {
setAuthError(null);
rememberPendingInviteToken(token);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
},
onError: (err) => {
setAuthError(err instanceof Error ? err.message : "Authentication failed");
},
});
if (!token) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
}
@@ -117,10 +188,7 @@ export function InviteLandingPage() {
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"
data-testid="invite-error"
>
<div className="rounded-lg 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.
@@ -133,7 +201,7 @@ export function InviteLandingPage() {
if (result?.kind === "bootstrap") {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
<p className="mt-2 text-sm text-muted-foreground">
The first instance admin is now configured. You can continue to the board.
@@ -166,35 +234,35 @@ export function InviteLandingPage() {
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]);
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
const humanJoinApproved = joinType === "human" && payload.status === "approved";
const personalAccessApproved = joinType === "human" && payload.status === "approved";
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold">
{humanJoinApproved ? "You joined the company" : "Join request submitted"}
{personalAccessApproved ? "You joined the company" : "Access request submitted"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{humanJoinApproved
{personalAccessApproved
? "Your membership is active now. You can continue to the board."
: "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">
<div className="mt-4 rounded-2xl border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
Request ID: <span className="font-mono">{payload.id}</span>
</div>
{humanJoinApproved && (
{personalAccessApproved && (
<Button asChild className="mt-4">
<Link to="/">Open board</Link>
</Button>
)}
{claimSecret && claimApiKeyPath && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 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">
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 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>}
@@ -202,14 +270,14 @@ export function InviteLandingPage() {
</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">
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 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">
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 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">
@@ -226,108 +294,352 @@ export function InviteLandingPage() {
);
}
const canSubmitJoin =
!requiresAccount &&
!acceptMutation.isPending &&
(joinType !== "agent" || invite.inviteType === "bootstrap_ceo" || agentName.trim().length > 0);
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-background px-4 py-6 sm:px-6 lg:px-8"
style={{
backgroundImage: [
`radial-gradient(circle at top left, ${hexToRgba(companyBrandColor, 0.18)}, transparent 36%)`,
`radial-gradient(circle at bottom right, ${hexToRgba(companyBrandColor, 0.1)}, transparent 32%)`,
"linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,248,245,0.98))",
].join(", "),
}}
>
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[minmax(0,1.2fr)_24rem]">
<section className="overflow-hidden rounded-[28px] border border-border/70 bg-card/95 shadow-[0_24px_70px_rgba(15,23,42,0.08)]">
<div
className="border-b border-border/70 px-6 py-6 sm:px-8"
style={{
backgroundImage: `linear-gradient(135deg, ${hexToRgba(companyBrandColor, 0.16)}, transparent 58%)`,
}}
>
<div className="flex items-start gap-4">
<CompanyPatternIcon
companyName={companyDisplayName}
logoUrl={companyLogoUrl}
brandColor={companyBrandColor}
className="h-16 w-16 rounded-[20px] text-xl shadow-sm"
/>
</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 flex-1">
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/80 px-3 py-1 text-xs font-medium text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
Invite ready
</div>
<h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
{invite.inviteType === "bootstrap_ceo"
? "Set up your Paperclip instance"
: `Join ${companyDisplayName}`}
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
{invite.inviteType === "bootstrap_ceo"
? "Finish creating the first operator account without leaving this page."
: "Review the invite, sign in or create an account inline if needed, and keep this invite in place until you finish."}
</p>
</div>
</div>
</div>
)}
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<div className="grid gap-6 px-6 py-6 sm:px-8 lg:grid-cols-[minmax(0,1fr)_260px]">
<div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Access
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{invite.inviteType === "bootstrap_ceo"
? "Instance setup"
: invite.allowedJoinTypes === "both"
? "Personal or agent"
: invite.allowedJoinTypes === "agent"
? "Agent"
: "Personal"}
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Default role
</div>
<div className="mt-2 text-sm font-medium text-foreground">{requestedRole ?? "Review on submit"}</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Expires
</div>
<div className="mt-2 text-sm font-medium text-foreground">{dateTime(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"
: joinType === "human"
? "Join company"
: "Submit join request"}
</Button>
{invite.inviteMessage ? (
<div className="rounded-2xl border border-border/70 bg-muted/30 p-4 text-sm leading-6 text-foreground">
{invite.inviteMessage}
</div>
) : null}
{invite.inviteType !== "bootstrap_ceo" ? (
<div>
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Choose your path
</div>
<div className="mt-3 flex flex-wrap gap-2">
{availableJoinTypes.map((type) => (
<button
key={type}
type="button"
onClick={() => {
setError(null);
setJoinType(type);
}}
className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
joinType === type
? "border-foreground bg-foreground text-background"
: "border-border bg-background text-foreground hover:border-foreground/40"
}`}
>
{type === "human" ? "Personal access" : "Agent access"}
</button>
))}
</div>
</div>
) : null}
<div className="rounded-3xl border border-border/70 bg-background/70 p-5">
<div className="text-sm font-semibold text-foreground">
{invite.inviteType === "bootstrap_ceo"
? "What happens next"
: joinType === "human"
? "How personal access works"
: "How agent access works"}
</div>
<div className="mt-4 space-y-3 text-sm text-muted-foreground">
{(invite.inviteType === "bootstrap_ceo" || joinType === "human") ? (
<>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
<p>Stay on this page while you sign in or create an account.</p>
</div>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
<p>The invite stays remembered locally until you finish the flow.</p>
</div>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
<p>After submit, access is activated immediately or routed for approval, depending on company policy.</p>
</div>
</>
) : (
<>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
<p>Choose the adapter and name for the agent you want to connect.</p>
</div>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
<p>Paperclip returns the one-time claim secret and onboarding endpoints right here.</p>
</div>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
<p>Admins can still review the request before the agent gets company access.</p>
</div>
</>
)}
</div>
</div>
</div>
<aside className="rounded-3xl border border-border/70 bg-background/90 p-5 shadow-sm">
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Continue
</div>
<div className="mt-2 text-lg font-semibold text-foreground">
{requiresAccount
? authMode === "sign_in"
? "Sign in to continue"
: "Create an account to continue"
: invite.inviteType === "bootstrap_ceo"
? "Finish bootstrap"
: joinType === "human"
? "Use your account"
: "Connect an agent"}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{requiresAccount
? "This invite stays on deck while you authenticate."
: invite.inviteType === "bootstrap_ceo"
? `Signed in as ${sessionLabel}.`
: joinType === "human"
? `This invite will be submitted for ${sessionLabel}.`
: "Fill in the agent details and submit the request."}
</p>
{invite.inviteType !== "bootstrap_ceo" && joinType === "agent" ? (
<div className="mt-5 space-y-3">
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Agent name</span>
<input
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
value={agentName}
onChange={(event) => setAgentName(event.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Adapter type</span>
<select
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
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-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
rows={4}
value={capabilities}
onChange={(event) => setCapabilities(event.target.value)}
/>
</label>
</div>
) : null}
{requiresAccount ? (
<div className="mt-5 rounded-3xl border border-border/70 bg-muted/20 p-4" data-testid="invite-inline-auth">
<div className="mb-4 inline-flex rounded-full border border-border bg-background p-1">
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
authMode === "sign_in" ? "bg-foreground text-background" : "text-muted-foreground"
}`}
onClick={() => {
setAuthError(null);
setAuthMode("sign_in");
}}
>
Sign in
</button>
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
authMode === "sign_up" ? "bg-foreground text-background" : "text-muted-foreground"
}`}
onClick={() => {
setAuthError(null);
setAuthMode("sign_up");
}}
>
Create account
</button>
</div>
<form
className="space-y-3"
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) {
setAuthError("Please fill in all required fields.");
return;
}
authMutation.mutate();
}}
>
{authMode === "sign_up" ? (
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Name</span>
<input
name="name"
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
/>
</label>
) : null}
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Email</span>
<input
name="email"
type="email"
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Password</span>
<input
name="password"
type="password"
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
/>
</label>
{authError ? <p className="text-xs text-destructive">{authError}</p> : null}
<Button
type="submit"
className="w-full"
disabled={authMutation.isPending}
aria-disabled={!authCanSubmit || authMutation.isPending}
>
{authMutation.isPending
? "Working..."
: authMode === "sign_in"
? "Sign in and continue"
: "Create account and continue"}
</Button>
</form>
<div className="mt-3 text-xs text-muted-foreground">
Prefer the full auth page?{" "}
<Link
className="font-medium text-foreground underline underline-offset-2"
to={`/auth?next=${encodeURIComponent(`/invite/${token}`)}`}
>
Open auth in a separate page
</Link>
</div>
</div>
) : (
<div className="mt-5 space-y-3">
<div className="rounded-2xl border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground">
{invite.inviteType === "bootstrap_ceo" || joinType === "human"
? `Ready to continue as ${sessionLabel}.`
: "Ready to submit the agent request."}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button className="w-full justify-between" disabled={!canSubmitJoin} onClick={() => acceptMutation.mutate()}>
<span>
{acceptMutation.isPending
? "Submitting..."
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: joinType === "human"
? "Join company"
: "Submit agent request"}
</span>
<ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</aside>
</div>
</section>
</div>
</div>
);
+193
View File
@@ -0,0 +1,193 @@
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!) });
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
View 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
View 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>
);
}