@@ -330,3 +370,12 @@ export function InstanceGeneralSettings() {
);
}
+
+function StatusBox({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx
new file mode 100644
index 00000000..0fe56c2d
--- /dev/null
+++ b/ui/src/pages/InviteLanding.test.tsx
@@ -0,0 +1,657 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { InviteLandingPage } from "./InviteLanding";
+
+const getInviteMock = vi.hoisted(() => vi.fn());
+const acceptInviteMock = vi.hoisted(() => vi.fn());
+const getSessionMock = vi.hoisted(() => vi.fn());
+const signInEmailMock = vi.hoisted(() => vi.fn());
+const signUpEmailMock = vi.hoisted(() => vi.fn());
+const healthGetMock = vi.hoisted(() => vi.fn());
+const listCompaniesMock = vi.hoisted(() => vi.fn());
+const setSelectedCompanyIdMock = vi.hoisted(() => vi.fn());
+
+vi.mock("../api/access", () => ({
+ accessApi: {
+ getInvite: (token: string) => getInviteMock(token),
+ acceptInvite: (token: string, input: unknown) => acceptInviteMock(token, input),
+ },
+}));
+
+vi.mock("../api/auth", () => ({
+ authApi: {
+ getSession: () => getSessionMock(),
+ signInEmail: (input: unknown) => signInEmailMock(input),
+ signUpEmail: (input: unknown) => signUpEmailMock(input),
+ },
+}));
+
+vi.mock("../api/health", () => ({
+ healthApi: {
+ get: () => healthGetMock(),
+ },
+}));
+
+vi.mock("../api/companies", () => ({
+ companiesApi: {
+ list: () => listCompaniesMock(),
+ },
+}));
+
+vi.mock("@/context/CompanyContext", () => ({
+ useCompany: () => ({
+ selectedCompany: null,
+ selectedCompanyId: null,
+ companies: [],
+ selectionSource: "manual",
+ loading: false,
+ error: null,
+ setSelectedCompanyId: setSelectedCompanyIdMock,
+ reloadCompanies: vi.fn(),
+ createCompany: vi.fn(),
+ }),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+async function flushReact() {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+}
+
+describe("InviteLandingPage", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ localStorage.clear();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
+ configurable: true,
+ value: vi.fn(() => ({
+ fillStyle: "",
+ fillRect: vi.fn(),
+ beginPath: vi.fn(),
+ arc: vi.fn(),
+ fill: vi.fn(),
+ })),
+ });
+ Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
+ configurable: true,
+ value: vi.fn(() => "data:image/png;base64,stub"),
+ });
+
+ getInviteMock.mockResolvedValue({
+ id: "invite-1",
+ companyId: "company-1",
+ companyName: "Acme Robotics",
+ companyLogoUrl: "/api/invites/pcp_invite_test/logo",
+ companyBrandColor: "#114488",
+ inviteType: "company_join",
+ allowedJoinTypes: "both",
+ humanRole: "operator",
+ expiresAt: "2027-03-07T00:10:00.000Z",
+ inviteMessage: "Welcome aboard.",
+ });
+ acceptInviteMock.mockReset();
+ healthGetMock.mockResolvedValue({
+ status: "ok",
+ deploymentMode: "authenticated",
+ });
+ listCompaniesMock.mockResolvedValue([]);
+ getSessionMock.mockResolvedValue(null);
+ signInEmailMock.mockResolvedValue(undefined);
+ signUpEmailMock.mockResolvedValue(undefined);
+ setSelectedCompanyIdMock.mockReset();
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ });
+
+ it("defaults invite auth to account creation and guides existing users back to sign in", async () => {
+ signUpEmailMock.mockRejectedValue(
+ Object.assign(new Error("User already exists. Use another email."), {
+ code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
+ status: 422,
+ }),
+ );
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ expect(container.textContent).toContain("You've been invited to join Paperclip");
+ expect(container.textContent).toContain("Join Acme Robotics");
+ expect(container.textContent).toContain("Create account");
+ expect(container.textContent).toContain("I already have an account");
+ expect(container.textContent).toContain("Message from inviter");
+ expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
+ expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
+ const inviteLogo = container.querySelector('img[alt="Acme Robotics logo"]');
+ expect(inviteLogo).not.toBeNull();
+ expect(inviteLogo?.className).toContain("object-contain");
+ expect(container.querySelector('input[name="name"]')).not.toBeNull();
+
+ const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null;
+ const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
+ const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
+ expect(nameInput).not.toBeNull();
+ expect(emailInput).not.toBeNull();
+ expect(passwordInput).not.toBeNull();
+ const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ expect(inputValueSetter).toBeTypeOf("function");
+
+ await act(async () => {
+ inputValueSetter!.call(nameInput, "Jane Example");
+ nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ nameInput!.dispatchEvent(new Event("change", { bubbles: true }));
+ inputValueSetter!.call(emailInput, "jane@example.com");
+ emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
+ inputValueSetter!.call(passwordInput, "supersecret");
+ passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
+ expect(authForm).not.toBeNull();
+
+ await act(async () => {
+ authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ });
+ await flushReact();
+ await flushReact();
+ await flushReact();
+
+ expect(signUpEmailMock).toHaveBeenCalledWith({
+ name: "Jane Example",
+ email: "jane@example.com",
+ password: "supersecret",
+ });
+ expect(container.textContent).toContain("An account already exists for jane@example.com. Sign in below to continue with this invite.");
+ expect(container.querySelector('input[name="name"]')).toBeNull();
+ expect(container.textContent).toContain("Sign in to continue");
+ expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("turns invalid sign-in responses into a clear invite-specific message", async () => {
+ signInEmailMock.mockRejectedValue(
+ Object.assign(new Error("Invalid email or password"), {
+ code: "INVALID_EMAIL_OR_PASSWORD",
+ status: 401,
+ }),
+ );
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ expect(inputValueSetter).toBeTypeOf("function");
+
+ const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent === "I already have an account",
+ );
+ expect(existingAccountButton).not.toBeNull();
+
+ await act(async () => {
+ existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
+ const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
+ expect(emailInput).not.toBeNull();
+ expect(passwordInput).not.toBeNull();
+
+ await act(async () => {
+ inputValueSetter!.call(emailInput, "jane@example.com");
+ emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
+ inputValueSetter!.call(passwordInput, "wrongpass");
+ passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
+ expect(authForm).not.toBeNull();
+
+ await act(async () => {
+ authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ });
+ await flushReact();
+ await flushReact();
+
+ expect(signInEmailMock).toHaveBeenCalledWith({
+ email: "jane@example.com",
+ password: "wrongpass",
+ });
+ expect(container.textContent).toContain(
+ "That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("auto-accepts the invite after account creation and redirects into the company", async () => {
+ getSessionMock.mockResolvedValueOnce(null);
+ getSessionMock.mockResolvedValue({
+ session: { id: "session-1", userId: "user-1" },
+ user: {
+ id: "user-1",
+ name: "Jane Example",
+ email: "jane@example.com",
+ image: null,
+ },
+ });
+ acceptInviteMock.mockResolvedValue({
+ id: "join-1",
+ companyId: "company-1",
+ requestType: "human",
+ status: "approved",
+ });
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ expect(inputValueSetter).toBeTypeOf("function");
+
+ const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null;
+ const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
+ const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
+ expect(nameInput).not.toBeNull();
+ expect(emailInput).not.toBeNull();
+ expect(passwordInput).not.toBeNull();
+
+ await act(async () => {
+ inputValueSetter!.call(nameInput, "Jane Example");
+ nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ inputValueSetter!.call(emailInput, "jane@example.com");
+ emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ inputValueSetter!.call(passwordInput, "supersecret");
+ passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
+ expect(authForm).not.toBeNull();
+
+ await act(async () => {
+ authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ });
+ await flushReact();
+ await flushReact();
+ await flushReact();
+ await flushReact();
+
+ expect(signUpEmailMock).toHaveBeenCalledWith({
+ name: "Jane Example",
+ email: "jane@example.com",
+ password: "supersecret",
+ });
+ expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
+ expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
+ expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("shows the pending approval page with the company icon and linked access instructions", async () => {
+ acceptInviteMock.mockResolvedValue({
+ id: "join-1",
+ companyId: "company-1",
+ requestType: "human",
+ status: "pending_approval",
+ });
+ getSessionMock.mockResolvedValue({
+ session: { id: "session-1", userId: "user-1" },
+ user: {
+ id: "user-1",
+ name: "Jane Example",
+ email: "jane@example.com",
+ image: null,
+ },
+ });
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+ await flushReact();
+ await flushReact();
+
+ expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
+ expect(container.textContent).toContain("Request to join Acme Robotics");
+ expect(container.textContent).toContain("A company admin must approve your request to join.");
+ expect(container.textContent).toContain(
+ "Ask them to visit Company Settings → Access to approve your request.",
+ );
+ expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
+ expect(container.textContent).not.toContain("http://localhost/company/settings/access");
+
+ const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
+ (link) => link.textContent === "Company Settings → Access",
+ );
+ expect(approvalLinks).toHaveLength(2);
+ const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
+ for (const link of approvalLinks) {
+ expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
+ }
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("keeps the waiting-for-approval state on refresh for an accepted invite", async () => {
+ getInviteMock.mockResolvedValue({
+ id: "invite-1",
+ companyId: "company-1",
+ companyName: "Acme Robotics",
+ companyLogoUrl: "/api/invites/pcp_invite_test/logo",
+ companyBrandColor: "#114488",
+ inviteType: "company_join",
+ allowedJoinTypes: "both",
+ humanRole: "operator",
+ expiresAt: "2027-03-07T00:10:00.000Z",
+ inviteMessage: "Welcome aboard.",
+ joinRequestStatus: "pending_approval",
+ joinRequestType: "human",
+ });
+ getSessionMock.mockResolvedValue({
+ session: { id: "session-1", userId: "user-1" },
+ user: {
+ id: "user-1",
+ name: "Jane Example",
+ email: "jane@example.com",
+ image: null,
+ },
+ });
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+ await flushReact();
+
+ expect(acceptInviteMock).not.toHaveBeenCalled();
+ expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
+ expect(container.textContent).toContain("Your request is still awaiting approval.");
+ expect(container.textContent).toContain(
+ "Ask them to visit Company Settings → Access to approve your request.",
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("redirects straight to the company after sign-in when the user already has access", async () => {
+ getSessionMock.mockResolvedValueOnce(null);
+ getSessionMock.mockResolvedValue({
+ session: { id: "session-1", userId: "user-1" },
+ user: {
+ id: "user-1",
+ name: "Jane Example",
+ email: "jane@example.com",
+ image: null,
+ },
+ });
+ listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]);
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ expect(inputValueSetter).toBeTypeOf("function");
+
+ const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent === "I already have an account",
+ );
+ expect(existingAccountButton).not.toBeNull();
+
+ await act(async () => {
+ existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
+ const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
+ expect(emailInput).not.toBeNull();
+ expect(passwordInput).not.toBeNull();
+
+ await act(async () => {
+ inputValueSetter!.call(emailInput, "jane@example.com");
+ emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ inputValueSetter!.call(passwordInput, "supersecret");
+ passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
+ expect(authForm).not.toBeNull();
+
+ await act(async () => {
+ authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ });
+ await flushReact();
+ await flushReact();
+
+ expect(signInEmailMock).toHaveBeenCalledWith({
+ email: "jane@example.com",
+ password: "supersecret",
+ });
+ expect(acceptInviteMock).not.toHaveBeenCalled();
+ expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
+ expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("falls back to the generated company icon when the invite logo fails to load", async () => {
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const logo = container.querySelector('img[alt="Acme Robotics logo"]') as HTMLImageElement | null;
+ expect(logo).not.toBeNull();
+
+ await act(async () => {
+ logo?.dispatchEvent(new Event("error"));
+ });
+ await flushReact();
+
+ expect(container.querySelector('img[alt="Acme Robotics logo"]')).toBeNull();
+ expect(container.querySelector('img[aria-hidden="true"]')).not.toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("waits for the membership check before showing invite acceptance to signed-in users", async () => {
+ let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null;
+ acceptInviteMock.mockResolvedValue({
+ id: "join-1",
+ companyId: "company-1",
+ requestType: "human",
+ status: "pending_approval",
+ });
+ listCompaniesMock.mockImplementation(
+ () =>
+ new Promise
>((resolve) => {
+ resolveCompanies = resolve;
+ }),
+ );
+ getSessionMock.mockResolvedValue({
+ session: { id: "session-1", userId: "user-1" },
+ user: {
+ id: "user-1",
+ name: "Jane Example",
+ email: "jane@example.com",
+ image: null,
+ },
+ });
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+ } />
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(container.textContent).toContain("Checking your access...");
+ expect(container.textContent).not.toContain("Accept company invite");
+ expect(acceptInviteMock).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveCompanies?.([]);
+ });
+ await flushReact();
+ await flushReact();
+ await flushReact();
+
+ expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
+ expect(container.textContent).toContain("Request to join Acme Robotics");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx
index c2b0fe02..7dcef629 100644
--- a/ui/src/pages/InviteLanding.tsx
+++ b/ui/src/pages/InviteLanding.tsx
@@ -1,24 +1,32 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Link, useParams } from "@/lib/router";
-import { accessApi } from "../api/access";
-import { authApi } from "../api/auth";
-import { healthApi } from "../api/health";
-import { queryKeys } from "../lib/queryKeys";
-import { Button } from "@/components/ui/button";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
-
-type JoinType = "human" | "agent";
-const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
-
+import { Button } from "@/components/ui/button";
+import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
+import { useCompany } from "@/context/CompanyContext";
+import { Link, useNavigate, useParams } from "@/lib/router";
+import { accessApi } from "../api/access";
+import { authApi } from "../api/auth";
+import { companiesApi } from "../api/companies";
+import { healthApi } from "../api/health";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
+import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
+import { queryKeys } from "../lib/queryKeys";
+import { formatDate } from "../lib/utils";
-const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
+type AuthMode = "sign_in" | "sign_up";
+type AuthFeedback = { tone: "error" | "info"; message: string };
-function dateTime(value: string) {
- return new Date(value).toLocaleString();
-}
+const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
+const ENABLED_INVITE_ADAPTERS = new Set([
+ "claude_local",
+ "codex_local",
+ "gemini_local",
+ "opencode_local",
+ "pi_local",
+ "cursor",
+]);
function readNestedString(value: unknown, path: string[]): string | null {
let current: unknown = value;
@@ -29,16 +37,198 @@ function readNestedString(value: unknown, path: string[]): string | null {
return typeof current === "string" && current.trim().length > 0 ? current : null;
}
+const fieldClassName =
+ "w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
+const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6";
+const modeButtonBaseClassName =
+ "flex-1 border px-3 py-2 text-sm transition-colors";
+
+function formatHumanRole(role: string | null | undefined) {
+ if (!role) return null;
+ return role.charAt(0).toUpperCase() + role.slice(1);
+}
+
+function getAuthErrorCode(error: unknown) {
+ if (!error || typeof error !== "object") return null;
+ const code = (error as { code?: unknown }).code;
+ return typeof code === "string" && code.trim().length > 0 ? code : null;
+}
+
+function getAuthErrorMessage(error: unknown) {
+ if (!(error instanceof Error)) return null;
+ const message = error.message.trim();
+ return message.length > 0 ? message : null;
+}
+
+function mapInviteAuthFeedback(
+ error: unknown,
+ authMode: AuthMode,
+ email: string,
+): AuthFeedback {
+ const code = getAuthErrorCode(error);
+ const message = getAuthErrorMessage(error);
+ const emailLabel = email.trim().length > 0 ? email.trim() : "that email";
+
+ if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
+ return {
+ tone: "info",
+ message: `An account already exists for ${emailLabel}. Sign in below to continue with this invite.`,
+ };
+ }
+
+ if (code === "INVALID_EMAIL_OR_PASSWORD") {
+ return {
+ tone: "error",
+ message:
+ "That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
+ };
+ }
+
+ if (authMode === "sign_in" && message === "Request failed: 401") {
+ return {
+ tone: "error",
+ message:
+ "That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
+ };
+ }
+
+ if (authMode === "sign_up" && message === "Request failed: 422") {
+ return {
+ tone: "info",
+ message: `An account may already exist for ${emailLabel}. Try signing in instead.`,
+ };
+ }
+
+ return {
+ tone: "error",
+ message: message ?? "Authentication failed",
+ };
+}
+
+function isBootstrapAcceptancePayload(payload: unknown) {
+ return Boolean(
+ payload &&
+ typeof payload === "object" &&
+ "bootstrapAccepted" in (payload as Record),
+ );
+}
+
+function isApprovedHumanJoinPayload(payload: unknown, showsAgentForm: boolean) {
+ if (!payload || typeof payload !== "object" || showsAgentForm) return false;
+ const status = (payload as { status?: unknown }).status;
+ return status === "approved";
+}
+
+type AwaitingJoinApprovalPanelProps = {
+ companyDisplayName: string;
+ companyLogoUrl: string | null;
+ companyBrandColor: string | null;
+ invitedByUserName: string | null;
+ claimSecret?: string | null;
+ claimApiKeyPath?: string | null;
+ onboardingTextUrl?: string | null;
+};
+
+function InviteCompanyLogo({
+ companyDisplayName,
+ companyLogoUrl,
+ companyBrandColor,
+ className,
+}: {
+ companyDisplayName: string;
+ companyLogoUrl: string | null;
+ companyBrandColor: string | null;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+function AwaitingJoinApprovalPanel({
+ companyDisplayName,
+ companyLogoUrl,
+ companyBrandColor,
+ invitedByUserName,
+ claimSecret = null,
+ claimApiKeyPath = null,
+ onboardingTextUrl = null,
+}: AwaitingJoinApprovalPanelProps) {
+ const approvalUrl = `${window.location.origin}/company/settings/access`;
+ const approverLabel = invitedByUserName ?? "A company admin";
+
+ return (
+
+
+
+
+
Request to join {companyDisplayName}
+
+
+
+ Your request is still awaiting approval. {approverLabel} must approve your request to join.
+
+
+
+ Ask them to visit Company Settings → Access to approve your request.
+
+
+ Refresh this page after you've been approved — you'll be redirected automatically.
+
+
+ {claimSecret && claimApiKeyPath ? (
+
+
Claim secret
+
{claimSecret}
+
POST {claimApiKeyPath}
+
+ ) : null}
+ {onboardingTextUrl ? (
+
+ Onboarding: {onboardingTextUrl}
+
+ ) : null}
+
+
+ );
+}
+
export function InviteLandingPage() {
const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const { setSelectedCompanyId } = useCompany();
const params = useParams();
const token = (params.token ?? "").trim();
- const [joinType, setJoinType] = useState("human");
+ const [authMode, setAuthMode] = useState("sign_up");
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
const [agentName, setAgentName] = useState("");
const [adapterType, setAdapterType] = useState("claude_local");
const [capabilities, setCapabilities] = useState("");
const [result, setResult] = useState<{ kind: "bootstrap" | "join"; payload: unknown } | null>(null);
const [error, setError] = useState(null);
+ const [authFeedback, setAuthFeedback] = useState(null);
+ const [autoAcceptStarted, setAutoAcceptStarted] = useState(false);
const healthQuery = useQuery({
queryKey: queryKeys.health,
@@ -57,33 +247,85 @@ export function InviteLandingPage() {
retry: false,
});
- const invite = inviteQuery.data;
- const companyName = invite?.companyName?.trim() || null;
- const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
- const availableJoinTypes = useMemo(() => {
- if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
- if (allowedJoinTypes === "both") return ["human", "agent"] as JoinType[];
- return [allowedJoinTypes] as JoinType[];
- }, [invite?.inviteType, allowedJoinTypes]);
+ const companiesQuery = useQuery({
+ queryKey: queryKeys.companies.all,
+ queryFn: () => companiesApi.list(),
+ enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId,
+ retry: false,
+ });
useEffect(() => {
- if (!availableJoinTypes.includes(joinType)) {
- setJoinType(availableJoinTypes[0] ?? "human");
- }
- }, [availableJoinTypes, joinType]);
+ if (token) rememberPendingInviteToken(token);
+ }, [token]);
- const requiresAuthForHuman =
- joinType === "human" &&
+ useEffect(() => {
+ setAutoAcceptStarted(false);
+ }, [token]);
+
+ useEffect(() => {
+ if (!companiesQuery.data || !inviteQuery.data?.companyId) return;
+ const isMember = companiesQuery.data.some(
+ (c) => c.id === inviteQuery.data!.companyId
+ );
+ if (isMember) {
+ clearPendingInviteToken(token);
+ navigate("/", { replace: true });
+ }
+ }, [companiesQuery.data, inviteQuery.data, token, navigate]);
+
+ const invite = inviteQuery.data;
+ const isCheckingExistingMembership =
+ Boolean(sessionQuery.data) &&
+ Boolean(invite?.companyId) &&
+ companiesQuery.isLoading;
+ const isCurrentMember =
+ Boolean(invite?.companyId) &&
+ Boolean(
+ companiesQuery.data?.some((company) => company.id === invite?.companyId),
+ );
+ const companyName = invite?.companyName?.trim() || null;
+ const companyDisplayName = companyName || "this Paperclip company";
+ const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
+ const companyBrandColor = invite?.companyBrandColor?.trim() || null;
+ const invitedByUserName = invite?.invitedByUserName?.trim() || null;
+ const inviteMessage = invite?.inviteMessage?.trim() || null;
+ const requestedHumanRole = formatHumanRole(invite?.humanRole);
+ const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null;
+ const inviteJoinRequestType = invite?.joinRequestType ?? null;
+ const requiresHumanAccount =
healthQuery.data?.deploymentMode === "authenticated" &&
- !sessionQuery.data;
+ !sessionQuery.data &&
+ invite?.allowedJoinTypes !== "agent";
+ const showsAgentForm = invite?.inviteType !== "bootstrap_ceo" && invite?.allowedJoinTypes === "agent";
+ const shouldAutoAcceptHumanInvite =
+ Boolean(sessionQuery.data) &&
+ !showsAgentForm &&
+ invite?.inviteType !== "bootstrap_ceo" &&
+ !inviteJoinRequestStatus &&
+ !isCheckingExistingMembership &&
+ !isCurrentMember &&
+ !result &&
+ error === null;
+ const sessionLabel =
+ sessionQuery.data?.user.name?.trim() ||
+ sessionQuery.data?.user.email?.trim() ||
+ "this account";
+
+ const authCanSubmit =
+ email.trim().length > 0 &&
+ password.trim().length > 0 &&
+ (authMode === "sign_in" || (name.trim().length > 0 && password.trim().length >= 8));
const acceptMutation = useMutation({
mutationFn: async () => {
if (!invite) throw new Error("Invite not found");
- if (invite.inviteType === "bootstrap_ceo") {
- return accessApi.acceptInvite(token, { requestType: "human" });
+ if (isCheckingExistingMembership) {
+ throw new Error("Checking your company access. Try again in a moment.");
}
- if (joinType === "human") {
+ if (isCurrentMember) {
+ throw new Error("This account already belongs to the company.");
+ }
+ if (invite.inviteType === "bootstrap_ceo" || invite.allowedJoinTypes !== "agent") {
return accessApi.acceptInvite(token, { requestType: "human" });
}
return accessApi.acceptInvite(token, {
@@ -95,17 +337,87 @@ export function InviteLandingPage() {
},
onSuccess: async (payload) => {
setError(null);
+ clearPendingInviteToken(token);
+ const asBootstrap = isBootstrapAcceptancePayload(payload);
+ setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
- const asBootstrap =
- payload && typeof payload === "object" && "bootstrapAccepted" in (payload as Record);
- setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
+ if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) {
+ setSelectedCompanyId(invite.companyId, { source: "manual" });
+ navigate("/", { replace: true });
+ }
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to accept invite");
},
});
+ useEffect(() => {
+ if (!shouldAutoAcceptHumanInvite || autoAcceptStarted || acceptMutation.isPending) return;
+ setAutoAcceptStarted(true);
+ setError(null);
+ acceptMutation.mutate();
+ }, [acceptMutation, autoAcceptStarted, shouldAutoAcceptHumanInvite]);
+
+ const authMutation = useMutation({
+ mutationFn: async () => {
+ if (authMode === "sign_in") {
+ await authApi.signInEmail({ email: email.trim(), password });
+ return;
+ }
+ await authApi.signUpEmail({
+ name: name.trim(),
+ email: email.trim(),
+ password,
+ });
+ },
+ onSuccess: async () => {
+ setAuthFeedback(null);
+ rememberPendingInviteToken(token);
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
+ const companies = await queryClient.fetchQuery({
+ queryKey: queryKeys.companies.all,
+ queryFn: () => companiesApi.list(),
+ retry: false,
+ });
+
+ if (invite?.companyId && companies.some((company) => company.id === invite.companyId)) {
+ clearPendingInviteToken(token);
+ setSelectedCompanyId(invite.companyId, { source: "manual" });
+ navigate("/", { replace: true });
+ return;
+ }
+
+ if (!invite || invite.inviteType !== "bootstrap_ceo") {
+ return;
+ }
+
+ try {
+ const payload = await acceptMutation.mutateAsync();
+ if (isBootstrapAcceptancePayload(payload)) {
+ navigate("/", { replace: true });
+ }
+ } catch {
+ return;
+ }
+ },
+ onError: (err) => {
+ const nextFeedback = mapInviteAuthFeedback(err, authMode, email);
+ if (getAuthErrorCode(err) === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
+ setAuthMode("sign_in");
+ setPassword("");
+ }
+ setAuthFeedback(nextFeedback);
+ },
+ });
+
+ const joinButtonLabel = useMemo(() => {
+ if (!invite) return "Continue";
+ if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
+ if (showsAgentForm) return "Submit request";
+ return sessionQuery.data ? "Accept invite" : "Continue";
+ }, [invite, sessionQuery.data, showsAgentForm]);
+
if (!token) {
return Invalid invite token.
;
}
@@ -114,10 +426,14 @@ export function InviteLandingPage() {
return Loading invite...
;
}
+ if (isCheckingExistingMembership) {
+ return Checking your access...
;
+ }
+
if (inviteQuery.error || !invite) {
return (
-
+
Invite not available
This invite may be expired, revoked, or already used.
@@ -127,17 +443,50 @@ export function InviteLandingPage() {
);
}
- if (result?.kind === "bootstrap") {
+ if (
+ inviteJoinRequestStatus === "approved" &&
+ inviteJoinRequestType === "human" &&
+ isCurrentMember
+ ) {
+ return
Opening company...
;
+ }
+
+ if (inviteJoinRequestStatus === "pending_approval") {
+ return (
+
+ );
+ }
+
+ if (inviteJoinRequestStatus) {
return (
-
-
Bootstrap complete
+
+
Invite not available
- The first instance admin is now configured. You can continue to the board.
+ {inviteJoinRequestStatus === "rejected"
+ ? "This join request was not approved."
+ : "This invite has already been used."}
-
- Open board
-
+
+
+ );
+ }
+
+ if (result?.kind === "bootstrap") {
+ return (
+
+
+
Bootstrap complete
+
+
+ Open board
+
+
);
@@ -148,171 +497,330 @@ export function InviteLandingPage() {
claimSecret?: string;
claimApiKeyPath?: string;
onboarding?: Record
;
- diagnostics?: Array<{
- code: string;
- level: "info" | "warn";
- message: string;
- hint?: string;
- }>;
};
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
- const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
- const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
- const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
- const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]);
- const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
+ const joinedNow = !showsAgentForm && payload.status === "approved";
+
return (
-
-
-
Join request submitted
-
- Your request is pending admin approval. You will not have access until approved.
-
-
- Request ID:
{payload.id}
+ joinedNow ? (
+
+
+
+
+
You joined the company
+
+
+
+ Open board
+
+
- {claimSecret && claimApiKeyPath && (
-
-
One-time claim secret (save now)
-
{claimSecret}
-
POST {claimApiKeyPath}
-
- )}
- {(onboardingSkillUrl || onboardingSkillPath || onboardingInstallPath) && (
-
-
Paperclip skill bootstrap
- {onboardingSkillUrl &&
GET {onboardingSkillUrl}
}
- {!onboardingSkillUrl && onboardingSkillPath &&
GET {onboardingSkillPath}
}
- {onboardingInstallPath &&
Install to {onboardingInstallPath}
}
-
- )}
- {(onboardingTextUrl || onboardingTextPath) && (
-
-
Agent-readable onboarding text
- {onboardingTextUrl &&
GET {onboardingTextUrl}
}
- {!onboardingTextUrl && onboardingTextPath &&
GET {onboardingTextPath}
}
-
- )}
- {diagnostics.length > 0 && (
-
-
Connectivity diagnostics
- {diagnostics.map((diag, idx) => (
-
-
- [{diag.level}] {diag.message}
-
- {diag.hint &&
{diag.hint}
}
-
- ))}
-
- )}
-
+ ) : (
+
+ )
);
}
return (
-
-
-
- {invite.inviteType === "bootstrap_ceo"
- ? "Bootstrap your Paperclip instance"
- : companyName
- ? `Join ${companyName}`
- : "Join this Paperclip company"}
-
-
- {invite.inviteType !== "bootstrap_ceo" && companyName
- ? `You were invited to join ${companyName}. `
- : null}
- Invite expires {dateTime(invite.expiresAt)}.
-
-
- {invite.inviteType !== "bootstrap_ceo" && (
-
- {availableJoinTypes.map((type) => (
- 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}
-
- ))}
-
- )}
-
- {joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && (
-
-
- Agent name
- setAgentName(event.target.value)}
+
+
+
+
+
+
-
-
- Adapter type
- setAdapterType(event.target.value as AgentAdapterType)}
- >
- {joinAdapterOptions.map((type) => (
-
- {getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
-
- ))}
-
-
-
- Capabilities (optional)
-
-
- )}
-
- {requiresAuthForHuman && (
-
- Sign in or create an account before submitting a human join request.
-
-
- Sign in / Create account
-
+
+
+ You've been invited to join Paperclip
+
+
+ {invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`}
+
+
+ {showsAgentForm
+ ? "Review the invite details, then submit the agent information below to start the join request."
+ : requiresHumanAccount
+ ? "Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
+ : "Your account is ready. Review the invite details, then accept it to continue."}
+
+
-
- )}
- {error && {error}
}
+
+
+
Company
+
{companyDisplayName}
+
+
+
Invited by
+
{invitedByUserName ?? "Paperclip board"}
+
+
+
Requested access
+
+ {showsAgentForm ? "Agent join request" : requestedHumanRole ?? "Company access"}
+
+
+
+
Invite expires
+
{formatDate(invite.expiresAt)}
+
+
- acceptMutation.mutate()}
- >
- {acceptMutation.isPending
- ? "Submitting…"
- : invite.inviteType === "bootstrap_ceo"
- ? "Accept bootstrap invite"
- : "Submit join request"}
-
+ {inviteMessage ? (
+
+
Message from inviter
+
{inviteMessage}
+
+ ) : null}
+
+ {sessionQuery.data ? (
+
+ Signed in as {sessionLabel} .
+
+ ) : null}
+
+
+
+ {showsAgentForm ? (
+
+
+
Submit agent details
+
+ This invite will create an approval request for a new agent in {companyDisplayName}.
+
+
+
+ Agent name
+ setAgentName(event.target.value)}
+ />
+
+
+ Adapter type
+ setAdapterType(event.target.value as AgentAdapterType)}
+ >
+ {joinAdapterOptions.map((type) => (
+
+ {getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
+
+ ))}
+
+
+
+ Capabilities
+
+ {error ?
{error}
: null}
+
acceptMutation.mutate()}
+ >
+ {acceptMutation.isPending ? "Working..." : joinButtonLabel}
+
+
+ ) : requiresHumanAccount ? (
+
+
+
+ {authMode === "sign_up" ? "Create your account" : "Sign in to continue"}
+
+
+ {authMode === "sign_up"
+ ? `Start with a Paperclip account. After that, you'll come right back here to accept the invite for ${companyDisplayName}.`
+ : "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
+
+
+
+
+ {
+ setAuthFeedback(null);
+ setAuthMode("sign_up");
+ }}
+ >
+ Create account
+
+ {
+ setAuthFeedback(null);
+ setAuthMode("sign_in");
+ }}
+ >
+ I already have an account
+
+
+
+
+
+
+ {authMode === "sign_up"
+ ? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user."
+ : "No account yet? Switch back to create account so you can accept the invite with a new login."}
+
+
+ ) : (
+
+
+
+ {shouldAutoAcceptHumanInvite
+ ? "Submitting join request"
+ : invite.inviteType === "bootstrap_ceo"
+ ? "Accept bootstrap invite"
+ : "Accept company invite"}
+
+
+ {shouldAutoAcceptHumanInvite
+ ? `Submitting your join request for ${companyDisplayName}.`
+ : isCurrentMember
+ ? `This account already belongs to ${companyDisplayName}.`
+ : `This will ${
+ invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}`
+ }.`}
+
+
+ {error ?
{error}
: null}
+ {shouldAutoAcceptHumanInvite ? (
+
+ {acceptMutation.isPending ? "Submitting request..." : "Finishing sign-in..."}
+
+ ) : (
+
acceptMutation.mutate()}
+ >
+ {acceptMutation.isPending ? "Working..." : joinButtonLabel}
+
+ )}
+
+ )}
+
+
);
diff --git a/ui/src/pages/InviteUxLab.test.tsx b/ui/src/pages/InviteUxLab.test.tsx
new file mode 100644
index 00000000..0eb7e8d8
--- /dev/null
+++ b/ui/src/pages/InviteUxLab.test.tsx
@@ -0,0 +1,52 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { InviteUxLab } from "./InviteUxLab";
+
+vi.mock("@/components/CompanyPatternIcon", () => ({
+ CompanyPatternIcon: ({ companyName }: { companyName: string }) => (
+ {companyName}
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("InviteUxLab", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ });
+
+ it("renders the invite/signup review sections", async () => {
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render( );
+ });
+
+ expect(container.textContent).toContain("Invite and signup UX review surface");
+ expect(container.textContent).toContain("/tests/ux/invites");
+ expect(container.textContent).toContain("Landing state coverage");
+ expect(container.textContent).toContain("Split-screen invite flows");
+ expect(container.textContent).toContain("Approval and completion screens");
+ expect(container.textContent).toContain("Auth page states");
+ expect(container.textContent).toContain("Company invite management");
+ expect(container.textContent).toContain("Create your account");
+ expect(container.textContent).toContain("Invite history");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx
new file mode 100644
index 00000000..941a4875
--- /dev/null
+++ b/ui/src/pages/InviteUxLab.tsx
@@ -0,0 +1,927 @@
+import type { ReactNode } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
+import { cn } from "@/lib/utils";
+import {
+ ArrowRight,
+ Check,
+ Clock3,
+ ExternalLink,
+ FlaskConical,
+ KeyRound,
+ Link2,
+ Loader2,
+ MailPlus,
+ ShieldCheck,
+ UserPlus,
+ Users,
+} from "lucide-react";
+
+const inviteRoleOptions = [
+ {
+ value: "viewer",
+ label: "Viewer",
+ description: "Can view company work and follow along without operational permissions.",
+ gets: "No built-in grants.",
+ },
+ {
+ value: "operator",
+ label: "Operator",
+ description: "Recommended for people who need to help run work without managing access.",
+ gets: "Can assign tasks.",
+ },
+ {
+ value: "admin",
+ label: "Admin",
+ description: "Recommended for operators who need to invite people, create agents, and approve joins.",
+ gets: "Can create agents, invite users, assign tasks, and approve join requests.",
+ },
+ {
+ value: "owner",
+ label: "Owner",
+ description: "Full company access, including membership and permission management.",
+ gets: "Everything in Admin, plus managing members and permission grants.",
+ },
+] as const;
+
+const inviteHistory = [
+ {
+ id: "invite-active",
+ state: "Active",
+ humanRole: "operator",
+ invitedBy: "Board User 25",
+ email: "board25@paperclip.local",
+ createdAt: "Apr 25, 2026, 9:00 AM",
+ action: "Revoke",
+ relatedLabel: "Review request",
+ },
+ {
+ id: "invite-accepted",
+ state: "Accepted",
+ humanRole: "viewer",
+ invitedBy: "Board User 24",
+ email: "board24@paperclip.local",
+ createdAt: "Apr 24, 2026, 8:15 AM",
+ action: "Inactive",
+ relatedLabel: "—",
+ },
+ {
+ id: "invite-revoked",
+ state: "Revoked",
+ humanRole: "admin",
+ invitedBy: "Board User 20",
+ email: "board20@paperclip.local",
+ createdAt: "Apr 20, 2026, 2:45 PM",
+ action: "Inactive",
+ relatedLabel: "—",
+ },
+ {
+ id: "invite-expired",
+ state: "Expired",
+ humanRole: "owner",
+ invitedBy: "Board User 19",
+ email: "board19@paperclip.local",
+ createdAt: "Apr 19, 2026, 7:10 PM",
+ action: "Inactive",
+ relatedLabel: "—",
+ },
+] as const;
+
+const fieldClassName =
+ "w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
+const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6";
+
+function LabSection({
+ eyebrow,
+ title,
+ description,
+ accentClassName,
+ children,
+}: {
+ eyebrow: string;
+ title: string;
+ description: string;
+ accentClassName?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+ {eyebrow}
+
+
{title}
+
{description}
+
+
+ {children}
+
+ );
+}
+
+function StatusCard({
+ icon,
+ title,
+ body,
+ tone = "default",
+}: {
+ icon: ReactNode;
+ title: string;
+ body: string;
+ tone?: "default" | "warn" | "success" | "error";
+}) {
+ const toneClassName = {
+ default: "border-border/70 bg-background/85",
+ warn: "border-amber-400/40 bg-amber-500/[0.08]",
+ success: "border-emerald-400/40 bg-emerald-500/[0.08]",
+ error: "border-rose-400/40 bg-rose-500/[0.08]",
+ }[tone];
+
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {body}
+
+
+
+ );
+}
+
+function InviteLandingShell({
+ left,
+ right,
+}: {
+ left: ReactNode;
+ right: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function InviteSummaryPanel({
+ title,
+ description,
+ inviteMessage,
+ requestedAccess,
+ signedInLabel,
+}: {
+ title: string;
+ description: string;
+ inviteMessage?: string;
+ requestedAccess: string;
+ signedInLabel?: string;
+}) {
+ return (
+ <>
+
+
+
+
You've been invited to join Paperclip
+
{title}
+
{description}
+
+
+
+
+
+
+
+
+
+
+ {inviteMessage ? (
+
+
Message from inviter
+
{inviteMessage}
+
+ ) : null}
+
+ {signedInLabel ? (
+
+ Signed in as {signedInLabel} .
+
+ ) : null}
+ >
+ );
+}
+
+function MetaCard({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
+
+function InlineAuthPreview({
+ mode,
+ feedback,
+ working,
+}: {
+ mode: "sign_up" | "sign_in";
+ feedback?: { tone: "info" | "error"; text: string };
+ working?: boolean;
+}) {
+ return (
+
+
+
+ {mode === "sign_up" ? "Create your account" : "Sign in to continue"}
+
+
+ {mode === "sign_up"
+ ? "Start with a Paperclip account. After that, you'll come right back here to accept the invite for Acme Robotics."
+ : "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
+
+
+
+
+
+ Create account
+
+
+ I already have an account
+
+
+
+
+
+
+ {mode === "sign_up"
+ ? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user."
+ : "No account yet? Switch back to create account so you can accept the invite with a new login."}
+
+
+ );
+}
+
+function AgentRequestPreview() {
+ return (
+
+
+
Submit agent details
+
+ This invite will create an approval request for a new agent in Acme Robotics.
+
+
+
+ Agent name
+
+
+
+ Adapter type
+
+ Codex
+ Claude Code
+ Cursor
+
+
+
+ Capabilities
+
+
+
+ Submit request
+
+
+ );
+}
+
+function AcceptInvitePreview({
+ autoAccept,
+ isCurrentMember,
+ error,
+}: {
+ autoAccept?: boolean;
+ isCurrentMember?: boolean;
+ error?: string;
+}) {
+ return (
+
+
+
Accept company invite
+
+ {autoAccept
+ ? "Submitting your join request for Acme Robotics."
+ : isCurrentMember
+ ? "This account already belongs to Acme Robotics."
+ : "This will submit or complete your join request for Acme Robotics."}
+
+
+ {error ?
{error}
: null}
+ {autoAccept ? (
+
Submitting request...
+ ) : (
+
+ Accept invite
+
+ )}
+
+ );
+}
+
+function InviteResultPreview({
+ title,
+ description,
+ claimSecret,
+ onboardingTextUrl,
+ joinedNow = false,
+}: {
+ title: string;
+ description: string;
+ claimSecret?: string;
+ onboardingTextUrl?: string;
+ joinedNow?: boolean;
+}) {
+ return (
+
+
+
+
{title}
+
+
+
{description}
+ {joinedNow ? (
+
+ Open board
+
+ ) : (
+ <>
+
+
+ Refresh this page after you've been approved — you'll be redirected automatically.
+
+ >
+ )}
+ {claimSecret ? (
+
+
Claim secret
+
{claimSecret}
+
POST /api/agents/claim-api-key
+
+ ) : null}
+ {onboardingTextUrl ? (
+
+ Onboarding: {onboardingTextUrl}
+
+ ) : null}
+
+
+ );
+}
+
+function AuthScreenPreview({ mode, error }: { mode: "sign_in" | "sign_up"; error?: string }) {
+ return (
+
+
+
+
+
+
+ Paperclip
+
+
+ {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
+
+
+ {mode === "sign_in"
+ ? "Use your email and password to access this instance."
+ : "Create an account for this instance. Email confirmation is not required in v1."}
+
+
+
+ {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
+
+ {mode === "sign_in" ? "Create one" : "Sign in"}
+
+
+
+
+
+
+
+ Auth preview
+
+
Side-by-side signup styling review
+
+ This frame mirrors the production auth surface so spacing, label density, button treatments, and desktop composition are easy to compare.
+
+
+
+
+
+ );
+}
+
+function CompanyInvitesPreview() {
+ return (
+
+
+
+
+
+ Company Invites
+
+
+ Create invite
+
+ Generate a human invite link and choose the default access it should request.
+
+
+
+
+
+ Choose a role
+
+ {inviteRoleOptions.map((option, index) => (
+ 0 && "border-t border-border")}
+ >
+
+
+
+ {option.label}
+ {option.value === "operator" ? (
+
+ Default
+
+ ) : null}
+
+ {option.description}
+ {option.gets}
+
+
+ ))}
+
+
+
+
+ Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
+
+
+
+ Create invite
+ Invite history below keeps the audit trail.
+
+
+
+
+
+
Latest invite link
+
+ This URL includes the current Paperclip domain returned by the server.
+
+
+
+
+ Copied
+
+
+
+ https://paperclip.local/invite/new-token
+
+
+
+
+ Open invite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ State
+ Role
+ Invited by
+ Created
+ Join request
+ Action
+
+
+
+ {inviteHistory.map((invite) => (
+
+
+
+ {invite.state}
+
+
+ {invite.humanRole}
+
+ {invite.invitedBy}
+ {invite.email}
+
+ {invite.createdAt}
+
+ {invite.relatedLabel === "Review request" ? (
+
+ {invite.relatedLabel}
+
+ ) : (
+ {invite.relatedLabel}
+ )}
+
+
+ {invite.action === "Revoke" ? (
+
+ Revoke
+
+ ) : (
+ Inactive
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
Empty history state
+
+ No invites have been created for this company yet.
+
+
+
+
Permission error
+
+ You do not have permission to manage company invites.
+
+
+
+
+
+
+ );
+}
+
+export function InviteUxLab() {
+ return (
+
+
+
+
+
+
+ Invite UX Lab
+
+
Invite and signup UX review surface
+
+ This page collects the current invite landing, signup, approval-result, and company invite-management states in one place so styling changes can be reviewed without recreating each backend condition by hand.
+
+
+
+
+ /tests/ux/invites
+
+
+ signup + invite states
+
+
+ fixture-backed preview
+
+
+
+
+
+
+ Covered states
+
+
+ {[
+ "Invite loading, access-check, missing-token, and unavailable states",
+ "Inline account creation and sign-in variants, including feedback/error copy",
+ "Human accept, agent request, and auto-accept transitions",
+ "Pending approval, joined-now, claim secret, and onboarding result screens",
+ "Company invite creation, copied-link, history, empty, and permission-error states",
+ ].map((highlight) => (
+
+ {highlight}
+
+ ))}
+
+
+
+
+
+
+
+ }
+ title="Loading invite"
+ body="Shown while invite summary, deployment mode, or auth session data is still loading."
+ />
+ }
+ title="Checking your access"
+ body="Shown after sign-in while the app verifies whether the current user already belongs to the invited company."
+ />
+ }
+ title="Invalid invite token"
+ body="The token is missing entirely, so the page short-circuits before any invite lookup."
+ tone="error"
+ />
+ }
+ title="Invite not available"
+ body="Used for expired, revoked, already-consumed, or otherwise missing invites."
+ tone="warn"
+ />
+ }
+ title="Bootstrap complete"
+ body="Result screen for bootstrap CEO invites after setup has been accepted successfully."
+ tone="success"
+ />
+ }
+ title="Auto-accept in progress"
+ body="Signed-in human users skip the extra button click and move straight into join submission."
+ />
+ }
+ title="Already a member"
+ body="Acceptance stays disabled and the page redirects into the company once membership is confirmed."
+ />
+ }
+ title="Invite result surfaces"
+ body="Both pending-approval and joined-now confirmations are included below with claim and onboarding extras."
+ tone="success"
+ />
+
+
+
+
+
+
+ }
+ right={
}
+ />
+
+
+ }
+ right={
+
+ }
+ />
+
+
+ }
+ right={
}
+ />
+
+
+ }
+ right={
}
+ />
+
+
+ }
+ right={
}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 7edea697..4ee14f76 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -7,6 +7,7 @@ import { approvalsApi } from "../api/approvals";
import { activityApi, type RunForIssue } from "../api/activity";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
+import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
@@ -17,6 +18,7 @@ import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
+import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
@@ -250,14 +252,17 @@ function mergeOptimisticFeedbackVote(
];
}
-function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map }) {
+function ActorIdentity({ evt, agentMap, userProfileMap }: { evt: ActivityEvent; agentMap: Map; userProfileMap?: Map }) {
const id = evt.actorId;
if (evt.actorType === "agent") {
const agent = agentMap.get(id);
return ;
}
if (evt.actorType === "system") return ;
- if (evt.actorType === "user") return ;
+ if (evt.actorType === "user") {
+ const profile = userProfileMap?.get(id);
+ return ;
+ }
return ;
}
@@ -502,6 +507,8 @@ type IssueDetailChatTabProps = {
feedbackTermsUrl: string | null;
agentMap: Map;
currentUserId: string | null;
+ userLabelMap: ReadonlyMap | null;
+ userProfileMap: ReadonlyMap | null;
draftKey: string;
reassignOptions: Array<{ id: string; label: string; searchText?: string }>;
currentAssigneeValue: string;
@@ -538,6 +545,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
feedbackTermsUrl,
agentMap,
currentUserId,
+ userLabelMap,
+ userProfileMap,
draftKey,
reassignOptions,
currentAssigneeValue,
@@ -682,6 +691,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueStatus={issueStatus}
agentMap={agentMap}
currentUserId={currentUserId}
+ userLabelMap={userLabelMap}
+ userProfileMap={userProfileMap}
draftKey={draftKey}
enableReassign
reassignOptions={reassignOptions}
@@ -713,6 +724,7 @@ type IssueDetailActivityTabProps = {
issueId: string;
agentMap: Map;
currentUserId: string | null;
+ userProfileMap: Map;
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
};
@@ -721,6 +733,7 @@ function IssueDetailActivityTab({
issueId,
agentMap,
currentUserId,
+ userProfileMap,
pendingApprovalAction,
onApprovalAction,
}: IssueDetailActivityTabProps) {
@@ -837,8 +850,8 @@ function IssueDetailActivityTab({
{activity.slice(0, 20).map((evt) => (
-
-
{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}
+
+
{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}
{relativeTime(evt.createdAt)}
))}
@@ -976,6 +989,11 @@ export function IssueDetail() {
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
+ const { data: companyMembers } = useQuery({
+ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
+ queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
+ enabled: !!selectedCompanyId,
+ });
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@@ -1027,31 +1045,21 @@ export function IssueDetail() {
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
+ const userProfileMap = useMemo(
+ () => buildCompanyUserProfileMap(companyMembers?.users),
+ [companyMembers?.users],
+ );
+ const userLabelMap = useMemo(
+ () => buildCompanyUserLabelMap(companyMembers?.users),
+ [companyMembers?.users],
+ );
const mentionOptions = useMemo
(() => {
- const options: MentionOption[] = [];
- const activeAgents = [...(agents ?? [])]
- .filter((agent) => agent.status !== "terminated")
- .sort((a, b) => a.name.localeCompare(b.name));
- for (const agent of activeAgents) {
- options.push({
- id: `agent:${agent.id}`,
- name: agent.name,
- kind: "agent",
- agentId: agent.id,
- agentIcon: agent.icon,
- });
- }
- for (const project of orderedProjects) {
- options.push({
- id: `project:${project.id}`,
- name: project.name,
- kind: "project",
- projectId: project.id,
- projectColor: project.color,
- });
- }
- return options;
- }, [agents, orderedProjects]);
+ return buildMarkdownMentionOptions({
+ agents,
+ projects: orderedProjects,
+ members: companyMembers?.users,
+ });
+ }, [agents, companyMembers?.users, orderedProjects]);
const resolvedProject = useMemo(
() => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null),
@@ -1085,6 +1093,7 @@ export function IssueDetail() {
const commentReassignOptions = useMemo(() => {
const options: Array<{ id: string; label: string; searchText?: string }> = [];
+ options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }));
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
@@ -1095,7 +1104,7 @@ export function IssueDetail() {
options.push({ id: `user:${currentUserId}`, label: "Me" });
}
return options;
- }, [agents, currentUserId]);
+ }, [agents, companyMembers?.users, currentUserId]);
const actualAssigneeValue = useMemo(
() => assigneeValueFromSelection(issue ?? {}),
@@ -2628,6 +2637,8 @@ export function IssueDetail() {
feedbackTermsUrl={FEEDBACK_TERMS_URL}
agentMap={agentMap}
currentUserId={currentUserId}
+ userLabelMap={userLabelMap}
+ userProfileMap={userProfileMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}
@@ -2652,6 +2663,7 @@ export function IssueDetail() {
issueId={issue.id}
agentMap={agentMap}
currentUserId={currentUserId}
+ userProfileMap={userProfileMap}
pendingApprovalAction={pendingApprovalAction}
onApprovalAction={(approvalId, action) => {
approvalDecision.mutate({ approvalId, action });
diff --git a/ui/src/pages/JoinRequestQueue.tsx b/ui/src/pages/JoinRequestQueue.tsx
new file mode 100644
index 00000000..29662d9a
--- /dev/null
+++ b/ui/src/pages/JoinRequestQueue.tsx
@@ -0,0 +1,194 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { UserPlus2 } from "lucide-react";
+import { accessApi } from "@/api/access";
+import { ApiError } from "@/api/client";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useCompany } from "@/context/CompanyContext";
+import { useToast } from "@/context/ToastContext";
+import { queryKeys } from "@/lib/queryKeys";
+
+export function JoinRequestQueue() {
+ const { selectedCompany, selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const { pushToast } = useToast();
+ const queryClient = useQueryClient();
+ const [status, setStatus] = useState<"pending_approval" | "approved" | "rejected">("pending_approval");
+ const [requestType, setRequestType] = useState<"all" | "human" | "agent">("all");
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: selectedCompany?.name ?? "Company", href: "/dashboard" },
+ { label: "Inbox", href: "/inbox" },
+ { label: "Join Requests" },
+ ]);
+ }, [selectedCompany?.name, setBreadcrumbs]);
+
+ const requestsQuery = useQuery({
+ queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", `${status}:${requestType}`),
+ queryFn: () =>
+ accessApi.listJoinRequests(
+ selectedCompanyId!,
+ status,
+ requestType === "all" ? undefined : requestType,
+ ),
+ enabled: !!selectedCompanyId,
+ });
+
+ const approveMutation = useMutation({
+ mutationFn: (requestId: string) => accessApi.approveJoinRequest(selectedCompanyId!, requestId),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId!) });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!) });
+ pushToast({ title: "Join request approved", tone: "success" });
+ },
+ });
+
+ const rejectMutation = useMutation({
+ mutationFn: (requestId: string) => accessApi.rejectJoinRequest(selectedCompanyId!, requestId),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
+ pushToast({ title: "Join request rejected", tone: "success" });
+ },
+ });
+
+ if (!selectedCompanyId) {
+ return Select a company to review join requests.
;
+ }
+
+ if (requestsQuery.isLoading) {
+ return Loading join requests…
;
+ }
+
+ 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 {message}
;
+ }
+
+ return (
+
+
+
+
+
Join Request Queue
+
+
+ Review human and agent join requests outside the mixed inbox feed. This queue uses the same approval mutations as the inline inbox cards.
+
+
+
+
+
+ Status
+
+ setStatus(event.target.value as "pending_approval" | "approved" | "rejected")
+ }
+ >
+ Pending approval
+ Approved
+ Rejected
+
+
+
+ Request type
+
+ setRequestType(event.target.value as "all" | "human" | "agent")
+ }
+ >
+ All
+ Human
+ Agent
+
+
+
+
+
+ {(requestsQuery.data ?? []).length === 0 ? (
+
+ No join requests match the current filters.
+
+ ) : (
+ requestsQuery.data!.map((request) => (
+
+
+
+
+
+ {request.status.replace("_", " ")}
+
+ {request.requestType}
+ {request.adapterType ? {request.adapterType} : null}
+
+
+
+ {request.requestType === "human"
+ ? request.requesterUser?.name || request.requestEmailSnapshot || request.requestingUserId || "Unknown human requester"
+ : request.agentName || "Unknown agent requester"}
+
+
+ {request.requestType === "human"
+ ? request.requesterUser?.email || request.requestEmailSnapshot || request.requestingUserId
+ : request.capabilities || request.requestIp}
+
+
+
+
+ {request.status === "pending_approval" ? (
+
+ rejectMutation.mutate(request.id)}
+ disabled={rejectMutation.isPending}
+ >
+ Reject
+
+ approveMutation.mutate(request.id)}
+ disabled={approveMutation.isPending}
+ >
+ Approve
+
+
+ ) : null}
+
+
+
+
+
Invite context
+
+ {request.invite
+ ? `${request.invite.allowedJoinTypes} join invite${request.invite.humanRole ? ` • default role ${request.invite.humanRole}` : ""}`
+ : "Invite metadata unavailable"}
+
+ {request.invite?.inviteMessage ? (
+
{request.invite.inviteMessage}
+ ) : null}
+
+
+
Request details
+
Submitted {new Date(request.createdAt).toLocaleString()}
+
Source IP {request.requestIp}
+ {request.requestType === "agent" && request.capabilities ?
{request.capabilities}
: null}
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/ui/src/pages/ProfileSettings.test.tsx b/ui/src/pages/ProfileSettings.test.tsx
new file mode 100644
index 00000000..72a94259
--- /dev/null
+++ b/ui/src/pages/ProfileSettings.test.tsx
@@ -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(
+
+
+ ,
+ );
+ });
+ 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();
+ });
+ });
+});
diff --git a/ui/src/pages/ProfileSettings.tsx b/ui/src/pages/ProfileSettings.tsx
new file mode 100644
index 00000000..a60c0b7a
--- /dev/null
+++ b/ui/src/pages/ProfileSettings.tsx
@@ -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(null);
+ const [name, setName] = useState("");
+ const [image, setImage] = useState("");
+ const [actionError, setActionError] = useState(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(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 Loading profile...
;
+ }
+
+ if (sessionQuery.error || !sessionQuery.data) {
+ return (
+
+ {sessionQuery.error instanceof Error ? sessionQuery.error.message : "Failed to load profile."}
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
Profile
+
+
+ Control how your account appears in the sidebar and other board surfaces.
+
+
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ uploadAvatarMutation.mutate(file);
+ event.target.value = "";
+ }}
+ />
+
+
+ {uploadAvatarMutation.isPending ? : }
+
+
+ {currentImage ? : null}
+ {initials}
+
+
+
+ avatarInputRef.current?.click()}
+ disabled={!selectedCompanyId || isSavingProfile}
+ >
+ {uploadAvatarMutation.isPending ? : }
+ {currentImage ? "Change photo" : "Upload photo"}
+
+ {currentImage ? (
+ removeAvatarMutation.mutate()}
+ disabled={isSavingProfile}
+ >
+ {removeAvatarMutation.isPending ? : }
+ Remove
+
+ ) : null}
+
+
+
+
+
+
{currentName}
+
{sessionQuery.data.user.email ?? "No email"}
+
+
+ Click the avatar to upload a new image. {uploadHint}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
index b88fcbd4..f5b0976b 100644
--- a/ui/src/pages/ProjectDetail.tsx
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -363,7 +363,6 @@ export function ProjectDetail() {
const experimentalSettingsQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
- retry: false,
});
const {
slots: pluginDetailSlots,
diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx
index d86dceef..0262cc94 100644
--- a/ui/src/pages/Routines.tsx
+++ b/ui/src/pages/Routines.tsx
@@ -807,20 +807,11 @@ export function Routines() {
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
- if (!createRoutine.isPending && draft.title.trim()) {
+ if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
-
-
- setDraft((current) => ({ ...current, variables }))}
- />
-