kyd-shared-badge 0.3.78 → 0.3.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.78",
3
+ "version": "0.3.80",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -1,9 +1,9 @@
1
- import React, { useMemo, useState } from 'react';
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
2
  import { useRouter } from 'next/navigation';
3
3
  import { ProviderIcon } from '../utils/provider';
4
4
  import { normalizeLinkedInInput } from './linkedin';
5
5
  import type { ConnectAccountsProps } from './types';
6
- import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink } from 'lucide-react';
6
+ import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink, Settings } from 'lucide-react';
7
7
  import { AnimatePresence, motion } from 'framer-motion';
8
8
  import { Button, Input, Spinner, Card, CardHeader, CardContent, CardFooter, CardTitle } from '../ui';
9
9
  import Link from 'next/link';
@@ -38,6 +38,9 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
38
38
  headerDescription,
39
39
  requiredProviders,
40
40
  companyName,
41
+ initialProviderId,
42
+ githubAppSlugId,
43
+ userId,
41
44
  } = props;
42
45
 
43
46
  const router = useRouter();
@@ -45,11 +48,23 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
45
48
  const [linkUrl, setLinkUrl] = useState('');
46
49
  const [isSubmitting, setIsSubmitting] = useState(false);
47
50
  const [isDisconnecting, setIsDisconnecting] = useState<string | null>(null);
51
+ const [showGithubManage, setShowGithubManage] = useState(false);
48
52
 
49
53
  const apiBase = apiGatewayUrl || (typeof process !== 'undefined' ? (process.env.NEXT_PUBLIC_API_GATEWAY_URL as string) : '');
50
- const connectedIds = useMemo(() => new Set((connected || []).map(c => c.id.toLowerCase())), [connected]);
54
+ const connectedIds = useMemo(() => new Set((connected || []).map(c => c.name.toLowerCase())), [connected]);
51
55
  const reconnectIds = useMemo(() => new Set((needsReconnectIds || []).map(id => id.toLowerCase())), [needsReconnectIds]);
52
56
 
57
+ // React to upstream changes to initialProviderId (e.g., after oauth success)
58
+ useEffect(() => {
59
+ if (initialProviderId && initialProviderId !== selectedProviderId) {
60
+ setSelectedProviderIdAndCallback(initialProviderId);
61
+ // If we landed here from initialProviderId, show the existing card (not manage)
62
+ if (initialProviderId === 'githubapp') setShowGithubManage(false);
63
+ }
64
+ // Do not clear selection if initialProviderId becomes falsy later
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, [initialProviderId]);
67
+
53
68
  const list = useMemo(() => {
54
69
  const arr = [...providers];
55
70
  if (!sort) return arr;
@@ -94,6 +109,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
94
109
  });
95
110
  const data = await res.json().catch(() => ({}));
96
111
  if (!res.ok) throw new Error(data?.error || `Failed to disconnect ${providerId}.`);
112
+ setShowGithubManage(false);
97
113
  if (onDisconnected) onDisconnected(providerId);
98
114
  } catch (e) {
99
115
  const err = e as Error;
@@ -148,14 +164,27 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
148
164
  if (url) router.push(url);
149
165
  };
150
166
 
151
- const selectedProvider = useMemo(
152
- () => (selectedProviderId ? providers.find(p => p.id.toLowerCase() === selectedProviderId.toLowerCase()) : null),
153
- [selectedProviderId, providers]
154
- );
167
+ const selectedProvider = useMemo(() => {
168
+ if (!selectedProviderId) return null;
169
+ if (selectedProviderId.toLowerCase() === 'githubapp') {
170
+ // Special virtual provider for github app, not in providers list
171
+ return {
172
+ id: 'githubapp',
173
+ name: 'GitHub App',
174
+ connectionType: 'githubapp',
175
+ iconColor: 'text-gray-900',
176
+ placeholder: 'Install the GitHub App to connect your repositories.',
177
+ };
178
+ }
179
+ return providers.find(
180
+ (p) => p.id.toLowerCase() === selectedProviderId.toLowerCase()
181
+ ) || null;
182
+ }, [selectedProviderId, providers]);
155
183
 
156
184
  const handleConnectBack = () => {
157
185
  setSelectedProviderIdAndCallback(null);
158
186
  setLinkUrl('');
187
+ setShowGithubManage(false);
159
188
  };
160
189
 
161
190
  const cardVariants = {
@@ -163,10 +192,20 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
163
192
  animate: { opacity: 1, y: 0 },
164
193
  exit: { opacity: 0, y: -20 },
165
194
  };
