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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AuthContext.Provider value={admin}><MemoryRouter><V2ConnectorTools pods={pods} /></MemoryRouter></AuthContext.Provider>);

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(<AuthContext.Provider value={admin}><MemoryRouter><V2ConnectorTools pods={pods} /></MemoryRouter></AuthContext.Provider>);

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 () => {
Expand Down
119 changes: 118 additions & 1 deletion frontend/src/v2/components/V2ConnectorTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<GrantWriteMode, number> = { read: 0, 'write-with-confirm': 1, write: 2 };
Expand Down Expand Up @@ -140,6 +152,7 @@ const V2ConnectorTools: React.FC<Props> = ({ pods }) => {
const [trailError, setTrailError] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftGrant | null>(null);
const [githubAppSetup, setGithubAppSetup] = useState<GithubAppSetup | null>(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<string | null>(null);
Expand All @@ -148,6 +161,8 @@ const V2ConnectorTools: React.FC<Props> = ({ 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 () => {
Expand Down Expand Up @@ -286,6 +301,13 @@ const V2ConnectorTools: React.FC<Props> = ({ 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);
Expand Down Expand Up @@ -332,6 +354,39 @@ const V2ConnectorTools: React.FC<Props> = ({ 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<GithubAppIntegrationResponse>('/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);
Expand Down Expand Up @@ -420,6 +475,7 @@ const V2ConnectorTools: React.FC<Props> = ({ 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 (
<article key={entry.installableId} className={`v2-connector-row v2-connector-row--not-yet${!entry.available ? ' v2-connector-row--not-enabled' : ''}`}>
<span className="v2-connector-row__name">
Expand Down Expand Up @@ -448,10 +504,71 @@ const V2ConnectorTools: React.FC<Props> = ({ pods }) => {
{t('tools.add', { defaultValue: 'Add' })}
</button>
)}
{canInstallGithubApp && (
<button type="button" className="v2-connector-row__action" onClick={openGithubAppSetup}>
{t('tools.installGitHubApp', { defaultValue: 'Install GitHub App' })}
</button>
)}
</article>
);
};

const renderGithubAppSetup = () => {
if (!githubAppSetup) return null;
return (
<aside className="v2-connectors__aside v2-tools__aside" aria-label={t('tools.installGitHubApp', { defaultValue: 'Install GitHub App' })}>
<section className="v2-connector-aside__card">
<p className="v2-connector-aside__eyebrow">{t('tools.adminSetup', { defaultValue: 'administrator setup' })}</p>
<h2>{t('tools.installGitHubApp', { defaultValue: 'Install GitHub App' })}</h2>
<p>{t('tools.githubAppSetupHint', { defaultValue: 'Connect the GitHub App once so people can grant GitHub tools to their rooms.' })}</p>
<div className="v2-tools__form">
<label className="v2-tools__field">
<span>{t('tools.installationId', { defaultValue: 'installation ID' })}</span>
<input
className="v2-tools__input"
value={githubAppSetup.installationId}
onChange={(event) => setGithubAppSetup({ ...githubAppSetup, installationId: event.target.value })}
aria-label={t('tools.installationId', { defaultValue: 'installation ID' })}
/>
</label>
<label className="v2-tools__field">
<span>{t('tools.owner', { defaultValue: 'owner' })}</span>
<input
className="v2-tools__input"
value={githubAppSetup.owner}
onChange={(event) => setGithubAppSetup({ ...githubAppSetup, owner: event.target.value })}
aria-label={t('tools.owner', { defaultValue: 'owner' })}
/>
</label>
<label className="v2-tools__field">
<span>{t('tools.repository', { defaultValue: 'repository' })}</span>
<input
className="v2-tools__input"
value={githubAppSetup.repo}
onChange={(event) => setGithubAppSetup({ ...githubAppSetup, repo: event.target.value })}
aria-label={t('tools.repository', { defaultValue: 'repository' })}
/>
</label>
</div>
<div className="v2-connector-aside__actions">
<button
type="button"
className="v2-connector-aside__primary"
disabled={busy || !githubAppSetup.installationId.trim() || !githubAppSetup.owner.trim() || !githubAppSetup.repo.trim()}
onClick={() => { void submitGithubAppSetup(); }}
>
{busy ? t('tools.installingGitHubApp', { defaultValue: 'Installing…' }) : t('tools.installGitHubApp', { defaultValue: 'Install GitHub App' })}
</button>
<button type="button" className="v2-connector-aside__secondary" disabled={busy} onClick={() => setGithubAppSetup(null)}>
{t('tools.cancel', { defaultValue: 'Cancel' })}
</button>
</div>
{error && <p className="v2-connector-aside__note" role="alert">{error}</p>}
</section>
</aside>
);
};

const renderDraft = () => {
if (!draft || !draftEntry) return null;
const podSeats = seats[draft.podId] || [];
Expand Down Expand Up @@ -670,7 +787,7 @@ const V2ConnectorTools: React.FC<Props> = ({ pods }) => {
)}
</div>
</section>
{draft ? renderDraft() : (selected ? renderAside(selected) : null)}
{draft ? renderDraft() : (selected ? renderAside(selected) : renderGithubAppSetup())}
</div>
);
};
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading