diff --git a/docs/design/evidence/task-040-github-app-setup-after-1200.png b/docs/design/evidence/task-040-github-app-setup-after-1200.png new file mode 100644 index 000000000..834ba7c9f Binary files /dev/null and b/docs/design/evidence/task-040-github-app-setup-after-1200.png differ diff --git a/docs/design/evidence/task-040-github-app-setup-after-390.png b/docs/design/evidence/task-040-github-app-setup-after-390.png new file mode 100644 index 000000000..1367d8a3e Binary files /dev/null and b/docs/design/evidence/task-040-github-app-setup-after-390.png differ diff --git a/docs/design/evidence/task-040-github-app-setup-before.png b/docs/design/evidence/task-040-github-app-setup-before.png new file mode 100644 index 000000000..4962103e4 Binary files /dev/null and b/docs/design/evidence/task-040-github-app-setup-before.png differ diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index cc1ad5584..24b30696b 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -196,6 +196,64 @@ test('a not-yet row without a Connection or on an unconfigured instance offers n renderTools(); expect(await screen.findByText('install the GitHub App first · an admin does this once')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Install GitHub App' })).toBeNull(); +}); + +test('an admin can install the GitHub App from the no-connection Tools row', async () => { + const noConnection = { ...githubEntry, connections: [] }; + axios.get.mockImplementation((url) => { + if (url === '/api/installables') return Promise.resolve({ data: { installables: [noConnection] } }); + if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } }); + if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); + return Promise.reject(new Error(`unmocked ${url}`)); + }); + axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'u1' } } }); + const admin = { + ...authValue, + currentUser: { ...authValue.currentUser, role: 'admin' }, + user: { ...authValue.user, role: 'admin' }, + }; + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Install GitHub App' })); + const form = await screen.findByRole('complementary', { name: 'Install GitHub App' }); + fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } }); + fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } }); + fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/integrations/github-app', + { installationId: 'install-42', owner: 'Team-Commonly', repo: 'commonly' }, + expect.any(Object), + )); + await waitFor(() => expect(screen.queryByRole('complementary', { name: 'Install GitHub App' })).toBeNull()); +}); + +test('an admin is told when another administrator already owns the GitHub App installation', async () => { + const noConnection = { ...githubEntry, connections: [] }; + axios.get.mockImplementation((url) => { + if (url === '/api/installables') return Promise.resolve({ data: { installables: [noConnection] } }); + if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } }); + if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); + return Promise.reject(new Error(`unmocked ${url}`)); + }); + axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'other-admin' } } }); + const admin = { + ...authValue, + currentUser: { ...authValue.currentUser, _id: 'u2', role: 'admin' }, + user: { ...authValue.user, _id: 'u2', role: 'admin' }, + }; + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Install GitHub App' })); + const form = await screen.findByRole('complementary', { name: 'Install GitHub App' }); + fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } }); + fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } }); + fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' })); + + await waitFor(() => expect(within(form).getByRole('alert')).toHaveTextContent('This GitHub App is already installed by another administrator.')); + expect(screen.getByRole('complementary', { name: 'Install GitHub App' })).toBeInTheDocument(); }); test('an unavailable not-yet tool row offers the same Ask affordance as unavailable channels', async () => { diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index dac556c0b..b3605de75 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -86,6 +86,18 @@ interface DraftGrant { replaces: string | null; } +interface GithubAppSetup { + installationId: string; + owner: string; + repo: string; +} + +interface GithubAppIntegrationResponse { + integration?: { + createdBy?: string | { _id?: string } | null; + }; +} + const USED_RECENTLY_MS = 10 * 60 * 1000; const MAX_PODS = 20; const MODE_RANK: Record = { read: 0, 'write-with-confirm': 1, write: 2 }; @@ -140,6 +152,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { const [trailError, setTrailError] = useState(false); const [selectedId, setSelectedId] = useState(null); const [draft, setDraft] = useState(null); + const [githubAppSetup, setGithubAppSetup] = useState(null); // Change access minted the new grant but the revoke of the old one failed: only the revoke is retried. const [staleAfterChange, setStaleAfterChange] = useState<{ oldGrantId: string; newGrantId: string } | null>(null); const [confirmRevoke, setConfirmRevoke] = useState(null); @@ -148,6 +161,8 @@ const V2ConnectorTools: React.FC = ({ pods }) => { const [query, setQuery] = useState(''); const [segment, setSegment] = useState<'all' | 'granted' | 'not-yet'>('all'); + const isAdmin = currentUser?.role === 'admin'; + const podIds = useMemo(() => pods.slice(0, MAX_PODS).map((pod) => String(pod._id)), [pods]); const load = useCallback(async () => { @@ -286,6 +301,13 @@ const V2ConnectorTools: React.FC = ({ pods }) => { setConfirmRevoke(null); setError(null); }; + const openGithubAppSetup = () => { + setGithubAppSetup({ installationId: '', owner: '', repo: '' }); + setDraft(null); + setSelectedId(null); + setConfirmRevoke(null); + setError(null); + }; const draftEntry = draft ? catalog.find((entry) => entry.installableId === draft.installableId) || null : null; const draftTools = (entry: ToolCatalogEntry | null, mode: GrantWriteMode): string[] => (entry?.tools || []) .filter((tool) => MODE_RANK[tool.requiredWriteMode] <= MODE_RANK[mode]).map((tool) => tool.name); @@ -332,6 +354,39 @@ const V2ConnectorTools: React.FC = ({ pods }) => { } }; + const submitGithubAppSetup = async () => { + if (!githubAppSetup || !isAdmin) return; + const installationId = githubAppSetup.installationId.trim(); + const owner = githubAppSetup.owner.trim(); + const repo = githubAppSetup.repo.trim(); + if (!installationId || !owner || !repo) { + setError(t('tools.githubAppRequired', { defaultValue: 'Enter the installation ID, owner, and repository.' })); + return; + } + setBusy(true); + setError(null); + try { + const response = await api.post('/api/integrations/github-app', { installationId, owner, repo }); + const createdBy = response?.integration?.createdBy; + const createdById = typeof createdBy === 'string' + ? createdBy + : createdBy && typeof createdBy === 'object' && createdBy._id + ? String(createdBy._id) + : ''; + if (createdById && viewerId && createdById !== viewerId) { + setError(t('tools.githubAppOwnedByOther', { defaultValue: 'This GitHub App is already installed by another administrator.' })); + return; + } + setGithubAppSetup(null); + await load(); + } catch (err) { + const code = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data; + setError(code?.message || code?.error || t('tools.githubAppError', { defaultValue: 'Could not install the GitHub App.' })); + } finally { + setBusy(false); + } + }; + const retryStaleRevoke = async () => { if (!staleAfterChange) return; setBusy(true); @@ -420,6 +475,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { const renderNotYet = (entry: ToolCatalogEntry) => { const canAdd = entry.available && entry.connections.length > 0 && podIds.length > 0; + const canInstallGithubApp = isAdmin && entry.installableId === 'github' && entry.available && entry.connections.length === 0; return (
@@ -448,10 +504,71 @@ const V2ConnectorTools: React.FC = ({ pods }) => { {t('tools.add', { defaultValue: 'Add' })} )} + {canInstallGithubApp && ( + + )}
); }; + const renderGithubAppSetup = () => { + if (!githubAppSetup) return null; + return ( + + ); + }; + const renderDraft = () => { if (!draft || !draftEntry) return null; const podSeats = seats[draft.podId] || []; @@ -670,7 +787,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { )} - {draft ? renderDraft() : (selected ? renderAside(selected) : null)} + {draft ? renderDraft() : (selected ? renderAside(selected) : renderGithubAppSetup())} ); }; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index b42efdcaa..3b52d6ec6 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -9810,6 +9810,8 @@ body.modern-ui.v2-canvas { .v2-tools__form { display: grid; gap: 14px; margin-top: 14px; } .v2-tools__field { display: grid; gap: 6px; margin: 0; padding: 0; border: none; min-width: 0; } .v2-tools__field > span:first-child, .v2-tools__field > legend { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; text-transform: lowercase; padding: 0; } +.v2-tools__input { min-width: 0; min-height: 40px; padding: 0 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); color: var(--v2-ink); font: 500 13px var(--v2-font); } +.v2-tools__input:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 0; } .v2-tools__agents { display: grid; gap: 6px; } .v2-tools__hint { color: var(--v2-text-secondary); font-size: 12px; line-height: 18px; } .v2-tools__hint code { display: inline-block; margin: 0 6px 4px 0; padding: 1px 6px; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius-sm); font-family: var(--v2-font-mono); font-size: 12px; white-space: nowrap; }