Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/useless-salmon-cougar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/agents-manage-ui": patch
---

Add Support Copilot install dialog with Chrome Web Store instructions, apps-list install button, post-create install flow, and member-count admin note
36 changes: 31 additions & 5 deletions .github/scripts/bridge-public-pr-to-monorepo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

// Keep the public PR bridge copies code-shape aligned. They ship to
// separate public repos through Copybara, so they cannot import shared code.
// Sibling bridge copies:
// - public/agents-optional-local-dev/.github/scripts/bridge-public-pr-to-monorepo.mjs
// - copybara/public-open-knowledge-overlay/.github/scripts/bridge-public-pr-to-monorepo.mjs
const BRIDGE_COMMENT_MARKER = '<!-- monorepo-pr-bridge -->';

function run(command, args, options = {}) {
Expand Down Expand Up @@ -76,7 +81,20 @@ function getPublicPrBranchName(prefix, prNumber) {
return `${prefix}-${prNumber}`;
}

function prefixPatchPaths(patch, prefix) {
function parseJsonEnv(name, fallback) {
const value = process.env[name];
if (!value) {
return fallback;
}

try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid JSON in ${name}: ${error.message}`);
}
}

function prefixPatchPaths(patch, prefix, pathRewrites = {}) {
const normalizedPrefix = prefix.replace(/^\/+|\/+$/g, '');
const prefixedPath = (value) => {
if (value === '/dev/null') {
Expand All @@ -91,7 +109,15 @@ function prefixPatchPaths(patch, prefix) {
throw new Error(`Rejecting patch with path traversal: ${unquoted}`);
}

const nextValue = `${normalizedPrefix}/${unquoted}`.replace(/\/+/g, '/');
const rewrite = pathRewrites[unquoted];
if (rewrite) {
const rewriteSegments = rewrite.split('/');
if (rewriteSegments.some((s) => s === '..' || s === '.')) {
throw new Error(`Rejecting patch rewrite with path traversal: ${rewrite}`);
}
}

const nextValue = rewrite ?? `${normalizedPrefix}/${unquoted}`.replace(/\/+/g, '/');
return value.startsWith('"') ? `"${nextValue}"` : nextValue;
};

Expand Down Expand Up @@ -273,7 +299,7 @@ async function findOpenInternalPr({ token, repo, owner, branchName }) {
return pulls[0] ?? null;
}

async function ensureDraftState({ token, repo, pullRequest, shouldBeDraft }) {
async function ensureDraftState({ token, pullRequest, shouldBeDraft }) {
if (Boolean(pullRequest.draft) === Boolean(shouldBeDraft)) {
return;
}
Expand All @@ -300,6 +326,7 @@ async function syncPublicPr() {
const internalBranchPrefix = requireEnv('INTERNAL_BRANCH_PREFIX');
const publicPrAction = process.env.PUBLIC_PR_ACTION ?? 'opened';
const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10);
const pathRewrites = parseJsonEnv('PUBLIC_PR_PATH_REWRITES', {});
const internalOwner = internalRepo.split('/')[0];
const branchName = getPublicPrBranchName(internalBranchPrefix, publicPrNumber);

Expand Down Expand Up @@ -354,7 +381,7 @@ async function syncPublicPr() {

const tempDir = mkdtempSync(path.join(tmpdir(), 'public-pr-bridge-'));
const patchFile = path.join(tempDir, 'public-pr.patch');
writeFileSync(patchFile, prefixPatchPaths(patch, mirrorPath), 'utf8');
writeFileSync(patchFile, prefixPatchPaths(patch, mirrorPath, pathRewrites), 'utf8');

try {
run('git', ['-C', internalRepoDir, 'fetch', 'origin', internalBaseRef, '--prune']);
Expand Down Expand Up @@ -456,7 +483,6 @@ async function syncPublicPr() {
});
await ensureDraftState({
token: internalToken,
repo: internalRepo,
pullRequest: internalPr,
shouldBeDraft: publicPr.draft,
});
Expand Down
44 changes: 31 additions & 13 deletions agents-manage-ui/src/components/access/project-members-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
'use client';

import { ProjectRoles } from '@inkeep/agents-core/client-exports';
import { ArrowUpRight } from 'lucide-react';
import Link from 'next/link';
import { type FC, use } from 'react';
import { useIsOrgAdmin } from '@/hooks/use-is-org-admin';
import { useProjectAccess } from './hooks/use-project-access';
import { ResourceMembersPage } from './resource-members-page';

Expand Down Expand Up @@ -41,6 +44,7 @@ export const ProjectMembersWrapper: FC<PageProps<'/[tenantId]/projects/[projectI
params,
}) => {
const { tenantId, projectId } = use(params);
const { isAdmin: isOrgAdmin } = useIsOrgAdmin();
const {
principals,
availablePrincipals,
Expand All @@ -54,18 +58,32 @@ export const ProjectMembersWrapper: FC<PageProps<'/[tenantId]/projects/[projectI
} = useProjectAccess({ tenantId, projectId });

return (
<ResourceMembersPage
roles={roles}
availableMembers={availablePrincipals}
inheritedAccess={inheritedAccess}
principals={principals}
membersConfig={membersConfig}
onAdd={addPrincipal}
onRefresh={refetch}
onRoleChange={changeRole}
onRemove={removePrincipal}
isLoading={isLoading}
isAdding={isMutating}
/>
<>
<ResourceMembersPage
roles={roles}
availableMembers={availablePrincipals}
inheritedAccess={inheritedAccess}
principals={principals}
membersConfig={membersConfig}
onAdd={addPrincipal}
onRefresh={refetch}
onRoleChange={changeRole}
onRemove={removePrincipal}
isLoading={isLoading}
isAdding={isMutating}
/>
{isOrgAdmin && (
<div className="max-w-xl mx-auto mt-4 text-sm text-muted-foreground">
Need to invite someone new to the platform?{' '}
<Link
href={`/${tenantId}/members`}
className="inline-flex items-center gap-0.5 font-medium text-primary hover:underline"
>
Open organization members
<ArrowUpRight className="size-3" aria-hidden="true" />
</Link>
</div>
)}
</>
);
};
40 changes: 30 additions & 10 deletions agents-manage-ui/src/components/apps/apps-table.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import { Check, Copy } from 'lucide-react';
import { Check, Copy, Download } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import type { SelectOption } from '@/components/form/generic-select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
Expand All @@ -14,6 +16,7 @@ import { useProjectPermissionsQuery } from '@/lib/query/projects';
import type { Agent } from '@/lib/types/agent-full';
import { formatDateAgo } from '@/lib/utils/format-date';
import { AppItemMenu } from './app-item-menu';
import { SupportCopilotInstallDialog } from './install/support-copilot-install-dialog';

interface AppsTableProps {
apps: App[];
Expand Down Expand Up @@ -135,15 +138,19 @@ export function AppsTable({ apps, agentLookup, agentOptions, credentialOptions }
id: 'actions',
header: '',
enableSorting: false,
meta: { className: 'w-12' },
cell: ({ row }) =>
canEdit && (
<AppItemMenu
app={row.original}
agentOptions={agentOptions}
credentialOptions={credentialOptions}
/>
),
meta: { className: 'w-24' },
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
{row.original.type === 'support_copilot' && <InstallButton app={row.original} />}
{canEdit && (
<AppItemMenu
app={row.original}
agentOptions={agentOptions}
credentialOptions={credentialOptions}
/>
)}
</div>
),
},
];

Expand All @@ -159,3 +166,16 @@ export function AppsTable({ apps, agentLookup, agentOptions, credentialOptions }
</div>
);
}

function InstallButton({ app }: { app: App }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" size="xs" onClick={() => setOpen(true)}>
<Download className="size-3" aria-hidden="true" />
Install
</Button>
<SupportCopilotInstallDialog app={app} open={open} onClose={() => setOpen(false)} />
</>
);
}
111 changes: 111 additions & 0 deletions agents-manage-ui/src/components/apps/install/admin-note.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use client';

import {
DEFAULT_MEMBERSHIP_LIMIT,
OrgRoles,
ProjectRoles,
} from '@inkeep/agents-core/client-exports';
import { ArrowUpRight, Users } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { CopyableSingleLineCode } from '@/components/ui/copyable-single-line-code';
import { useAuthClient } from '@/contexts/auth-client';
import { listProjectMembers } from '@/lib/api/project-members';

interface AdminNoteProps {
tenantId: string;
projectId: string;
isZendesk: boolean;
chromeExtensionId: string;
}

interface Counts {
members: number;
admins: number;
}

function describeAccess({ members, admins }: Counts): string {
if (members === 0) {
const adminLine = admins > 0 ? ` Org admins (${admins}) have access automatically.` : '';
return `No project members yet.${adminLine} Add project members to give your team access.`;
}
return `Currently ${members} project member${members === 1 ? '' : 's'} and ${admins} org admin${admins === 1 ? '' : 's'} have access.`;
}

export function AdminNote({ tenantId, projectId, isZendesk, chromeExtensionId }: AdminNoteProps) {
const authClient = useAuthClient();
const [counts, setCounts] = useState<Counts | null>(null);
const [errored, setErrored] = useState(false);

useEffect(() => {
let cancelled = false;

Promise.all([
listProjectMembers({ tenantId, projectId }),
authClient.organization.getFullOrganization({
query: { organizationId: tenantId, membersLimit: DEFAULT_MEMBERSHIP_LIMIT },
}),
])
.then(([proj, org]) => {
if (cancelled) return;
const members = proj.data.filter(
(m) => m.role === ProjectRoles.ADMIN || m.role === ProjectRoles.MEMBER
).length;
const admins =
org.data?.members?.filter((m) => m.role === OrgRoles.OWNER || m.role === OrgRoles.ADMIN)
.length ?? 0;
setCounts({ members, admins });
})
.catch((err) => {
if (!cancelled) {
console.error('AdminNote: failed to load member/admin counts', err);
setErrored(true);
}
});

return () => {
cancelled = true;
};
}, [tenantId, projectId, authClient]);

const showCounts = counts !== null && !errored;

return (
<div className="space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex items-center gap-2">
<Users className="size-4 text-muted-foreground" aria-hidden="true" />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Admin note
</p>
</div>

<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Only users added to this project (Project Member role or above) can use the Support
Copilot app.
</p>

{showCounts && <p className="text-sm text-muted-foreground">{describeAccess(counts)}</p>}

<Link
href={`/${tenantId}/projects/${projectId}/members`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
Review project members
<ArrowUpRight className="size-3" aria-hidden="true" />
</Link>
</div>

{!isZendesk && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
Chrome extension ID (for enterprise allowlist / force-install policies)
</p>
<CopyableSingleLineCode code={chromeExtensionId} />
</div>
)}
</div>
);
}
Loading
Loading