166
-
195
+
196
+ // GitHub status helpers
197
+ const githubConnectedAccount = useMemo(() => {
198
+ return (connected || []).find(c => (c?.name || '').toLowerCase() === 'github');
199
+ }, [connected]);
200
+ const isGithubConnected = !!githubConnectedAccount;
201
+ const isGithubAppInstalled = useMemo(() => {
202
+ const setupAction = (githubConnectedAccount as any)?.app_installation_info?.setupAction;
203
+ return setupAction === 'install';
204
+ }, [githubConnectedAccount]);
205
+
167
206
  return (
168
207
  <>
169
- {selectedProvider ? (
208
+ {selectedProvider && selectedProvider.id !== 'githubapp' ? (
170
209
  <AnimatePresence>
171
210
  <motion.div
172
211
  key="connect-card"
@@ -255,6 +294,165 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
255
294
  </div>
256
295
  </motion.div>
257
296
  </AnimatePresence>
297
+ ) : selectedProvider && selectedProvider.id === 'githubapp' ? (
298
+ <AnimatePresence>
299
+ {(!showGithubManage && initialProviderId === 'githubapp') ? (
300
+ <motion.div
301
+ key="github-card"
302
+ className="rounded-xl border max-w-xl w-full"
303
+ style={{
304
+ backgroundColor: 'var(--content-card-background)',
305
+ borderColor: 'var(--icon-button-secondary)',
306
+ }}
307
+ variants={cardVariants}
308
+ initial="initial"
309
+ animate="animate"
310
+ exit="exit"
311
+ transition={{ duration: 0.3 }}
312
+ >
313
+ <div className="p-6 flex flex-col items-center">
314
+ <div className="w-full flex items-center gap-3 mb-2 justify-center">
315
+ <ProviderIcon name="github" className="w-8 h-8 inline-block" />
316
+ <span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
317
+ </div>
318
+ <p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
319
+ You’ve successfully linked your GitHub account!
320
+ <br />
321
+ To complete your profile, you can optionally allow access to your <b>private repositories</b>. This is useful if you’d like to highlight private work or share additional contributions for verification.
322
+ <br /><br />
323
+ <span className="text-[var(--text-main)] font-medium">
324
+ Would you like to connect your private repositories?
325
+ </span>
326
+ </p>
327
+ <div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
328
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
329
+ <Button
330
+ className="w-full sm:w-auto text-[var(--text-main)] transition-colors border border-[var(--icon-button-secondary)]"
331
+ variant="destructive"
332
+ onClick={() => {
333
+ handleConnectBack();
334
+ }}
335
+ >
336
+ No, don&#39;t connect
337
+ </Button>
338
+ </motion.div>
339
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
340
+ <Button
341
+ className="w-full sm:w-auto bg-[var(--icon-accent)] text-white transition-colors font-semibold"
342
+ onClick={() => {
343
+ const opaqueState = window.crypto?.randomUUID?.() || Math.random().toString(36).substring(2);
344
+ const stateObj = { opaqueState, userId, companyId };
345
+ const stateString = encodeURIComponent(JSON.stringify(stateObj));
346
+ const redirectUrl = `https://github.com/apps/${githubAppSlugId}/installations/new?state=${stateString}`;
347
+ window.location.href = redirectUrl;
348
+ }}
349
+ >
350
+ <span className="flex items-center justify-center">
351
+ <ExternalLink className="w-4 h-4 mr-2" />
352
+ Yes, connect my private repos
353
+ </span>
354
+ </Button>
355
+ </motion.div>
356
+ </div>
357
+ </div>
358
+ </motion.div>
359
+ ) : (
360
+ <motion.div
361
+ key="github-manage"
362
+ className="rounded-xl border max-w-xl w-full"
363
+ style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
364
+ variants={cardVariants}
365
+ initial="initial"
366
+ animate="animate"
367
+ exit="exit"
368
+ >
369
+ <div className="sm:p-6 p-4">
370
+ <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
371
+ <ArrowLeft className="w-4 h-4" />
372
+ Back
373
+ </button>
374
+ <div className="text-center">
375
+ <div className="flex justify-center mb-4">
376
+ <ProviderIcon name="github" className="w-10 h-10" />
377
+ </div>
378
+ <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>Manage GitHub Connections</h3>
379
+ <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md">
380
+ Connect or disconnect your GitHub OAuth account and optional GitHub App for private repositories.
381
+ </p>
382
+ </div>
383
+
384
+ <div className="mt-6 space-y-4">
385
+ <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
386
+ <div className="flex items-center justify-between">
387
+ <div>
388
+ <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub OAuth</div>
389
+ <div className="sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>{isGithubConnected ? 'Connected' : 'Not connected'}</div>
390
+ </div>
391
+ <div className="flex items-center gap-2">
392
+ {isGithubConnected ? (
393
+ <Button
394
+ onClick={() => onDisconnect('github')}
395
+ className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
396
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
397
+ variant="destructive"
398
+ disabled={isDisconnecting === 'github'}
399
+ >
400
+ {isDisconnecting === 'github' ? <Spinner /> : <Unlink className="size-3 sm:size-4" />}
401
+ <span>Disconnect</span>
402
+ </Button>
403
+ ) : (
404
+ <Button
405
+ onClick={() => onOAuth('github')}
406
+ className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
407
+ >
408
+ <ExternalLink className="w-4 h-4 mr-2" />
409
+ Connect
410
+ </Button>
411
+ )}
412
+ </div>
413
+ </div>
414
+ </div>
415
+
416
+ <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
417
+ <div className="flex items-center justify-between">
418
+ <div>
419
+ <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub App (Private Repos)</div>
420
+ <div className="sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>{isGithubAppInstalled ? 'Installed' : 'Not installed'}</div>
421
+ </div>
422
+ <div className="flex items-center gap-2">
423
+ {isGithubAppInstalled ? (
424
+ <Button
425
+ onClick={() => { window.location.href = 'https://github.com/settings/installations'; }}
426
+ className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
427
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
428
+ variant="destructive"
429
+ >
430
+ <Unlink className="size-3 sm:size-4" />
431
+ <span>Uninstall</span>
432
+ </Button>
433
+ ) : (
434
+ <Button
435
+ onClick={() => {
436
+ const opaqueState = window.crypto?.randomUUID?.() || Math.random().toString(36).substring(2);
437
+ const stateObj = { opaqueState, userId };
438
+ const stateString = encodeURIComponent(JSON.stringify(stateObj));
439
+ const redirectUrl = `https://github.com/apps/${githubAppSlugId}/installations/new?state=${stateString}`;
440
+ window.location.href = redirectUrl;
441
+ }}
442
+ className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
443
+ >
444
+ <ExternalLink className="w-4 h-4 mr-2" />
445
+ Install
446
+ </Button>
447
+ )}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ </motion.div>
454
+ )}
455
+ </AnimatePresence>
258
456
  ) : (
259
457
  <Card className="border-[var(--icon-button-secondary)] pt-2" style={{ backgroundColor: 'var(--content-card-background)'}}>
260
458
  <AnimatePresence mode="wait">
@@ -279,7 +477,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
279
477
  const isConnected = connectedIds.has(providerId.toLowerCase());
280
478
  const needsReconnect = reconnectIds.has(providerId.toLowerCase());
281
479
  const isOauth = provider.connectionType === 'oauth'
282
- const connectedUrl = connected.find(c => c.id.toLowerCase() === providerId.toLowerCase())?.url;
480
+ const connectedUrl = connected.find(c => c.name.toLowerCase() === providerId.toLowerCase())?.url;
283
481
  const betaFlag = () => (
284
482
  <span
285
483
  className="ml-2 inline-block rounded-full px-2 py-0.5 text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200"
@@ -324,7 +522,49 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
324
522
  {provider.beta ? betaFlag() : null}
325
523
  </div>
326
524
 
327
- {isConnected && isDisconnecting !== providerId ? (
525
+ {providerId.toLowerCase() === 'github' ? (
526
+ <div className="flex items-center gap-2">
527
+ {(!isConnected && !isGithubAppInstalled) && (
528
+ <Button
529
+ onClick={() => setSelectedProviderIdAndCallback(providerId)}
530
+ className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
531
+ >
532
+ <Link2 className="size-3 sm:size-4" />
533
+ <span className="sm:text-base text-sm">Connect</span>
534
+ </Button>
535
+ )}
536
+ {(isConnected && !isGithubAppInstalled) && (
537
+ <Button
538
+ onClick={() => { setShowGithubManage(true); setSelectedProviderIdAndCallback('githubapp'); }}
539
+ className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
540
+ >
541
+ <Settings className="size-3 sm:size-4" />
542
+ <span className="sm:text-base text-sm">Manage</span>
543
+ </Button>
544
+ )}
545
+ {(isConnected && isGithubAppInstalled) && (
546
+ <div
547
+ className="relative flex items-center group"
548
+ onClick={() => { setShowGithubManage(true); setSelectedProviderIdAndCallback('githubapp'); }}
549
+ style={{ cursor: 'pointer' }}
550
+ >
551
+ <div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
552
+ <CheckCircle className="size-3 sm:size-4" />
553
+ <span className="text-sm font-medium">Connected</span>
554
+ </div>
555
+ <div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
556
+ <div className="flex items-center gap-1.5 text-red-600 text-sm hover:underline">
557
+ <Unlink className="size-3 sm:size-4" />
558
+ <span>Disconnect</span>
559
+ </div>
560
+ </div>
561
+ </div>
562
+ )}
563
+ {isDisconnecting === providerId && (
564
+ <Spinner />
565
+ )}
566
+ </div>
567
+ ) : isConnected && isDisconnecting !== providerId ? (
328
568
  <div className="relative flex items-center">
329
569
  <div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
330
570
  <CheckCircle className="size-3 sm:size-4" />
@@ -333,14 +573,13 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
333
573
  <div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
334
574
  <button
335
575
  onClick={() => onDisconnect(providerId)}
336
- className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
576
+ className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
337
577
  >
338
578
  <Unlink className="size-3 sm:size-4" />
339
579
  <span>Disconnect</span>
340
580
  </button>
341
581
  </div>
342
582
  </div>
343
-
344
583
  ) : isDisconnecting === providerId ? (
345
584
  <div className="relative flex items-center">
346
585
  <Spinner />
@@ -415,3 +654,4 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
415
654
  export default ConnectAccounts;
416
655
 
417
656
 
657
+
@@ -1,7 +1,4 @@
1
- export type ConnectedAccountLite = {
2
- id: string;
3
- url?: string | null;
4
- };
1
+ import { ConnectedAccount } from '../types';
5
2
 
6
3
  export type RedirectUriOverrides = Partial<Record<'github' | 'gitlab' | 'stackoverflow', string>>;
7
4
 
@@ -18,7 +15,7 @@ export interface ConnectAccountsProps {
18
15
  copy?: string;
19
16
  beta?: boolean;
20
17
  }>;
21
- connected: ConnectedAccountLite[];
18
+ connected: ConnectedAccount[];
22
19
  idToken: string;
23
20
  apiGatewayUrl?: string;
24
21
  companyId?: string;
@@ -39,6 +36,9 @@ export interface ConnectAccountsProps {
39
36
  isDisconnecting?: string | null;
40
37
  requiredProviders?: string[];
41
38
  companyName?: string;
39
+ initialProviderId?: string;
40
+ githubAppSlugId: string;
41
+ userId: string;
42
42
  }
43
43
 
44
44
 
package/src/types.ts CHANGED
@@ -22,18 +22,28 @@ export interface PublicBadgeData {
22
22
  assessmentResult: AssessmentResult;
23
23
  updatedAt: string;
24
24
  badgeImageUrl?: string;
25
- connectedAccounts?: {
26
- name: string;
27
- url?: string;
28
- handle?: string;
29
- observedAt?: string;
30
- }[];
25
+ connectedAccounts?: ConnectedAccount[];
31
26
  optOutScreening?: boolean;
32
27
  isPublic: boolean;
33
28
  companyName?: string;
34
29
  providersToReconnect?: string[];
35
30
  }
36
31
 
32
+ export interface AppInstallationInfo {
33
+ installationId: string;
34
+ setupAction: string;
35
+ stateOpaque: string;
36
+ verifiedAt: string;
37
+ }
38
+
39
+ export interface ConnectedAccount {
40
+ name: string;
41
+ url?: string;
42
+ handle?: string;
43
+ observedAt?: string;
44
+ app_installation_info?: AppInstallationInfo;
45
+ }
46
+
37
47
  export type User = {
38
48
  userId: string;
39
49
  name: string;
@@ -47,7 +57,7 @@ export type User = {
47
57
  latestBadgeId?: string;
48
58
  latestBadgeImageUrl?: string;
49
59
  assessments: PublicBadgeData[];
50
- connectedAccounts?: { name: string; url: string | null; handle: string | null; observedAt?: string }[];
60
+ connectedAccounts?: ConnectedAccount[];
51
61
  userPendingRetryBadgeId?: string;
52
62
  createdAt?: string;
53
63
  };