mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Add agent permissions and controls plan (#6386)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies by keeping task ownership, approvals, and operator control inside one control plane. > - Agent permissions and plugin-hosted company settings sit on the boundary between autonomy and governance. > - V1 needs scoped task assignment rules, plugin extension points, and clearer company access surfaces without weakening company boundaries. > - The branch builds the core authorization service, plugin SDK/host APIs, and UI simplifications needed to support those controls. > - Paperclip EE plugin surfaces were intentionally moved out of this core PR per review direction, so this PR now carries only the public core/plugin infrastructure work. > - The latest updates preserve the PAP-9937 branch changes that belong in this PR, remove the `design/` artifacts, and exclude the experimental `plugin-briefs` package. > - Greptile feedback was applied through the authorization/audit paths and the final cleanup commit was re-reviewed at 5/5 with no unresolved Greptile threads. > - The benefit is safer assignment control with extension hooks for richer permission products while preserving simple defaults for normal operators. ## What Changed - Added scoped task-assignment authorization decisions and routed issue/agent assignment mutations through the authorization service. - Added plugin SDK and host APIs for company settings slots, authorization policy/grant management, assignment previews, and bridge invocation scope propagation. - Simplified core company access UI and moved advanced controls behind plugin-provided settings surfaces. - Added retry-now affordances for blocked issue next-step notices. - Added protected-assignment enforcement for persisted agent/project/issue policies, including explicit-grant fallback behavior. - Added incremental principal-access compatibility backfill for active agent memberships and role-default human permission grants. - Added the Markdown code block wrap action fix from the latest branch changes. - Removed `design/` artifacts from the PR and removed `packages/plugins/plugin-briefs` from the final diff. - Addressed Greptile feedback for plugin actor sanitization, legacy membership handling, audit pagination, unknown grant-scope metadata, and startup test mocks. ## Verification - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54 tests passed. - `pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62 tests passed. - `pnpm exec vitest run server/src/__tests__/authorization-service.test.ts server/src/__tests__/plugin-access-authorization-host-services.test.ts server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files passed, 28 tests passed. - `pnpm --filter @paperclipai/server typecheck` -> passed. - `git diff --check` -> passed. - `node ./scripts/check-docker-deps-stage.mjs` -> passed. - `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed with no lockfile update. - `pnpm exec vitest run ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed. - `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0. - GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`. - Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0 comments/annotations added, 0 unresolved review threads. - Confirmed the PR diff contains no `design/`, `packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or `.github/workflows` changes. ## Risks - Medium: task assignment authorization paths are behaviorally stricter for protected/private policy data, so existing plugin-authored policies may block assignment until explicit grants or approval flows are configured. - Medium: plugin-host authorization APIs expand the surface area available to trusted plugins and need careful review for company scoping. - Low: startup now performs a principal-access compatibility backfill, but the migration and runtime backfill use conflict-tolerant inserts. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell, git, and GitHub CLI access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c91a062326
commit
38c185fb8b
102 changed files with 6744 additions and 395 deletions
|
|
@ -30,7 +30,8 @@ import { Activity } from "./pages/Activity";
|
|||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CompanyAccess } from "./pages/CompanyAccess";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { Secrets } from "./pages/Secrets";
|
||||
|
|
@ -69,11 +70,13 @@ function boardRoutes() {
|
|||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||
<Route path="company/settings/:settingsRoutePath/*" element={<CompanySettingsPluginPage />} />
|
||||
<Route path="skills/*" element={<CompanySkills />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
|||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
|
|
@ -61,6 +62,10 @@ vi.mock("@/api/sidebarBadges", () => ({
|
|||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -83,6 +88,11 @@ describe("CompanySettingsSidebar", () => {
|
|||
failedRuns: 0,
|
||||
joinRequests: 2,
|
||||
});
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -110,7 +120,7 @@ describe("CompanySettingsSidebar", () => {
|
|||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Members");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
|
|
@ -129,8 +139,8 @@ describe("CompanySettingsSidebar", () => {
|
|||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/access",
|
||||
label: "Access",
|
||||
to: "/company/settings/members",
|
||||
label: "Members",
|
||||
badge: 2,
|
||||
end: true,
|
||||
}),
|
||||
|
|
@ -154,4 +164,50 @@ describe("CompanySettingsSidebar", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders company settings pages contributed by ready plugins", async () => {
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: "permissions",
|
||||
displayName: "Permissions",
|
||||
exportName: "PermissionsPage",
|
||||
routePath: "permissions",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "permissions-extension",
|
||||
pluginDisplayName: "Permissions Extension",
|
||||
pluginVersion: "0.1.0",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Permissions");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/permissions",
|
||||
label: "Permissions",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
|
||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
export function CompanySettingsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { slots: companySettingsPluginSlots } = usePluginSlots({
|
||||
slotTypes: ["companySettingsPage"],
|
||||
companyId: selectedCompanyId,
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: badges } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.sidebarBadges(selectedCompanyId)
|
||||
|
|
@ -61,12 +67,23 @@ export function CompanySettingsSidebar() {
|
|||
end
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to="/company/settings/access"
|
||||
label="Access"
|
||||
icon={Shield}
|
||||
to="/company/settings/members"
|
||||
label="Members"
|
||||
icon={Users}
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
{companySettingsPluginSlots
|
||||
.filter((slot) => slot.routePath)
|
||||
.map((slot) => (
|
||||
<SidebarNavItem
|
||||
key={`${slot.pluginKey}:${slot.id}`}
|
||||
to={`/company/settings/${slot.routePath}`}
|
||||
label={slot.displayName}
|
||||
icon={Puzzle}
|
||||
end
|
||||
/>
|
||||
))}
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@
|
|||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
|
||||
const retryNowMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
|
||||
|
|
@ -14,11 +18,57 @@ vi.mock("@/lib/router", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
retryScheduledRetryNow: retryNowMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
|
||||
const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
|
||||
|
||||
const baseRetry: IssueScheduledRetry = {
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: "2026-04-19T20:00:00.000Z",
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "max_turns_continuation",
|
||||
retryExhaustedReason: null,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
};
|
||||
|
||||
function buildRetryResponse(outcome: IssueRetryNowOutcome) {
|
||||
return {
|
||||
outcome,
|
||||
message:
|
||||
outcome === "promoted"
|
||||
? "Promoted scheduled retry"
|
||||
: outcome === "already_promoted"
|
||||
? "Scheduled retry already promoted"
|
||||
: outcome === "no_scheduled_retry"
|
||||
? "No scheduled retry"
|
||||
: "Promotion suppressed by gate",
|
||||
scheduledRetry:
|
||||
outcome === "promoted" || outcome === "already_promoted"
|
||||
? { ...baseRetry, status: "queued" as const }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
|
||||
retryNowMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
|
|
@ -27,13 +77,22 @@ afterEach(() => {
|
|||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
dateNowSpy?.mockRestore();
|
||||
dateNowSpy = null;
|
||||
});
|
||||
|
||||
function withProviders(node: ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
<ToastProvider>{node}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
|
@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => {
|
|||
expect(node.textContent).toContain("This issue still needs a next step.");
|
||||
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
|
||||
expect(node.textContent).toContain("Detected progress: Updated the plan");
|
||||
expect(node.textContent).not.toContain("Retry now");
|
||||
expect(node.textContent).not.toContain("Work on this issue is blocked until");
|
||||
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows retry-now action for next-step notices with a scheduled retry", async () => {
|
||||
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueId="issue-1"
|
||||
issueStatus="in_progress"
|
||||
blockers={[]}
|
||||
agentName="CodexCoder"
|
||||
scheduledRetry={baseRetry}
|
||||
successfulRunHandoff={{
|
||||
state: "required",
|
||||
required: true,
|
||||
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
correctiveRunId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
detectedProgressSummary: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.textContent).toContain("Corrective wake scheduled in 1d");
|
||||
const button = node.querySelector<HTMLButtonElement>('[data-testid="issue-next-step-retry-now"]');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button!.textContent ?? "").toContain("Retry now");
|
||||
|
||||
await act(async () => {
|
||||
button!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
|
||||
expect(button!.textContent ?? "").toContain("Promoted");
|
||||
expect(button!.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render when the issue is done even if a stale handoff state is required", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@ import type {
|
|||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
IssueScheduledRetry,
|
||||
SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertTriangle, Flag } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Flag, Loader2, RotateCcw } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { formatMonitorOffset } from "../lib/issue-monitor";
|
||||
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { RetryErrorBand } from "./IssueScheduledRetryCard";
|
||||
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
|
|
@ -34,22 +39,96 @@ function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SuccessfulRunRetryNowControl({
|
||||
issueId,
|
||||
scheduledRetry,
|
||||
}: {
|
||||
issueId: string;
|
||||
scheduledRetry: IssueScheduledRetry;
|
||||
}) {
|
||||
const retryNow = useRetryNowMutation(issueId);
|
||||
const dueAtIso = scheduledRetry.scheduledRetryAt
|
||||
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
|
||||
: null;
|
||||
const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null;
|
||||
const scheduleLabel = relative === "now"
|
||||
? "due now"
|
||||
: relative
|
||||
? `scheduled ${relative}`
|
||||
: "scheduled";
|
||||
const success = retryNow.isSuccess
|
||||
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-amber-300/70 bg-background/80 p-2 dark:border-amber-500/40 dark:bg-background/40">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 text-xs leading-5 text-amber-900 dark:text-amber-100">
|
||||
Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately.
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-amber-300/80 bg-background/80 text-amber-950 shadow-none hover:bg-amber-100 dark:border-amber-500/50 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
||||
onClick={() => retryNow.mutate()}
|
||||
disabled={retryNow.isPending || success}
|
||||
data-testid="issue-next-step-retry-now"
|
||||
>
|
||||
{retryNow.isPending ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
Retrying...
|
||||
</span>
|
||||
) : success ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Retry now
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<RetryErrorBand
|
||||
error={retryNow.lastError}
|
||||
className="mt-2 border-amber-300/70 bg-amber-100/70 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-100"
|
||||
onRetry={() => {
|
||||
retryNow.reset();
|
||||
retryNow.mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueId,
|
||||
issueStatus,
|
||||
blockers,
|
||||
blockerAttention,
|
||||
successfulRunHandoff,
|
||||
scheduledRetry,
|
||||
agentName,
|
||||
}: {
|
||||
issueId?: string | null;
|
||||
issueStatus?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
scheduledRetry?: IssueScheduledRetry | null;
|
||||
agentName?: string | null;
|
||||
}) {
|
||||
if (issueStatus === "done" || issueStatus === "cancelled") return null;
|
||||
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
|
||||
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
const successfulRunRetryNow = showSuccessfulRunHandoff
|
||||
&& issueId
|
||||
&& scheduledRetry?.status === "scheduled_retry"
|
||||
? { issueId, scheduledRetry }
|
||||
: null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
|
|
@ -162,6 +241,12 @@ export function IssueBlockedNotice({
|
|||
Detected progress: {successfulRunHandoff.detectedProgressSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{successfulRunRetryNow ? (
|
||||
<SuccessfulRunRetryNowControl
|
||||
issueId={successfulRunRetryNow.issueId}
|
||||
scheduledRetry={successfulRunRetryNow.scheduledRetry}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import type {
|
|||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
IssueScheduledRetry,
|
||||
SuccessfulRunHandoffState,
|
||||
IssueWorkMode,
|
||||
} from "@paperclipai/shared";
|
||||
|
|
@ -296,9 +297,11 @@ interface IssueChatThreadProps {
|
|||
timelineEvents?: IssueTimelineEvent[];
|
||||
liveRuns?: LiveRunForIssue[];
|
||||
activeRun?: ActiveRunForIssue | null;
|
||||
issueId?: string | null;
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
scheduledRetry?: IssueScheduledRetry | null;
|
||||
recoveryAction?: IssueRecoveryAction | null;
|
||||
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
|
||||
canFalsePositiveRecoveryAction?: boolean;
|
||||
|
|
@ -3617,9 +3620,11 @@ export function IssueChatThread({
|
|||
timelineEvents = [],
|
||||
liveRuns = [],
|
||||
activeRun = null,
|
||||
issueId = null,
|
||||
blockedBy = [],
|
||||
blockerAttention = null,
|
||||
successfulRunHandoff = null,
|
||||
scheduledRetry = null,
|
||||
recoveryAction = null,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction = false,
|
||||
|
|
@ -4299,10 +4304,12 @@ export function IssueChatThread({
|
|||
/>
|
||||
) : null}
|
||||
<IssueBlockedNotice
|
||||
issueId={issueId}
|
||||
issueStatus={issueStatus}
|
||||
blockers={unresolvedBlockers}
|
||||
blockerAttention={blockerAttention}
|
||||
successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
|
||||
scheduledRetry={scheduledRetry}
|
||||
agentName={
|
||||
successfulRunHandoff?.assigneeAgentId
|
||||
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
|
||||
|
|
|
|||
95
ui/src/components/MarkdownBody.interaction.test.tsx
Normal file
95
ui/src/components/MarkdownBody.interaction.test.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
flushSync(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function renderMarkdown(children: string) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function click(element: Element | null) {
|
||||
if (!element) throw new Error("Expected element to exist");
|
||||
flushSync(() => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("MarkdownBody code block interactions", () => {
|
||||
it("toggles line wrapping for indented preformatted markdown blocks", () => {
|
||||
const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
|
||||
const pre = node.querySelector("pre");
|
||||
const wrapButton = node.querySelector<HTMLButtonElement>(".paperclip-markdown-codeblock-wrap");
|
||||
|
||||
expect(pre?.style.whiteSpace).toBe("");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
|
||||
click(wrapButton);
|
||||
|
||||
expect(pre?.style.whiteSpace).toBe("pre-wrap");
|
||||
expect(pre?.style.overflowWrap).toBe("anywhere");
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
|
||||
|
||||
click(wrapButton);
|
||||
|
||||
expect(pre?.style.whiteSpace).toBe("");
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, Github } from "lucide-react";
|
||||
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -364,6 +364,7 @@ function CodeBlock({
|
|||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [wrapLines, setWrapLines] = useState(false);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
|
|
@ -401,33 +402,57 @@ function CodeBlock({
|
|||
}, 1500);
|
||||
}, [children]);
|
||||
|
||||
const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
|
||||
const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
|
||||
const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines";
|
||||
|
||||
return (
|
||||
<div className="paperclip-markdown-codeblock">
|
||||
<div className="paperclip-markdown-codeblock" data-wrap-lines={wrapLines || undefined}>
|
||||
<pre
|
||||
{...preProps}
|
||||
ref={preRef}
|
||||
style={mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined)}
|
||||
style={{
|
||||
...mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined),
|
||||
...(wrapLines
|
||||
? {
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "break-word",
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy code"
|
||||
title={label}
|
||||
className="paperclip-markdown-codeblock-copy"
|
||||
data-copied={copied || undefined}
|
||||
data-failed={failed || undefined}
|
||||
>
|
||||
{copied && !failed ? (
|
||||
<Check aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="paperclip-markdown-codeblock-copy-label">{label}</span>
|
||||
</button>
|
||||
<div className="paperclip-markdown-codeblock-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWrapLines((value) => !value)}
|
||||
aria-label={wrapLabel}
|
||||
title={wrapLabel}
|
||||
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
|
||||
aria-pressed={wrapLines}
|
||||
data-active={wrapLines || undefined}
|
||||
>
|
||||
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<span className="paperclip-markdown-codeblock-action-label">{wrapLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy code"
|
||||
title={copyLabel}
|
||||
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-copy"
|
||||
data-copied={copied || undefined}
|
||||
data-failed={failed || undefined}
|
||||
>
|
||||
{copied && !failed ? (
|
||||
<Check aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="paperclip-markdown-codeblock-action-label">{copyLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,27 +60,29 @@ describe("CompanySettingsNav", () => {
|
|||
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
|
||||
});
|
||||
|
||||
it("renders the active tab and navigates when a different tab is selected", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
currentPathname = "/PAP/company/settings/members";
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CompanySettingsNav />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("access");
|
||||
expect(container.textContent).toContain("members");
|
||||
expect(pageTabBarMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: "access",
|
||||
value: "members",
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "environments", label: "Environments" },
|
||||
{ value: "access", label: "Access" },
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router";
|
|||
const items = [
|
||||
{ value: "general", label: "General", href: "/company/settings" },
|
||||
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
|
||||
{ value: "access", label: "Access", href: "/company/settings/access" },
|
||||
{ value: "members", label: "Members", href: "/company/settings/members" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
] as const;
|
||||
|
||||
|
|
@ -16,8 +16,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
|||
return "environments";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/access")) {
|
||||
return "access";
|
||||
if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) {
|
||||
return "members";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/invites")) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings";
|
|||
import { heartbeatsApi } from "../../api/heartbeats";
|
||||
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
|
||||
|
||||
const LOG_POLL_INTERVAL_MS = 2000;
|
||||
const LOG_READ_LIMIT_BYTES = 256_000;
|
||||
|
|
@ -279,8 +280,9 @@ export function useLiveRunTranscripts({
|
|||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
|
||||
const url = buildSameOriginWebSocketUrl(
|
||||
`/api/companies/${encodeURIComponent(companyId)}/events/ws`,
|
||||
);
|
||||
socket = new WebSocket(url);
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-iss
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import { useLocation } from "../lib/router";
|
||||
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
|
||||
|
||||
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||
const TOAST_COOLDOWN_MAX = 3;
|
||||
|
|
@ -979,8 +980,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`;
|
||||
const url = buildSameOriginWebSocketUrl(
|
||||
`/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`,
|
||||
);
|
||||
const nextSocket = new WebSocket(url);
|
||||
socket = nextSocket;
|
||||
|
||||
|
|
|
|||
|
|
@ -717,15 +717,23 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
|||
background: none;
|
||||
}
|
||||
|
||||
/* Copy-to-clipboard button on fenced code blocks */
|
||||
/* Actions for fenced and indented preformatted markdown blocks */
|
||||
.paperclip-markdown-codeblock {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy {
|
||||
.paperclip-markdown-codeblock-actions {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
|
@ -737,30 +745,31 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
|||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy,
|
||||
.paperclip-markdown-codeblock-copy:focus-visible,
|
||||
.paperclip-markdown-codeblock-copy[data-copied] {
|
||||
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
|
||||
.paperclip-markdown-codeblock-actions:focus-within,
|
||||
.paperclip-markdown-codeblock-action[data-copied],
|
||||
.paperclip-markdown-codeblock-action[data-active] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy:hover {
|
||||
.paperclip-markdown-codeblock-action:hover {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy[data-copied] {
|
||||
.paperclip-markdown-codeblock-action[data-active],
|
||||
.paperclip-markdown-codeblock-action[data-copied] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy[data-failed] {
|
||||
.paperclip-markdown-codeblock-action[data-failed] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy-label {
|
||||
.paperclip-markdown-codeblock-action-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
|
|||
51
ui/src/lib/websocket-url.test.ts
Normal file
51
ui/src/lib/websocket-url.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { browserReachableHost, buildSameOriginWebSocketUrl } from "./websocket-url";
|
||||
|
||||
describe("browserReachableHost", () => {
|
||||
it("keeps concrete browser hosts unchanged", () => {
|
||||
expect(browserReachableHost({
|
||||
protocol: "http:",
|
||||
hostname: "paperclip-dev",
|
||||
host: "paperclip-dev:46259",
|
||||
port: "46259",
|
||||
})).toBe("paperclip-dev:46259");
|
||||
});
|
||||
|
||||
it("rewrites wildcard IPv4 bind hosts to localhost", () => {
|
||||
expect(browserReachableHost({
|
||||
protocol: "http:",
|
||||
hostname: "0.0.0.0",
|
||||
host: "0.0.0.0:46259",
|
||||
port: "46259",
|
||||
})).toBe("localhost:46259");
|
||||
});
|
||||
|
||||
it("rewrites wildcard IPv6 bind hosts to localhost", () => {
|
||||
expect(browserReachableHost({
|
||||
protocol: "http:",
|
||||
hostname: "::",
|
||||
host: "[::]:46259",
|
||||
port: "46259",
|
||||
})).toBe("localhost:46259");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSameOriginWebSocketUrl", () => {
|
||||
it("uses wss for https pages", () => {
|
||||
expect(buildSameOriginWebSocketUrl("/api/events/ws", {
|
||||
protocol: "https:",
|
||||
hostname: "example.com",
|
||||
host: "example.com",
|
||||
port: "",
|
||||
})).toBe("wss://example.com/api/events/ws");
|
||||
});
|
||||
|
||||
it("does not emit 0.0.0.0 websocket URLs", () => {
|
||||
expect(buildSameOriginWebSocketUrl("api/events/ws", {
|
||||
protocol: "http:",
|
||||
hostname: "0.0.0.0",
|
||||
host: "0.0.0.0:46259",
|
||||
port: "46259",
|
||||
})).toBe("ws://localhost:46259/api/events/ws");
|
||||
});
|
||||
});
|
||||
20
ui/src/lib/websocket-url.ts
Normal file
20
ui/src/lib/websocket-url.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
type BrowserLocationLike = Pick<Location, "host" | "hostname" | "port" | "protocol">;
|
||||
|
||||
function isWildcardHost(hostname: string): boolean {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]";
|
||||
}
|
||||
|
||||
export function browserReachableHost(location: BrowserLocationLike = window.location): string {
|
||||
if (!isWildcardHost(location.hostname)) return location.host;
|
||||
return location.port ? `localhost:${location.port}` : "localhost";
|
||||
}
|
||||
|
||||
export function buildSameOriginWebSocketUrl(
|
||||
path: string,
|
||||
location: BrowserLocationLike = window.location,
|
||||
): string {
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${protocol}://${browserReachableHost(location)}${normalizedPath}`;
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
|
|||
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
|
||||
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
|
||||
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
|
||||
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { describeRunRetryState } from "../lib/runRetryState";
|
||||
|
|
@ -1713,7 +1714,9 @@ function ConfigurationTab({
|
|||
? "Enabled automatically while this agent can create new agents."
|
||||
: taskAssignSource === "explicit_grant"
|
||||
? "Enabled via explicit company permission grant."
|
||||
: "Disabled unless explicitly granted.";
|
||||
: taskAssignSource === "simple_default"
|
||||
? "Enabled by simple company-wide task assignment defaults."
|
||||
: "Disabled unless explicitly granted.";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -3863,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
|
||||
const url = buildSameOriginWebSocketUrl(
|
||||
`/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
|
||||
);
|
||||
socket = new WebSocket(url);
|
||||
|
||||
socket.onopen = () => {
|
||||
|
|
|
|||
|
|
@ -4,23 +4,25 @@ 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 { CompanyAccess } from "./CompanyAccess";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess";
|
||||
|
||||
const listMembersMock = vi.hoisted(() => vi.fn());
|
||||
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const updateMemberAccessMock = vi.hoisted(() => vi.fn());
|
||||
const updateMemberMock = vi.hoisted(() => vi.fn());
|
||||
const archiveMemberMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const listIssuesMock = vi.hoisted(() => vi.fn());
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
listMembers: (companyId: string) => listMembersMock(companyId),
|
||||
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status),
|
||||
updateMember: vi.fn(),
|
||||
updateMember: (companyId: string, memberId: string, input: unknown) =>
|
||||
updateMemberMock(companyId, memberId, input),
|
||||
updateMemberPermissions: vi.fn(),
|
||||
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
|
||||
updateMemberAccessMock(companyId, memberId, input),
|
||||
updateMemberAccess: vi.fn(),
|
||||
archiveMember: (companyId: string, memberId: string, input: unknown) =>
|
||||
archiveMemberMock(companyId, memberId, input),
|
||||
approveJoinRequest: vi.fn(),
|
||||
|
|
@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
|
||||
mockNavigate(to, replace);
|
||||
return <div data-testid="navigate">{to}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
|
|
@ -146,7 +160,7 @@ describe("CompanyAccess", () => {
|
|||
},
|
||||
},
|
||||
]);
|
||||
updateMemberAccessMock.mockResolvedValue({});
|
||||
updateMemberMock.mockResolvedValue({});
|
||||
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
|
||||
listAgentsMock.mockResolvedValue([
|
||||
{
|
||||
|
|
@ -164,6 +178,11 @@ describe("CompanyAccess", () => {
|
|||
status: "todo",
|
||||
},
|
||||
]);
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -172,7 +191,7 @@ describe("CompanyAccess", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the page human-focused and explains implicit versus explicit grants", async () => {
|
||||
it("keeps the page human-focused and hides advanced permission controls", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -188,10 +207,15 @@ describe("CompanyAccess", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Manage company user memberships");
|
||||
expect(container.textContent).toContain("Manage the people who can work in Paperclip");
|
||||
expect(container.textContent).toContain("Members can collaborate across the company by default");
|
||||
expect(container.textContent).toContain("Core keeps this page focused on membership");
|
||||
expect(container.textContent).toContain("Humans");
|
||||
expect(container.textContent).toContain("Pending human joins");
|
||||
expect(container.textContent).toContain("User account");
|
||||
expect(container.textContent).not.toContain("Grants");
|
||||
expect(container.textContent).not.toContain("explicit grants");
|
||||
expect(container.textContent).not.toContain("Assign scoped tasks");
|
||||
expect(container.textContent).not.toContain("Agents");
|
||||
expect(container.textContent).not.toContain("Pending agent joins");
|
||||
expect(container.textContent).not.toContain("Open join request queue");
|
||||
|
|
@ -210,18 +234,16 @@ describe("CompanyAccess", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Implicit grants from role");
|
||||
expect(document.body.textContent).toContain("Owner currently includes these permissions automatically.");
|
||||
expect(document.body.textContent).toContain(
|
||||
"Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.",
|
||||
);
|
||||
expect(document.body.textContent).toContain("Update company role and membership status");
|
||||
expect(document.body.textContent).not.toContain("Implicit grants from role");
|
||||
expect(document.body.textContent).not.toContain("permissionKey");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves member role, status, and grants in one request", async () => {
|
||||
it("saves member role and status without touching grants", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -248,7 +270,7 @@ describe("CompanyAccess", () => {
|
|||
await flushReact();
|
||||
|
||||
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Save access",
|
||||
(button) => button.textContent === "Save member",
|
||||
);
|
||||
expect(saveButton).toBeTruthy();
|
||||
|
||||
|
|
@ -257,10 +279,9 @@ describe("CompanyAccess", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", {
|
||||
expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
|
||||
membershipRole: "owner",
|
||||
status: "active",
|
||||
grants: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -382,4 +403,65 @@ describe("CompanyAccess", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects legacy access deep links to the permissions extension route when installed", async () => {
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: "permissions",
|
||||
displayName: "Permissions",
|
||||
routePath: "permissions",
|
||||
pluginKey: "permissions-extension",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyAccessLegacyRoute />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/company/settings/permissions", true);
|
||||
expect(container.textContent).toContain("/company/settings/permissions");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a read-only unavailable fallback for legacy access deep links", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyAccessLegacyRoute />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Advanced Permissions");
|
||||
expect(container.textContent).toContain("Advanced permissions unavailable");
|
||||
expect(container.textContent).toContain("Open Members");
|
||||
expect(container.textContent).toContain("Open Invites");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
PERMISSION_KEYS,
|
||||
type Agent,
|
||||
type PermissionKey,
|
||||
} from "@paperclipai/shared";
|
||||
import { ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { Shield, ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { accessApi, type CompanyMember } from "@/api/access";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -25,38 +22,13 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Link, Navigate } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const permissionLabels: Record<PermissionKey, string> = {
|
||||
"agents:create": "Create agents",
|
||||
"users:invite": "Invite humans and agents",
|
||||
"users:manage_permissions": "Manage members and grants",
|
||||
"tasks:assign": "Assign tasks",
|
||||
"tasks:assign_scope": "Assign scoped tasks",
|
||||
"tasks:manage_active_checkouts": "Manage active task checkouts",
|
||||
"joins:approve": "Approve join requests",
|
||||
"environments:manage": "Manage environments",
|
||||
};
|
||||
|
||||
function formatGrantSummary(member: CompanyMember) {
|
||||
if (member.grants.length === 0) return "No explicit grants";
|
||||
return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", ");
|
||||
}
|
||||
|
||||
const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>, PermissionKey[]> = {
|
||||
owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"],
|
||||
admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"],
|
||||
operator: ["tasks:assign"],
|
||||
viewer: [],
|
||||
};
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
|
||||
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
|
||||
type EditableMemberStatus = "pending" | "active" | "suspended";
|
||||
|
||||
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
|
||||
return role ? implicitRoleGrantMap[role] : [];
|
||||
}
|
||||
|
||||
export function CompanyAccess() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
|
@ -67,13 +39,12 @@ export function CompanyAccess() {
|
|||
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
|
||||
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
|
||||
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
|
||||
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Access" },
|
||||
{ label: "Members" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
|
|
@ -103,11 +74,10 @@ export function CompanyAccess() {
|
|||
};
|
||||
|
||||
const updateMemberMutation = useMutation({
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => {
|
||||
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => {
|
||||
return accessApi.updateMember(selectedCompanyId!, input.memberId, {
|
||||
membershipRole: input.membershipRole,
|
||||
status: input.status,
|
||||
grants: input.grants.map((permissionKey) => ({ permissionKey })),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
|
|
@ -223,7 +193,6 @@ export function CompanyAccess() {
|
|||
if (!editingMember) return;
|
||||
setDraftRole(editingMember.membershipRole);
|
||||
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
|
||||
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
|
||||
}, [editingMember]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -255,8 +224,6 @@ export function CompanyAccess() {
|
|||
joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? [];
|
||||
const joinRequestActionPending =
|
||||
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
|
||||
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
|
||||
const implicitGrantSet = new Set(implicitGrantKeys);
|
||||
const activeReassignmentUsers = members.filter(
|
||||
(member) =>
|
||||
member.status === "active" &&
|
||||
|
|
@ -271,11 +238,14 @@ export function CompanyAccess() {
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Access</h1>
|
||||
<h1 className="text-lg font-semibold">Company Members</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}.
|
||||
Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.
|
||||
</p>
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Core keeps this page focused on membership, invite approvals, and safe member removal.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{access && !access.currentUserRole && (
|
||||
|
|
@ -291,7 +261,7 @@ export function CompanyAccess() {
|
|||
<h2 className="text-base font-semibold">Humans</h2>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage human company memberships, status, and grants here.
|
||||
Manage human company memberships and status here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -340,11 +310,10 @@ export function CompanyAccess() {
|
|||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div>User account</div>
|
||||
<div>Role</div>
|
||||
<div>Status</div>
|
||||
<div>Grants</div>
|
||||
<div className="text-right">Action</div>
|
||||
</div>
|
||||
{members.length === 0 ? (
|
||||
|
|
@ -356,7 +325,7 @@ export function CompanyAccess() {
|
|||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
||||
|
|
@ -372,7 +341,6 @@ export function CompanyAccess() {
|
|||
{member.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
|
|
@ -405,7 +373,7 @@ export function CompanyAccess() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Edit member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
|
||||
Update company role and membership status for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingMember && (
|
||||
|
|
@ -443,66 +411,6 @@ export function CompanyAccess() {
|
|||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Grants</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border px-3 py-3">
|
||||
<div className="text-sm font-medium">Implicit grants from role</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{draftRole
|
||||
? `${HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole]} currently includes these permissions automatically.`
|
||||
: "No role is selected, so this member has no implicit grants right now."}
|
||||
</p>
|
||||
{implicitGrantKeys.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{implicitGrantKeys.map((permissionKey) => (
|
||||
<Badge key={permissionKey} variant="outline">
|
||||
{permissionLabels[permissionKey]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{PERMISSION_KEYS.map((permissionKey) => (
|
||||
<label
|
||||
key={permissionKey}
|
||||
className="flex items-start gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={draftGrants.has(permissionKey)}
|
||||
onCheckedChange={(checked) => {
|
||||
setDraftGrants((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(permissionKey);
|
||||
else next.delete(permissionKey);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium">{permissionLabels[permissionKey]}</span>
|
||||
<span className="block text-xs text-muted-foreground">{permissionKey}</span>
|
||||
{implicitGrantSet.has(permissionKey) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Included implicitly by the {draftRole ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole] : "selected"} role. Add an explicit grant only if it should stay after the role changes.
|
||||
</span>
|
||||
) : null}
|
||||
{draftGrants.has(permissionKey) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Stored explicitly for this member.
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
|
|
@ -516,12 +424,11 @@ export function CompanyAccess() {
|
|||
memberId: editingMember.id,
|
||||
membershipRole: draftRole,
|
||||
status: draftStatus,
|
||||
grants: [...draftGrants],
|
||||
});
|
||||
}}
|
||||
disabled={updateMemberMutation.isPending}
|
||||
>
|
||||
{updateMemberMutation.isPending ? "Saving…" : "Save access"}
|
||||
{updateMemberMutation.isPending ? "Saving…" : "Save member"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -616,6 +523,66 @@ export function CompanyAccess() {
|
|||
);
|
||||
}
|
||||
|
||||
export function CompanyAccessLegacyRoute() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { slots, isLoading, errorMessage } = usePluginSlots({
|
||||
slotTypes: ["companySettingsPage"],
|
||||
companyId: selectedCompanyId,
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Access" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const permissionsSlot = slots.find((slot) => slot.routePath === "permissions");
|
||||
if (permissionsSlot) {
|
||||
return <Navigate to="/company/settings/permissions" replace />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Checking for advanced permission extensions...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<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">Advanced Permissions</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Advanced access, scoped assignment, and explicit grant controls are provided by installed company settings extensions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-border px-5 py-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">Advanced permissions unavailable</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires an installed extension.
|
||||
</p>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">Plugin extensions unavailable: {errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to="/company/settings/members">Open Members</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/company/settings/invites">Open Invites</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function memberDisplayName(member: CompanyMember | null) {
|
||||
if (!member) return "this member";
|
||||
return member.user?.name?.trim() || member.user?.email || member.principalId;
|
||||
|
|
|
|||
|
|
@ -152,7 +152,8 @@ describe("CompanyInvites", () => {
|
|||
expect(container.textContent).toContain("Choose a role");
|
||||
expect(container.textContent).toContain("Each invite link is single-use.");
|
||||
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
|
||||
expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants.");
|
||||
expect(container.textContent).toContain("Everything in Admin, plus managing members.");
|
||||
expect(container.textContent).not.toContain("permission grants");
|
||||
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
|
||||
|
||||
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ const inviteRoleOptions = [
|
|||
{
|
||||
value: "viewer",
|
||||
label: "Viewer",
|
||||
description: "Can view company work and follow along without operational permissions.",
|
||||
gets: "No built-in grants.",
|
||||
description: "Can view company work and follow along.",
|
||||
gets: "View-only company membership.",
|
||||
},
|
||||
{
|
||||
value: "operator",
|
||||
|
|
@ -32,8 +32,8 @@ const inviteRoleOptions = [
|
|||
{
|
||||
value: "owner",
|
||||
label: "Owner",
|
||||
description: "Full company access, including membership and permission management.",
|
||||
gets: "Everything in Admin, plus managing members and permission grants.",
|
||||
description: "Full company access, including membership management.",
|
||||
gets: "Everything in Admin, plus managing members.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
140
ui/src/pages/CompanySettingsPluginPage.test.tsx
Normal file
140
ui/src/pages/CompanySettingsPluginPage.test.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// @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 { CompanySettingsPluginPage } from "./CompanySettingsPluginPage";
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockParams = vi.hoisted(() => ({
|
||||
companyPrefix: "PAP" as string | undefined,
|
||||
settingsRoutePath: "permissions" as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
useLocation: () => ({ pathname: "/PAP/company/settings/permissions", search: "", hash: "" }),
|
||||
useParams: () => mockParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
PluginSlotMount: ({
|
||||
slot,
|
||||
context,
|
||||
}: {
|
||||
slot: { displayName: string };
|
||||
context: { companyId: string | null; companyPrefix: string | null };
|
||||
}) => (
|
||||
<div data-testid="plugin-slot-mount">
|
||||
{slot.displayName}:{context.companyId}:{context.companyPrefix}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPage(container: HTMLDivElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsPluginPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("CompanySettingsPluginPage", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockParams.companyPrefix = "PAP";
|
||||
mockParams.settingsRoutePath = "permissions";
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: "permissions",
|
||||
displayName: "Permissions",
|
||||
exportName: "PermissionsPage",
|
||||
routePath: "permissions",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "permissions-extension",
|
||||
pluginDisplayName: "Permissions Extension",
|
||||
pluginVersion: "0.1.0",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("mounts the matching company settings slot with company context", async () => {
|
||||
const root = await renderPage(container);
|
||||
|
||||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe(
|
||||
"Permissions:company-1:PAP",
|
||||
);
|
||||
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Permissions" },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when no ready plugin declares the route", async () => {
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
const root = await renderPage(container);
|
||||
|
||||
expect(container.textContent).toContain("Page not found");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
88
ui/src/pages/CompanySettingsPluginPage.tsx
Normal file
88
ui/src/pages/CompanySettingsPluginPage.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useParams } from "@/lib/router";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import { NotFoundPage } from "./NotFound";
|
||||
|
||||
export function CompanySettingsPluginPage() {
|
||||
const params = useParams<{
|
||||
companyPrefix?: string;
|
||||
settingsRoutePath?: string;
|
||||
}>();
|
||||
const { companyPrefix: routeCompanyPrefix, settingsRoutePath } = params;
|
||||
const { companies, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const routeCompany = useMemo(() => {
|
||||
if (!routeCompanyPrefix) return null;
|
||||
const requested = routeCompanyPrefix.toUpperCase();
|
||||
return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null;
|
||||
}, [companies, routeCompanyPrefix]);
|
||||
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
|
||||
const resolvedCompanyId = routeCompany?.id ?? (routeCompanyPrefix ? null : selectedCompanyId ?? null);
|
||||
const companyPrefix = resolvedCompanyId
|
||||
? companies.find((company) => company.id === resolvedCompanyId)?.issuePrefix ?? null
|
||||
: null;
|
||||
|
||||
const { slots, isLoading, errorMessage } = usePluginSlots({
|
||||
slotTypes: ["companySettingsPage"],
|
||||
companyId: resolvedCompanyId,
|
||||
enabled: Boolean(resolvedCompanyId && settingsRoutePath),
|
||||
});
|
||||
|
||||
const pageSlots = useMemo(() => {
|
||||
if (!settingsRoutePath) return [];
|
||||
return slots.filter((slot) => slot.routePath === settingsRoutePath);
|
||||
}, [settingsRoutePath, slots]);
|
||||
|
||||
const pageSlot = pageSlots.length === 1 ? pageSlots[0] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageSlot) return;
|
||||
setBreadcrumbs([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: pageSlot.displayName },
|
||||
]);
|
||||
}, [pageSlot, setBreadcrumbs]);
|
||||
|
||||
if (!resolvedCompanyId) {
|
||||
if (hasInvalidCompanyPrefix) {
|
||||
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
|
||||
}
|
||||
return <div className="text-sm text-muted-foreground">Select a company to view this page.</div>;
|
||||
}
|
||||
|
||||
if (!settingsRoutePath || isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
Plugin extensions unavailable: {errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageSlots.length > 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
Multiple plugins declare the company settings route <code>{settingsRoutePath}</code>. Disable one plugin or change its route.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageSlot) {
|
||||
return <NotFoundPage scope="board" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginSlotMount
|
||||
slot={pageSlot}
|
||||
context={{ companyId: resolvedCompanyId, companyPrefix }}
|
||||
className="min-h-[200px]"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -403,16 +403,16 @@ describe("InviteLandingPage", () => {
|
|||
expect(container.textContent).toContain("Request to join Acme Robotics");
|
||||
expect(container.textContent).toContain("A company admin must approve your request to join.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Access to approve your request.",
|
||||
"Ask them to visit Company Settings → Members to approve your request.",
|
||||
);
|
||||
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("http://localhost/company/settings/access");
|
||||
expect(container.textContent).not.toContain("http://localhost/company/settings/members");
|
||||
|
||||
const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
|
||||
(link) => link.textContent === "Company Settings → Access",
|
||||
(link) => link.textContent === "Company Settings → Members",
|
||||
);
|
||||
expect(approvalLinks).toHaveLength(2);
|
||||
const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
|
||||
const expectedApprovalUrl = `${window.location.origin}/company/settings/members`;
|
||||
for (const link of approvalLinks) {
|
||||
expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
|
||||
}
|
||||
|
|
@ -471,7 +471,7 @@ describe("InviteLandingPage", () => {
|
|||
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Your request is still awaiting approval.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Access to approve your request.",
|
||||
"Ask them to visit Company Settings → Members to approve your request.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({
|
|||
claimApiKeyPath = null,
|
||||
onboardingTextUrl = null,
|
||||
}: AwaitingJoinApprovalPanelProps) {
|
||||
const approvalUrl = `${window.location.origin}/company/settings/access`;
|
||||
const approvalUrl = `${window.location.origin}/company/settings/members`;
|
||||
const approverLabel = invitedByUserName ?? "A company admin";
|
||||
|
||||
return (
|
||||
|
|
@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({
|
|||
href={approvalUrl}
|
||||
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
|
||||
>
|
||||
Company Settings → Access
|
||||
Company Settings → Members
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings → Access</a> to approve your request.
|
||||
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings → Members</a> to approve your request.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Refresh this page after you've been approved — you'll be redirected automatically.
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ const inviteRoleOptions = [
|
|||
{
|
||||
value: "viewer",
|
||||
label: "Viewer",
|
||||
description: "Can view company work and follow along without operational permissions.",
|
||||
gets: "No built-in grants.",
|
||||
description: "Can view company work and follow along.",
|
||||
gets: "View-only company membership.",
|
||||
},
|
||||
{
|
||||
value: "operator",
|
||||
|
|
@ -41,8 +41,8 @@ const inviteRoleOptions = [
|
|||
{
|
||||
value: "owner",
|
||||
label: "Owner",
|
||||
description: "Full company access, including membership and permission management.",
|
||||
gets: "Everything in Admin, plus managing members and permission grants.",
|
||||
description: "Full company access, including membership management.",
|
||||
gets: "Everything in Admin, plus managing members.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
@ -423,8 +423,8 @@ function InviteResultPreview({
|
|||
<>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<p className="mb-1 text-xs text-zinc-500">Approval page</p>
|
||||
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/access">
|
||||
Company Settings → Access
|
||||
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/members">
|
||||
Company Settings → Members
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
|
|
@ -897,7 +897,7 @@ export function InviteUxLab() {
|
|||
/>
|
||||
<InviteResultPreview
|
||||
title="Request to join Acme Robotics"
|
||||
description="Ask them to visit Company Settings → Access to approve your request."
|
||||
description="Ask them to visit Company Settings → Members to approve your request."
|
||||
/>
|
||||
</div>
|
||||
</LabSection>
|
||||
|
|
|
|||
|
|
@ -617,6 +617,7 @@ type IssueDetailChatTabProps = {
|
|||
blockedBy: Issue["blockedBy"];
|
||||
blockerAttention: Issue["blockerAttention"] | null;
|
||||
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
|
||||
scheduledRetry: Issue["scheduledRetry"] | null;
|
||||
recoveryAction: Issue["activeRecoveryAction"];
|
||||
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
|
||||
canFalsePositiveRecoveryAction?: boolean;
|
||||
|
|
@ -689,6 +690,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
blockedBy,
|
||||
blockerAttention,
|
||||
successfulRunHandoff,
|
||||
scheduledRetry,
|
||||
recoveryAction,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction,
|
||||
|
|
@ -897,9 +899,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
timelineEvents={timelineEvents}
|
||||
liveRuns={resolvedLiveRuns}
|
||||
activeRun={resolvedActiveRun}
|
||||
issueId={issueId}
|
||||
blockedBy={blockedBy ?? []}
|
||||
blockerAttention={blockerAttention}
|
||||
successfulRunHandoff={successfulRunHandoff}
|
||||
scheduledRetry={scheduledRetry}
|
||||
recoveryAction={recoveryAction ?? null}
|
||||
onResolveRecoveryAction={onResolveRecoveryAction}
|
||||
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
|
||||
|
|
@ -3914,6 +3918,7 @@ export function IssueDetail() {
|
|||
blockedBy={issue.blockedBy ?? []}
|
||||
blockerAttention={issue.blockerAttention ?? null}
|
||||
successfulRunHandoff={issue.successfulRunHandoff ?? null}
|
||||
scheduledRetry={issue.scheduledRetry ?? null}
|
||||
recoveryAction={issue.activeRecoveryAction ?? null}
|
||||
onResolveRecoveryAction={handleResolveRecoveryAction}
|
||||
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./bridge.js";
|
||||
import { createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { Component, createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { User } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -524,6 +524,127 @@ function FragmentSafe({ children }: { children?: ReactNode }) {
|
|||
return createElement("span", { className: "contents" }, children);
|
||||
}
|
||||
|
||||
type PluginStatusBadgeProps = {
|
||||
label: string;
|
||||
status: "ok" | "warning" | "error" | "info" | "pending";
|
||||
};
|
||||
|
||||
function PluginSdkStatusBadge({ label, status }: PluginStatusBadgeProps) {
|
||||
const className = {
|
||||
ok: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
warning: "border-amber-300 bg-amber-50 text-amber-800",
|
||||
error: "border-red-300 bg-red-50 text-red-700",
|
||||
info: "border-slate-300 bg-slate-50 text-slate-700",
|
||||
pending: "border-slate-300 bg-slate-50 text-slate-600",
|
||||
}[status];
|
||||
return createElement(
|
||||
"span",
|
||||
{ className: `inline-flex w-fit items-center rounded-full border px-2 py-0.5 text-xs font-medium ${className}` },
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
type PluginDataTableColumn = {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (value: unknown, row: Record<string, unknown>) => ReactNode;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
type PluginDataTableProps = {
|
||||
columns: PluginDataTableColumn[];
|
||||
rows: Array<Record<string, unknown> & { id?: string }>;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
function PluginSdkDataTable({ columns, rows, loading, emptyMessage = "No rows." }: PluginDataTableProps) {
|
||||
if (loading) return createElement("div", { className: "text-sm text-muted-foreground" }, "Loading...");
|
||||
if (!rows.length) return createElement("div", { className: "text-sm text-muted-foreground" }, emptyMessage);
|
||||
const gridColumns = columns.map((column) => column.width ?? "minmax(0, 1fr)").join(" ");
|
||||
return createElement(
|
||||
"div",
|
||||
{ className: "overflow-hidden rounded-md border" },
|
||||
createElement(
|
||||
"div",
|
||||
{
|
||||
className: "hidden border-b bg-muted/35 px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground md:grid md:[grid-template-columns:var(--plugin-grid-cols)]",
|
||||
style: { "--plugin-grid-cols": gridColumns },
|
||||
},
|
||||
columns.map((column) => createElement("div", { key: column.key }, column.header)),
|
||||
),
|
||||
createElement(
|
||||
"div",
|
||||
{ className: "divide-y" },
|
||||
rows.map((row, index) => createElement(
|
||||
"div",
|
||||
{
|
||||
key: String(row.id ?? index),
|
||||
className: "grid gap-2 px-3 py-3 md:items-center md:[grid-template-columns:var(--plugin-grid-cols)]",
|
||||
style: { "--plugin-grid-cols": gridColumns },
|
||||
},
|
||||
columns.map((column) => createElement(
|
||||
"div",
|
||||
{ key: column.key, className: "min-w-0 text-sm" },
|
||||
createElement("div", { className: "mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground md:hidden" }, column.header),
|
||||
column.render ? column.render(row[column.key], row) : String(row[column.key] ?? ""),
|
||||
)),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type PluginKeyValueListProps = {
|
||||
pairs: Array<{ label: string; value: ReactNode }>;
|
||||
};
|
||||
|
||||
function PluginSdkKeyValueList({ pairs }: PluginKeyValueListProps) {
|
||||
return createElement(
|
||||
"dl",
|
||||
{ className: "grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]" },
|
||||
pairs.flatMap((pair) => [
|
||||
createElement("dt", { key: `${pair.label}:label`, className: "text-muted-foreground" }, pair.label),
|
||||
createElement("dd", { key: `${pair.label}:value`, className: "min-w-0" }, pair.value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSdkMetricCard({ label, value, unit }: { label: string; value: string | number; unit?: string }) {
|
||||
return createElement(
|
||||
"div",
|
||||
{ className: "rounded-md border bg-card p-3" },
|
||||
createElement("div", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground" }, label),
|
||||
createElement("div", { className: "mt-1 text-lg font-semibold" }, `${value}${unit ?? ""}`),
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSdkJsonTree({ data }: { data: unknown }) {
|
||||
return createElement("pre", { className: "max-h-80 overflow-auto rounded-md border bg-muted/30 p-2 text-xs" }, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function PluginSdkSpinner({ label = "Loading" }: { size?: "sm" | "md" | "lg"; label?: string }) {
|
||||
return createElement("span", {
|
||||
className: "inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground align-middle",
|
||||
role: "status",
|
||||
"aria-label": label,
|
||||
});
|
||||
}
|
||||
|
||||
class PluginSdkErrorBoundary extends Component<{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean }> {
|
||||
override state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? createElement("div", { className: "rounded-md border border-destructive/30 p-3 text-sm text-destructive" }, "Plugin UI failed to render.");
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin bridge global registry.
|
||||
*
|
||||
|
|
@ -564,6 +685,13 @@ export function initPluginBridge(
|
|||
resolveWikiLinkHref,
|
||||
children: content,
|
||||
}),
|
||||
MetricCard: PluginSdkMetricCard,
|
||||
StatusBadge: PluginSdkStatusBadge,
|
||||
DataTable: PluginSdkDataTable,
|
||||
KeyValueList: PluginSdkKeyValueList,
|
||||
JsonTree: PluginSdkJsonTree,
|
||||
Spinner: PluginSdkSpinner,
|
||||
ErrorBoundary: PluginSdkErrorBoundary,
|
||||
MarkdownEditor: PluginSdkMarkdownEditor,
|
||||
FileTree: PluginSdkFileTree,
|
||||
IssuesList: PluginSdkIssuesList,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue