kyd-shared-badge 0.3.68 → 0.3.69
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 +1 -1
- package/src/connect/ConnectAccounts.tsx +221 -0
- package/src/connect/linkedin.ts +48 -0
- package/src/connect/oauth.ts +44 -0
- package/src/connect/types.ts +36 -0
- package/src/index.ts +4 -1
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { useRouter } from 'next/navigation';
|
|
3
|
+
import { ProviderIcon } from '../utils/provider';
|
|
4
|
+
import { normalizeLinkedInInput } from './linkedin';
|
|
5
|
+
import type { ConnectAccountsProps } from './types';
|
|
6
|
+
|
|
7
|
+
function byPriority(a: string, b: string) {
|
|
8
|
+
const pr = (id: string) => (id === 'github' ? 0 : id === 'linkedin' ? 1 : 2);
|
|
9
|
+
const da = pr(a.toLowerCase());
|
|
10
|
+
const db = pr(b.toLowerCase());
|
|
11
|
+
return da - db;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
15
|
+
const {
|
|
16
|
+
providers,
|
|
17
|
+
connected,
|
|
18
|
+
idToken,
|
|
19
|
+
apiGatewayUrl,
|
|
20
|
+
companyId,
|
|
21
|
+
needsReconnectIds,
|
|
22
|
+
sort = true,
|
|
23
|
+
buildOAuthState,
|
|
24
|
+
redirectUriOverrides,
|
|
25
|
+
onConnected,
|
|
26
|
+
onDisconnected,
|
|
27
|
+
onError,
|
|
28
|
+
className,
|
|
29
|
+
} = props;
|
|
30
|
+
|
|
31
|
+
const router = useRouter();
|
|
32
|
+
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
|
|
33
|
+
const [linkUrl, setLinkUrl] = useState('');
|
|
34
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
35
|
+
|
|
36
|
+
const apiBase = apiGatewayUrl || (typeof process !== 'undefined' ? (process.env.NEXT_PUBLIC_API_GATEWAY_URL as string) : '');
|
|
37
|
+
const connectedIds = useMemo(() => new Set((connected || []).map(c => c.id.toLowerCase())), [connected]);
|
|
38
|
+
const reconnectIds = useMemo(() => new Set((needsReconnectIds || []).map(id => id.toLowerCase())), [needsReconnectIds]);
|
|
39
|
+
|
|
40
|
+
const list = useMemo(() => {
|
|
41
|
+
const arr = [...providers];
|
|
42
|
+
if (!sort) return arr;
|
|
43
|
+
return arr.sort((a, b) => {
|
|
44
|
+
const pr = byPriority(a.id, b.id);
|
|
45
|
+
if (pr !== 0) return pr;
|
|
46
|
+
return a.name.localeCompare(b.name);
|
|
47
|
+
});
|
|
48
|
+
}, [providers, sort]);
|
|
49
|
+
|
|
50
|
+
const getRedirectUri = (providerId: string) => {
|
|
51
|
+
if (redirectUriOverrides && redirectUriOverrides[providerId as keyof typeof redirectUriOverrides]) {
|
|
52
|
+
return redirectUriOverrides[providerId as keyof typeof redirectUriOverrides] as string;
|
|
53
|
+
}
|
|
54
|
+
if (typeof window !== 'undefined') {
|
|
55
|
+
const origin = window.location.origin;
|
|
56
|
+
return `${origin}/api/auth/${providerId}/callback`;
|
|
57
|
+
}
|
|
58
|
+
return '';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const buildState = () => {
|
|
62
|
+
const baseStateObj: Record<string, string> = {};
|
|
63
|
+
if (companyId) baseStateObj.companyId = companyId;
|
|
64
|
+
const extra = typeof buildOAuthState === 'function' ? buildOAuthState() : undefined;
|
|
65
|
+
if (typeof extra === 'string') return extra;
|
|
66
|
+
const obj = { ...baseStateObj, ...(extra || {}) } as Record<string, string | number | boolean>;
|
|
67
|
+
// default to JSON string for safety across providers
|
|
68
|
+
try { return JSON.stringify(obj); } catch { return ''; }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const onDisconnect = async (providerId: string) => {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${apiBase}/user/disconnect`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Authorization': `Bearer ${idToken}`,
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({ provider: providerId, companyId })
|
|
80
|
+
});
|
|
81
|
+
const data = await res.json().catch(() => ({}));
|
|
82
|
+
if (!res.ok) throw new Error(data?.error || `Failed to disconnect ${providerId}.`);
|
|
83
|
+
if (onDisconnected) onDisconnected(providerId);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
const err = e as Error;
|
|
86
|
+
if (onError) onError(err.message || 'Failed to disconnect');
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const onSubmitLink = async (providerId: string) => {
|
|
91
|
+
const provider = providers.find(p => p.id.toLowerCase() === providerId.toLowerCase());
|
|
92
|
+
if (!provider || !provider.endpoint) return;
|
|
93
|
+
if (!linkUrl) return onError && onError(`Please enter your ${provider.name} profile URL.`);
|
|
94
|
+
try {
|
|
95
|
+
setIsSubmitting(true);
|
|
96
|
+
const urlToSend = provider.id.toLowerCase() === 'linkedin' ? normalizeLinkedInInput(linkUrl) : linkUrl;
|
|
97
|
+
const res = await fetch(`${apiBase}${provider.endpoint}`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
'Authorization': `Bearer ${idToken}`,
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify(companyId ? { profileUrl: urlToSend, companyId } : { profileUrl: urlToSend })
|
|
104
|
+
});
|
|
105
|
+
const data = await res.json().catch(() => ({}));
|
|
106
|
+
if (!res.ok) throw new Error(data?.error || `Failed to sync ${provider.name} profile.`);
|
|
107
|
+
setSelectedProviderId(null);
|
|
108
|
+
setLinkUrl('');
|
|
109
|
+
if (onConnected) onConnected(providerId);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const err = e as Error;
|
|
112
|
+
if (onError) onError(err.message || 'Failed to connect');
|
|
113
|
+
} finally {
|
|
114
|
+
setIsSubmitting(false);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const onOAuth = (providerId: string) => {
|
|
119
|
+
const id = providerId.toLowerCase();
|
|
120
|
+
const redirectUri = getRedirectUri(id);
|
|
121
|
+
const state = buildState();
|
|
122
|
+
// Lazy import builder to avoid circular deps
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
124
|
+
const { buildOAuthUrl } = require('./oauth');
|
|
125
|
+
const url = buildOAuthUrl({ providerId: id, redirectUri, state });
|
|
126
|
+
if (url) router.push(url);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className={className}>
|
|
131
|
+
{list.map((provider) => {
|
|
132
|
+
const providerId = provider.id;
|
|
133
|
+
const isConnected = connectedIds.has(providerId.toLowerCase());
|
|
134
|
+
const needsReconnect = reconnectIds.has(providerId.toLowerCase());
|
|
135
|
+
const isUrl = (provider.connectionType || 'url') === 'url' || provider.connectionType === 'link';
|
|
136
|
+
const isOauth = provider.connectionType === 'oauth';
|
|
137
|
+
return (
|
|
138
|
+
<div key={providerId} className="group flex items-center justify-between p-2 rounded-lg transition-colors hover:bg-[var(--icon-button-secondary)]/10">
|
|
139
|
+
<div className="flex items-center gap-3">
|
|
140
|
+
<ProviderIcon name={providerId} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
|
|
141
|
+
<span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
|
|
142
|
+
{provider.beta ? (
|
|
143
|
+
<span
|
|
144
|
+
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"
|
|
145
|
+
style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
|
|
146
|
+
>
|
|
147
|
+
Beta
|
|
148
|
+
</span>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{isConnected ? (
|
|
153
|
+
<div className="relative flex items-center">
|
|
154
|
+
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
155
|
+
{/* Check icon via CSS/emoji to avoid extra deps */}
|
|
156
|
+
<span className="w-4 h-4 inline-block">✔</span>
|
|
157
|
+
<span className="text-sm font-medium">Connected</span>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
160
|
+
<button
|
|
161
|
+
onClick={() => onDisconnect(providerId)}
|
|
162
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 hover:text-red-700"
|
|
163
|
+
>
|
|
164
|
+
<span className="w-3 h-3 inline-block">✕</span>
|
|
165
|
+
<span>Disconnect</span>
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="flex items-center gap-2">
|
|
171
|
+
{selectedProviderId === providerId && isUrl ? (
|
|
172
|
+
<form onSubmit={(e) => { e.preventDefault(); onSubmitLink(providerId); }} className="flex items-center gap-2">
|
|
173
|
+
<div className="relative">
|
|
174
|
+
<input
|
|
175
|
+
type="url"
|
|
176
|
+
value={linkUrl}
|
|
177
|
+
onChange={(e) => setLinkUrl(e.target.value)}
|
|
178
|
+
placeholder={provider.placeholder || 'https://example.com/your-profile'}
|
|
179
|
+
required
|
|
180
|
+
className="w-72 border bg-transparent p-2 text-sm rounded"
|
|
181
|
+
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
182
|
+
onPaste={providerId === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
|
|
183
|
+
onBlur={providerId === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
<button type="submit" disabled={isSubmitting} className="px-3 py-2 rounded bg-[var(--icon-accent)] text-white">
|
|
187
|
+
{isSubmitting ? 'Connecting…' : 'Connect'}
|
|
188
|
+
</button>
|
|
189
|
+
</form>
|
|
190
|
+
) : (
|
|
191
|
+
<>
|
|
192
|
+
<button
|
|
193
|
+
onClick={() => (isOauth ? onOAuth(providerId) : setSelectedProviderId(providerId))}
|
|
194
|
+
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"
|
|
195
|
+
>
|
|
196
|
+
<span className="sm:text-base text-sm">Connect</span>
|
|
197
|
+
</button>
|
|
198
|
+
{needsReconnect && (
|
|
199
|
+
<button
|
|
200
|
+
onClick={() => onDisconnect(providerId)}
|
|
201
|
+
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
|
|
202
|
+
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
203
|
+
>
|
|
204
|
+
<span className="w-3 h-3 inline-block">✕</span>
|
|
205
|
+
<span>Remove</span>
|
|
206
|
+
</button>
|
|
207
|
+
)}
|
|
208
|
+
</>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export default ConnectAccounts;
|
|
220
|
+
|
|
221
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function normalizeLinkedInInput(raw: string): string {
|
|
2
|
+
let working = (raw || '').trim();
|
|
3
|
+
if (!working) return '';
|
|
4
|
+
working = working.replace(/^['"]|['"]$/g, '');
|
|
5
|
+
const lower = working.toLowerCase();
|
|
6
|
+
|
|
7
|
+
const buildFromHandle = (handleRaw: string) => {
|
|
8
|
+
let handle = (handleRaw || '').trim();
|
|
9
|
+
handle = handle.replace(/^@+/, '');
|
|
10
|
+
handle = handle.replace(/^in\//i, '');
|
|
11
|
+
handle = handle.replace(/^\/+/, '');
|
|
12
|
+
handle = handle.split('?')[0].split('#')[0];
|
|
13
|
+
handle = handle.replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
14
|
+
handle = handle.replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
15
|
+
return handle ? `https://www.linkedin.com/in/${handle}` : 'https://www.linkedin.com/';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
if (lower.includes('linkedin.com')) {
|
|
19
|
+
let urlStr = working;
|
|
20
|
+
if (!/^https?:\/\//i.test(urlStr)) {
|
|
21
|
+
urlStr = 'https://' + urlStr.replace(/^\/+/, '');
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(urlStr);
|
|
25
|
+
const parts = (u.pathname || '').split('/').filter(Boolean);
|
|
26
|
+
const reserved = new Set(['pub', 'mwlite', 'profile', 'redir', 'sales', 'company', 'school', 'feed', 'posts', 'pulse']);
|
|
27
|
+
let handle = '';
|
|
28
|
+
const inIndex = parts.findIndex(p => p.toLowerCase() === 'in');
|
|
29
|
+
if (inIndex >= 0 && parts[inIndex + 1]) {
|
|
30
|
+
handle = parts[inIndex + 1];
|
|
31
|
+
} else {
|
|
32
|
+
const candidates = parts.filter(p => !reserved.has(p.toLowerCase()));
|
|
33
|
+
handle = candidates[candidates.length - 1] || '';
|
|
34
|
+
}
|
|
35
|
+
return buildFromHandle(handle);
|
|
36
|
+
} catch {
|
|
37
|
+
// fall through to handle mode
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (/^https?:\/\//i.test(working) || /^www\./i.test(working)) {
|
|
42
|
+
return working;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return buildFromHandle(working);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type KnownProvider = 'github' | 'gitlab' | 'stackoverflow';
|
|
2
|
+
|
|
3
|
+
const env = (key: string): string | undefined => {
|
|
4
|
+
// Access process.env directly; Next.js will inline NEXT_PUBLIC_* at build time
|
|
5
|
+
try { return (process.env as any)[key]; } catch { return undefined; }
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const CLIENT_IDS: Record<KnownProvider, string | undefined> = {
|
|
9
|
+
github: env('NEXT_PUBLIC_GITHUB_CLIENT_ID'),
|
|
10
|
+
gitlab: env('NEXT_PUBLIC_GITLAB_CLIENT_ID'),
|
|
11
|
+
stackoverflow: env('NEXT_PUBLIC_STACKOVERFLOW_CLIENT_ID'),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SCOPES: Record<KnownProvider, string> = {
|
|
15
|
+
github: 'read:user,repo',
|
|
16
|
+
gitlab: 'read_api read_user read_repository openid profile email',
|
|
17
|
+
stackoverflow: '',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function buildOAuthUrl(params: {
|
|
21
|
+
providerId: KnownProvider | string;
|
|
22
|
+
redirectUri: string;
|
|
23
|
+
state?: string;
|
|
24
|
+
scopeOverride?: string;
|
|
25
|
+
}): string {
|
|
26
|
+
const id = (params.providerId || '').toLowerCase() as KnownProvider;
|
|
27
|
+
const clientId = CLIENT_IDS[id as KnownProvider];
|
|
28
|
+
const scope = params.scopeOverride ?? DEFAULT_SCOPES[id as KnownProvider] ?? '';
|
|
29
|
+
const redirect = encodeURIComponent(params.redirectUri);
|
|
30
|
+
const state = params.state ? `&state=${encodeURIComponent(params.state)}` : '';
|
|
31
|
+
|
|
32
|
+
if (id === 'github') {
|
|
33
|
+
return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirect}${scope ? `&scope=${encodeURIComponent(scope)}` : ''}${state}`;
|
|
34
|
+
}
|
|
35
|
+
if (id === 'gitlab') {
|
|
36
|
+
return `https://gitlab.com/oauth/authorize?client_id=${clientId}&redirect_uri=${redirect}&response_type=code&scope=${encodeURIComponent(scope)}${state}`;
|
|
37
|
+
}
|
|
38
|
+
if (id === 'stackoverflow') {
|
|
39
|
+
return `https://stackoverflow.com/oauth?client_id=${clientId}&redirect_uri=${redirect}${scope ? `&scope=${encodeURIComponent(scope)}` : ''}${state}`;
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ConnectedAccountLite = {
|
|
2
|
+
id: string;
|
|
3
|
+
url?: string | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type RedirectUriOverrides = Partial<Record<'github' | 'gitlab' | 'stackoverflow', string>>;
|
|
7
|
+
|
|
8
|
+
export type StateFormat = 'query' | 'json';
|
|
9
|
+
|
|
10
|
+
export interface ConnectAccountsProps {
|
|
11
|
+
providers: Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
connectionType?: 'oauth' | 'url' | 'link';
|
|
15
|
+
iconColor?: string;
|
|
16
|
+
endpoint?: string; // required for url/link providers
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
copy?: string;
|
|
19
|
+
beta?: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
connected: ConnectedAccountLite[];
|
|
22
|
+
idToken: string;
|
|
23
|
+
apiGatewayUrl?: string;
|
|
24
|
+
companyId?: string;
|
|
25
|
+
needsReconnectIds?: string[];
|
|
26
|
+
sort?: boolean;
|
|
27
|
+
buildOAuthState?: () => Record<string, string | number | boolean> | string | undefined;
|
|
28
|
+
stateFormat?: StateFormat;
|
|
29
|
+
redirectUriOverrides?: RedirectUriOverrides;
|
|
30
|
+
onConnected?: (providerId: string) => void;
|
|
31
|
+
onDisconnected?: (providerId: string) => void;
|
|
32
|
+
onError?: (message: string) => void;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
package/src/index.ts
CHANGED
|
@@ -8,4 +8,7 @@ export { getBadgeImageUrl } from './components/ReportHeader';
|
|
|
8
8
|
|
|
9
9
|
export * from './components/charts/ChartTooltip';
|
|
10
10
|
export * from './components/charts/ChartEmptyState';
|
|
11
|
-
export * from './components/charts/ChartLegend';
|
|
11
|
+
export * from './components/charts/ChartLegend';
|
|
12
|
+
export { default as ConnectAccounts } from './connect/ConnectAccounts';
|
|
13
|
+
export * from './connect/types';
|
|
14
|
+
export { normalizeLinkedInInput } from './connect/linkedin';
|