541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { companiesApi } from "../api/companies";
|
|
import { accessApi } from "../api/access";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Settings, Check } from "lucide-react";
|
|
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
|
import {
|
|
Field,
|
|
ToggleField,
|
|
HintIcon
|
|
} from "../components/agent-config-primitives";
|
|
|
|
type AgentSnippetInput = {
|
|
onboardingTextUrl: string;
|
|
connectionCandidates?: string[] | null;
|
|
testResolutionUrl?: string | null;
|
|
};
|
|
|
|
export function CompanySettings() {
|
|
const {
|
|
companies,
|
|
selectedCompany,
|
|
selectedCompanyId,
|
|
setSelectedCompanyId
|
|
} = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const queryClient = useQueryClient();
|
|
|
|
// General settings local state
|
|
const [companyName, setCompanyName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [brandColor, setBrandColor] = useState("");
|
|
|
|
// Sync local state from selected company
|
|
useEffect(() => {
|
|
if (!selectedCompany) return;
|
|
setCompanyName(selectedCompany.name);
|
|
setDescription(selectedCompany.description ?? "");
|
|
setBrandColor(selectedCompany.brandColor ?? "");
|
|
}, [selectedCompany]);
|
|
|
|
const [inviteError, setInviteError] = useState<string | null>(null);
|
|
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
|
|
const [snippetCopied, setSnippetCopied] = useState(false);
|
|
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
|
|
|
const generalDirty =
|
|
!!selectedCompany &&
|
|
(companyName !== selectedCompany.name ||
|
|
description !== (selectedCompany.description ?? "") ||
|
|
brandColor !== (selectedCompany.brandColor ?? ""));
|
|
|
|
const generalMutation = useMutation({
|
|
mutationFn: (data: {
|
|
name: string;
|
|
description: string | null;
|
|
brandColor: string | null;
|
|
}) => companiesApi.update(selectedCompanyId!, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
}
|
|
});
|
|
|
|
const settingsMutation = useMutation({
|
|
mutationFn: (requireApproval: boolean) =>
|
|
companiesApi.update(selectedCompanyId!, {
|
|
requireBoardApprovalForNewAgents: requireApproval
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
}
|
|
});
|
|
|
|
const inviteMutation = useMutation({
|
|
mutationFn: () =>
|
|
accessApi.createCompanyInvite(selectedCompanyId!, {
|
|
allowedJoinTypes: "agent",
|
|
expiresInHours: 72
|
|
}),
|
|
onSuccess: async (invite) => {
|
|
setInviteError(null);
|
|
const base = window.location.origin.replace(/\/+$/, "");
|
|
const onboardingTextLink =
|
|
invite.onboardingTextUrl ??
|
|
invite.onboardingTextPath ??
|
|
`/api/invites/${invite.token}/onboarding.txt`;
|
|
const absoluteUrl = onboardingTextLink.startsWith("http")
|
|
? onboardingTextLink
|
|
: `${base}${onboardingTextLink}`;
|
|
setSnippetCopied(false);
|
|
setSnippetCopyDelightId(0);
|
|
try {
|
|
const manifest = await accessApi.getInviteOnboarding(invite.token);
|
|
setInviteSnippet(
|
|
buildAgentSnippet({
|
|
onboardingTextUrl: absoluteUrl,
|
|
connectionCandidates:
|
|
manifest.onboarding.connectivity?.connectionCandidates ?? null,
|
|
testResolutionUrl:
|
|
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
|
|
null
|
|
})
|
|
);
|
|
} catch {
|
|
setInviteSnippet(
|
|
buildAgentSnippet({
|
|
onboardingTextUrl: absoluteUrl,
|
|
connectionCandidates: null,
|
|
testResolutionUrl: null
|
|
})
|
|
);
|
|
}
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.sidebarBadges(selectedCompanyId!)
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
setInviteError(
|
|
err instanceof Error ? err.message : "Failed to create invite"
|
|
);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
setInviteError(null);
|
|
setInviteSnippet(null);
|
|
setSnippetCopied(false);
|
|
setSnippetCopyDelightId(0);
|
|
}, [selectedCompanyId]);
|
|
const archiveMutation = useMutation({
|
|
mutationFn: ({
|
|
companyId,
|
|
nextCompanyId
|
|
}: {
|
|
companyId: string;
|
|
nextCompanyId: string | null;
|
|
}) => companiesApi.archive(companyId).then(() => ({ nextCompanyId })),
|
|
onSuccess: async ({ nextCompanyId }) => {
|
|
if (nextCompanyId) {
|
|
setSelectedCompanyId(nextCompanyId);
|
|
}
|
|
await queryClient.invalidateQueries({
|
|
queryKey: queryKeys.companies.all
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: queryKeys.companies.stats
|
|
});
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
|
{ label: "Settings" }
|
|
]);
|
|
}, [setBreadcrumbs, selectedCompany?.name]);
|
|
|
|
if (!selectedCompany) {
|
|
return (
|
|
<div className="text-sm text-muted-foreground">
|
|
No company selected. Select a company from the switcher above.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function handleSaveGeneral() {
|
|
generalMutation.mutate({
|
|
name: companyName.trim(),
|
|
description: description.trim() || null,
|
|
brandColor: brandColor || null
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl space-y-6">
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-5 w-5 text-muted-foreground" />
|
|
<h1 className="text-lg font-semibold">Company Settings</h1>
|
|
</div>
|
|
|
|
{/* General */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
General
|
|
</div>
|
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
|
<Field label="Company name" hint="The display name for your company.">
|
|
<input
|
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
|
type="text"
|
|
value={companyName}
|
|
onChange={(e) => setCompanyName(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Description"
|
|
hint="Optional description shown in the company profile."
|
|
>
|
|
<input
|
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
|
type="text"
|
|
value={description}
|
|
placeholder="Optional company description"
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Appearance */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Appearance
|
|
</div>
|
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="shrink-0">
|
|
<CompanyPatternIcon
|
|
companyName={companyName || selectedCompany.name}
|
|
brandColor={brandColor || null}
|
|
className="rounded-[14px]"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 space-y-2">
|
|
<Field
|
|
label="Brand color"
|
|
hint="Sets the hue for the company icon. Leave empty for auto-generated color."
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={brandColor || "#6366f1"}
|
|
onChange={(e) => setBrandColor(e.target.value)}
|
|
className="h-8 w-8 cursor-pointer rounded border border-border bg-transparent p-0"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={brandColor}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
if (v === "" || /^#[0-9a-fA-F]{0,6}$/.test(v)) {
|
|
setBrandColor(v);
|
|
}
|
|
}}
|
|
placeholder="Auto"
|
|
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm font-mono outline-none"
|
|
/>
|
|
{brandColor && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setBrandColor("")}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save button for General + Appearance */}
|
|
{generalDirty && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveGeneral}
|
|
disabled={generalMutation.isPending || !companyName.trim()}
|
|
>
|
|
{generalMutation.isPending ? "Saving..." : "Save changes"}
|
|
</Button>
|
|
{generalMutation.isSuccess && (
|
|
<span className="text-xs text-muted-foreground">Saved</span>
|
|
)}
|
|
{generalMutation.isError && (
|
|
<span className="text-xs text-destructive">
|
|
{generalMutation.error instanceof Error
|
|
? generalMutation.error.message
|
|
: "Failed to save"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Hiring */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Hiring
|
|
</div>
|
|
<div className="rounded-md border border-border px-4 py-3">
|
|
<ToggleField
|
|
label="Require board approval for new hires"
|
|
hint="New agent hires stay pending until approved by board."
|
|
checked={!!selectedCompany.requireBoardApprovalForNewAgents}
|
|
onChange={(v) => settingsMutation.mutate(v)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invites */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Invites
|
|
</div>
|
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground">
|
|
Generate an agent snippet for join flows.
|
|
</span>
|
|
<HintIcon text="Creates an agent-only invite (72h) and renders a copy-ready snippet." />
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => inviteMutation.mutate()}
|
|
disabled={inviteMutation.isPending}
|
|
>
|
|
{inviteMutation.isPending
|
|
? "Generating..."
|
|
: "Generate agent snippet"}
|
|
</Button>
|
|
</div>
|
|
{inviteError && (
|
|
<p className="text-sm text-destructive">{inviteError}</p>
|
|
)}
|
|
{inviteSnippet && (
|
|
<div className="rounded-md border border-border bg-muted/30 p-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
Agent Snippet
|
|
</div>
|
|
{snippetCopied && (
|
|
<span
|
|
key={snippetCopyDelightId}
|
|
className="flex items-center gap-1 text-xs text-green-600 animate-pulse"
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
Copied
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 space-y-1.5">
|
|
<textarea
|
|
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
|
|
value={inviteSnippet}
|
|
readOnly
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(inviteSnippet);
|
|
setSnippetCopied(true);
|
|
setSnippetCopyDelightId((prev) => prev + 1);
|
|
setTimeout(() => setSnippetCopied(false), 2000);
|
|
} catch {
|
|
/* clipboard may not be available */
|
|
}
|
|
}}
|
|
>
|
|
{snippetCopied ? "Copied snippet" : "Copy snippet"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Danger Zone */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
|
Danger Zone
|
|
</div>
|
|
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Archive this company to hide it from the sidebar. This persists in
|
|
the database.
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
disabled={
|
|
archiveMutation.isPending ||
|
|
selectedCompany.status === "archived"
|
|
}
|
|
onClick={() => {
|
|
if (!selectedCompanyId) return;
|
|
const confirmed = window.confirm(
|
|
`Archive company "${selectedCompany.name}"? It will be hidden from the sidebar.`
|
|
);
|
|
if (!confirmed) return;
|
|
const nextCompanyId =
|
|
companies.find(
|
|
(company) =>
|
|
company.id !== selectedCompanyId &&
|
|
company.status !== "archived"
|
|
)?.id ?? null;
|
|
archiveMutation.mutate({
|
|
companyId: selectedCompanyId,
|
|
nextCompanyId
|
|
});
|
|
}}
|
|
>
|
|
{archiveMutation.isPending
|
|
? "Archiving..."
|
|
: selectedCompany.status === "archived"
|
|
? "Already archived"
|
|
: "Archive company"}
|
|
</Button>
|
|
{archiveMutation.isError && (
|
|
<span className="text-xs text-destructive">
|
|
{archiveMutation.error instanceof Error
|
|
? archiveMutation.error.message
|
|
: "Failed to archive company"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildAgentSnippet(input: AgentSnippetInput) {
|
|
const candidateUrls = buildCandidateOnboardingUrls(input);
|
|
const resolutionTestUrl = buildResolutionTestUrl(input);
|
|
|
|
const candidateList =
|
|
candidateUrls.length > 0
|
|
? candidateUrls.map((u) => `- ${u}`).join("\n")
|
|
: "- (No candidate URLs available yet.)";
|
|
|
|
const connectivityBlock =
|
|
candidateUrls.length === 0
|
|
? `No candidate URLs are available. Ask your user to configure a reachable hostname in Paperclip, then retry.
|
|
Suggested steps:
|
|
- choose a hostname that resolves to the Paperclip host from your runtime
|
|
- run: pnpm paperclipai allowed-hostname <host>
|
|
- restart Paperclip
|
|
- verify with: curl -fsS http://<host>:3100/api/health
|
|
- regenerate this invite snippet`
|
|
: `If none are reachable, ask your user to add a reachable hostname in Paperclip, restart, and retry.
|
|
Suggested command:
|
|
- pnpm paperclipai allowed-hostname <host>
|
|
Then verify with: curl -fsS <base-url>/api/health`;
|
|
|
|
const resolutionLine = resolutionTestUrl
|
|
? `\nIf you need to test callback reachability, call: http://<whatever hostname worked above>?url=<urlencoded-webhook-url>`
|
|
: "";
|
|
|
|
return `You're invited to join a Paperclip organization.
|
|
|
|
The URLs you should try are:
|
|
${candidateList}
|
|
|
|
Connectivity guidance:
|
|
If you are running on a different machine than Paperclip, Paperclip must be reachable at one of the hostnames used above.
|
|
Verify the hostname works from your runtime with: GET <base-url>/api/health
|
|
|
|
${connectivityBlock}
|
|
|
|
For your "agentName", just use your own agent name (OpenClaw or whatever your name is) and send it directly.
|
|
You also need to send a webhook URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
|
|
|
|
---
|
|
|
|
In order for Paperclip to work smoothly, you need to enable \`gateway.http.endpoints.responses.enabled=true\` in your OpenClaw config.
|
|
|
|
Please enable that setting and notify your user that we did so.
|
|
|
|
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.
|
|
|
|
Then after you've connected to Paperclip (exchanged keys etc.) you MUST review and follow the onboarding instructions in onboarding.txt they give you.
|
|
|
|
`;
|
|
}
|
|
|
|
function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
|
|
const candidates = (input.connectionCandidates ?? [])
|
|
.map((candidate) => candidate.trim())
|
|
.filter(Boolean);
|
|
const urls = new Set<string>();
|
|
let onboardingUrl: URL | null = null;
|
|
|
|
try {
|
|
onboardingUrl = new URL(input.onboardingTextUrl);
|
|
urls.add(onboardingUrl.toString());
|
|
} catch {
|
|
const trimmed = input.onboardingTextUrl.trim();
|
|
if (trimmed) {
|
|
urls.add(trimmed);
|
|
}
|
|
}
|
|
|
|
if (!onboardingUrl) {
|
|
for (const candidate of candidates) {
|
|
urls.add(candidate);
|
|
}
|
|
return Array.from(urls);
|
|
}
|
|
|
|
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const base = new URL(candidate);
|
|
urls.add(`${base.origin}${onboardingPath}`);
|
|
} catch {
|
|
urls.add(candidate);
|
|
}
|
|
}
|
|
|
|
return Array.from(urls);
|
|
}
|
|
|
|
function buildResolutionTestUrl(input: AgentSnippetInput): string | null {
|
|
const explicit = input.testResolutionUrl?.trim();
|
|
if (explicit) return explicit;
|
|
|
|
try {
|
|
const onboardingUrl = new URL(input.onboardingTextUrl);
|
|
const testPath = onboardingUrl.pathname.replace(
|
|
/\/onboarding\.txt$/,
|
|
"/test-resolution"
|
|
);
|
|
return `${onboardingUrl.origin}${testPath}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|