Skip to content

Commit 4c663ac

Browse files
committed
fix(github): complete user authorization flow
Surface contextual identity setup guidance in Cloud Agent and identify revocation requests so disconnect succeeds against GitHub.
1 parent e9f66a2 commit 4c663ac

5 files changed

Lines changed: 283 additions & 1 deletion

File tree

apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5+
import Link from 'next/link';
56
import { useRouter } from 'next/navigation';
67
import { useAtom, useSetAtom } from 'jotai';
78
import { toast } from 'sonner';
@@ -89,6 +90,12 @@ import {
8990
setLastUsedRepo,
9091
setLastUsedVariant,
9192
} from '@/components/cloud-agent-next/model-preferences';
93+
import {
94+
GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY,
95+
getGitHubIdentityHint,
96+
getGitHubIdentityHintDismissed,
97+
markGitHubIdentityHintDismissed,
98+
} from '@/components/cloud-agent-next/github-identity-hint';
9299

93100
type Repository = {
94101
id: number;
@@ -102,6 +109,13 @@ type NewSessionPanelProps = {
102109
isDevcontainerAvailable: boolean;
103110
};
104111

112+
type ContextualTipProps = {
113+
body: string;
114+
linkLabel: string;
115+
href: string;
116+
onDismiss: () => void;
117+
};
118+
105119
export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: NewSessionPanelProps) {
106120
const router = useRouter();
107121
const trpc = useTRPC();
@@ -111,6 +125,9 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
111125
const fileInputRef = useRef<HTMLInputElement>(null);
112126
const commandListRef = useRef<HTMLDivElement>(null);
113127
const [devcontainer, setDevcontainer] = useState(false);
128+
const [isGitHubIdentityHintDismissed, setIsGitHubIdentityHintDismissed] = useState<
129+
boolean | null
130+
>(null);
114131
const { mutateAsync: personalUploadUrl } = useMutation(
115132
trpc.cloudAgentNext.getAttachmentUploadUrl.mutationOptions()
116133
);
@@ -173,6 +190,21 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
173190
const [isPreparing, setIsPreparing] = useState(false);
174191
const [attachmentMessageUuid, setAttachmentMessageUuid] = useState(() => crypto.randomUUID());
175192

193+
// ---------------------------------------------------------------------------
194+
// GitHub identity awareness
195+
// ---------------------------------------------------------------------------
196+
const {
197+
data: githubUserAuthorization,
198+
isLoading: isGitHubUserAuthorizationLoading,
199+
isError: isGitHubUserAuthorizationError,
200+
} = useQuery({
201+
...trpc.githubApps.getUserAuthorization.queryOptions(),
202+
enabled:
203+
isGitHubIdentityHintDismissed === false &&
204+
selectedRepo.length > 0 &&
205+
selectedPlatform === 'github',
206+
});
207+
176208
const attachmentUpload = useCloudAgentAttachmentUpload({
177209
messageUuid: attachmentMessageUuid,
178210
organizationId,
@@ -197,6 +229,15 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
197229

198230
useEffect(() => {
199231
setDevcontainer(getDevcontainerEnabled());
232+
setIsGitHubIdentityHintDismissed(getGitHubIdentityHintDismissed());
233+
234+
const handleGitHubIdentityHintStorage = (event: StorageEvent) => {
235+
if (event.key === GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY && event.newValue === 'true') {
236+
setIsGitHubIdentityHintDismissed(true);
237+
}
238+
};
239+
window.addEventListener('storage', handleGitHubIdentityHintStorage);
240+
return () => window.removeEventListener('storage', handleGitHubIdentityHintStorage);
200241
}, []);
201242

202243
const handleDevcontainerChange = useCallback((enabled: boolean) => {
@@ -865,6 +906,20 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
865906
[handleAutocompleteKeyDown, isFormValid, handleStartSession]
866907
);
867908

909+
const githubIdentityHint = getGitHubIdentityHint({
910+
selectedRepo,
911+
selectedPlatform,
912+
authorization: githubUserAuthorization,
913+
isLoading: isGitHubUserAuthorizationLoading,
914+
isError: isGitHubUserAuthorizationError,
915+
isDismissed: isGitHubIdentityHintDismissed !== false,
916+
});
917+
918+
const handleDismissGitHubIdentityHint = useCallback(() => {
919+
markGitHubIdentityHintDismissed();
920+
setIsGitHubIdentityHintDismissed(true);
921+
}, []);
922+
868923
// ---------------------------------------------------------------------------
869924
// Integration missing view
870925
// ---------------------------------------------------------------------------
@@ -1326,6 +1381,42 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
13261381
/>
13271382
</div>
13281383
</div>
1384+
1385+
{githubIdentityHint && (
1386+
<ContextualTip {...githubIdentityHint} onDismiss={handleDismissGitHubIdentityHint} />
1387+
)}
1388+
</div>
1389+
</div>
1390+
);
1391+
}
1392+
1393+
function ContextualTip({ body, linkLabel, href, onDismiss }: ContextualTipProps) {
1394+
return (
1395+
<div className="group/tip flex max-w-full justify-center text-center" role="status">
1396+
<div className="text-muted-foreground inline-flex max-w-full items-start justify-center gap-1 text-xs">
1397+
<span aria-hidden="true" className="invisible mr-1 shrink-0 px-1">
1398+
Dismiss
1399+
</span>
1400+
<span className="text-foreground font-medium">Tip:</span>
1401+
<span aria-hidden="true" className="text-border">
1402+
&middot;
1403+
</span>
1404+
<span className="min-w-0">
1405+
{body}{' '}
1406+
<Link
1407+
href={href}
1408+
className="text-blue-400 hover:text-blue-300 hover:underline focus-visible:underline"
1409+
>
1410+
{linkLabel}
1411+
</Link>
1412+
</span>
1413+
<button
1414+
type="button"
1415+
className="text-muted-foreground/70 hover:text-foreground focus-visible:text-foreground focus-visible:ring-ring pointer-events-none -my-4 ml-1 shrink-0 cursor-pointer rounded-sm px-1 py-4 underline decoration-border underline-offset-4 opacity-0 transition-opacity group-focus-within/tip:pointer-events-auto group-focus-within/tip:opacity-100 group-hover/tip:pointer-events-auto group-hover/tip:opacity-100 focus-visible:ring-1 focus-visible:outline-none [@media(any-pointer:coarse)]:pointer-events-auto [@media(any-pointer:coarse)]:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
1416+
onClick={onDismiss}
1417+
>
1418+
Dismiss
1419+
</button>
13291420
</div>
13301421
</div>
13311422
);
@@ -1334,7 +1425,6 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
13341425
// ---------------------------------------------------------------------------
13351426
// Internal sub-component for repo items in the Command list
13361427
// ---------------------------------------------------------------------------
1337-
13381428
function RepoCommandItem({
13391429
repo,
13401430
isSelected,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY,
3+
getGitHubIdentityHint,
4+
getGitHubIdentityHintDismissed,
5+
markGitHubIdentityHintDismissed,
6+
parseGitHubIdentityHintDismissed,
7+
} from './github-identity-hint';
8+
9+
const visibleHintOptions = {
10+
selectedRepo: 'kilo/example',
11+
selectedPlatform: 'github',
12+
authorization: { connected: false, githubLogin: null, revoked: false },
13+
isLoading: false,
14+
isError: false,
15+
isDismissed: false,
16+
} satisfies Parameters<typeof getGitHubIdentityHint>[0];
17+
18+
function createMemoryStorage() {
19+
const values = new Map<string, string>();
20+
return {
21+
storage: {
22+
getItem: (key: string) => values.get(key) ?? null,
23+
setItem: (key: string, value: string) => values.set(key, value),
24+
},
25+
values,
26+
};
27+
}
28+
29+
describe('GitHub identity hint dismissal storage', () => {
30+
it('uses one browser-local dismissal marker', () => {
31+
expect(GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY).toBe(
32+
'cloud-agent:github-identity-hint-dismissed'
33+
);
34+
});
35+
36+
it('treats only true as dismissed', () => {
37+
expect(parseGitHubIdentityHintDismissed('true')).toBe(true);
38+
expect(parseGitHubIdentityHintDismissed('false')).toBe(false);
39+
expect(parseGitHubIdentityHintDismissed(null)).toBe(false);
40+
});
41+
42+
it('persists and reloads browser-local dismissal', () => {
43+
const { storage, values } = createMemoryStorage();
44+
45+
markGitHubIdentityHintDismissed(storage);
46+
47+
expect(values.get(GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY)).toBe('true');
48+
expect(getGitHubIdentityHintDismissed(storage)).toBe(true);
49+
});
50+
});
51+
52+
describe('getGitHubIdentityHint', () => {
53+
it('returns null when no repository is selected', () => {
54+
expect(getGitHubIdentityHint({ ...visibleHintOptions, selectedRepo: '' })).toBeNull();
55+
});
56+
57+
it('returns null for a GitLab repository', () => {
58+
expect(getGitHubIdentityHint({ ...visibleHintOptions, selectedPlatform: 'gitlab' })).toBeNull();
59+
});
60+
61+
it('returns null when authorization status is missing', () => {
62+
expect(getGitHubIdentityHint({ ...visibleHintOptions, authorization: undefined })).toBeNull();
63+
});
64+
65+
it('returns null while authorization status is loading', () => {
66+
expect(getGitHubIdentityHint({ ...visibleHintOptions, isLoading: true })).toBeNull();
67+
});
68+
69+
it('returns null when authorization status fails to load', () => {
70+
expect(getGitHubIdentityHint({ ...visibleHintOptions, isError: true })).toBeNull();
71+
});
72+
73+
it('returns null when a GitHub identity is connected', () => {
74+
expect(
75+
getGitHubIdentityHint({
76+
...visibleHintOptions,
77+
authorization: { connected: true, githubLogin: 'octocat', revoked: false },
78+
})
79+
).toBeNull();
80+
});
81+
82+
it('returns null when a GitHub identity authorization was revoked', () => {
83+
expect(
84+
getGitHubIdentityHint({
85+
...visibleHintOptions,
86+
authorization: { connected: false, githubLogin: 'octocat', revoked: true },
87+
})
88+
).toBeNull();
89+
});
90+
91+
it('returns null after the browser dismisses the awareness hint', () => {
92+
expect(getGitHubIdentityHint({ ...visibleHintOptions, isDismissed: true })).toBeNull();
93+
});
94+
95+
it('returns subtle setup copy until dismissed or connected', () => {
96+
expect(getGitHubIdentityHint(visibleHintOptions)).toEqual({
97+
body: 'Commit as yourself instead of the Kilo bot.',
98+
linkLabel: 'Set up identity',
99+
href: '/integrations/github#github-identity',
100+
});
101+
});
102+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { safeLocalStorage } from '@/lib/localStorage';
2+
import type { RepositoryPlatform } from '@/components/shared/RepositoryCombobox';
3+
4+
export const GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY =
5+
'cloud-agent:github-identity-hint-dismissed';
6+
7+
type GitHubUserAuthorization = {
8+
connected: boolean;
9+
githubLogin: string | null;
10+
revoked: boolean;
11+
};
12+
13+
type GitHubIdentityHintOptions = {
14+
selectedRepo: string;
15+
selectedPlatform: RepositoryPlatform;
16+
authorization: GitHubUserAuthorization | undefined;
17+
isLoading: boolean;
18+
isError: boolean;
19+
isDismissed: boolean;
20+
};
21+
22+
export type GitHubIdentityHint = {
23+
body: string;
24+
linkLabel: string;
25+
href: string;
26+
};
27+
28+
type GitHubIdentityHintStorage = Pick<typeof safeLocalStorage, 'getItem' | 'setItem'>;
29+
30+
const githubIdentityHint: GitHubIdentityHint = {
31+
body: 'Commit as yourself instead of the Kilo bot.',
32+
linkLabel: 'Set up identity',
33+
href: '/integrations/github#github-identity',
34+
};
35+
36+
export function parseGitHubIdentityHintDismissed(storedValue: string | null) {
37+
return storedValue === 'true';
38+
}
39+
40+
export function getGitHubIdentityHintDismissed(
41+
storage: GitHubIdentityHintStorage = safeLocalStorage
42+
) {
43+
return parseGitHubIdentityHintDismissed(
44+
storage.getItem(GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY)
45+
);
46+
}
47+
48+
export function markGitHubIdentityHintDismissed(
49+
storage: GitHubIdentityHintStorage = safeLocalStorage
50+
) {
51+
storage.setItem(GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY, 'true');
52+
}
53+
54+
export function getGitHubIdentityHint({
55+
selectedRepo,
56+
selectedPlatform,
57+
authorization,
58+
isLoading,
59+
isError,
60+
isDismissed,
61+
}: GitHubIdentityHintOptions): GitHubIdentityHint | null {
62+
if (
63+
!selectedRepo ||
64+
selectedPlatform !== 'github' ||
65+
!authorization ||
66+
isLoading ||
67+
isError ||
68+
isDismissed ||
69+
authorization.connected ||
70+
authorization.revoked
71+
) {
72+
return null;
73+
}
74+
return githubIdentityHint;
75+
}

services/git-token-service/src/github-user-authorization-service.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,20 @@ describe('GitHubUserAuthorizationService disconnect', () => {
267267
expect(database.deleted).toBe(true);
268268
});
269269

270+
it('identifies grant revocation requests with the GitHub user agent', async () => {
271+
const request = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
272+
vi.stubGlobal('fetch', request);
273+
274+
await makeService().disconnectUserAuthorization('user_1');
275+
276+
expect(request).toHaveBeenCalledWith(
277+
'https://api.github.com/applications/client-id/grant',
278+
expect.objectContaining({
279+
headers: expect.objectContaining({ 'User-Agent': 'Kilo-Git-Token-Service' }),
280+
})
281+
);
282+
});
283+
270284
it('persists refreshed credentials before revoking an expired grant', async () => {
271285
database.rows = [
272286
{ ...makeRow(), access_token_expires_at: new Date(Date.now() - 1000).toISOString() },

services/git-token-service/src/github-user-authorization-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ export class GitHubUserAuthorizationService {
426426
Accept: 'application/vnd.github+json',
427427
Authorization: `Basic ${basicAuth}`,
428428
'Content-Type': 'application/json',
429+
'User-Agent': 'Kilo-Git-Token-Service',
429430
},
430431
body: JSON.stringify({ access_token: accessToken }),
431432
});

0 commit comments

Comments
 (0)