mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(ui): add profile, access, and invite management flows
This commit is contained in:
1 parent
0c07a8235f
commit
38d2bf6326
23 files changed
+2571
-266
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user