kyd-shared-badge 0.3.67 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.67",
3
+ "version": "0.3.69",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -185,14 +185,12 @@ const AppendixTables: React.FC<AppendixTableProps> = ({ type, sources, searchedA
185
185
  if (type !== 'business_rules') return;
186
186
  const key = sortableBusinessHeaders[header as keyof typeof sortableBusinessHeaders];
187
187
  if (!key) return;
188
- setSortBy(prev => {
189
- if (prev === key) {
190
- setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
191
- return prev;
192
- }
188
+ if (sortBy === key) {
189
+ setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
190
+ } else {
191
+ setSortBy(key);
193
192
  setSortDir('asc');
194
- return key;
195
- });
193
+ }
196
194
  };
197
195
 
198
196
  // Build quick lookup: category -> KYD Pillar (e.g., Technical or Risk)
@@ -69,7 +69,7 @@ export default function RiskCard({
69
69
  <div className="hidden group-hover:block absolute z-30 right-0 top-full mt-2 w-80">
70
70
  <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
71
71
  <div style={{ fontWeight: 600 }}>{title}</div>
72
- <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {pctGood}% (higher is better)</div>
72
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {100 - pctGood}% (lower is better)</div>
73
73
  <div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>{tooltipText || description}</div>
74
74
  </div>
75
75
  </div>
@@ -93,7 +93,7 @@ export default function RiskCard({
93
93
  <div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
94
94
  <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
95
95
  <div style={{ fontWeight: 600 }}>{title}</div>
96
- <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {pctGood}% (higher is better)</div>
96
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {100 - pctGood}% (lower is better)</div>
97
97
  <div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>{tooltipText || description}</div>
98
98
  </div>
99
99
  </div>
@@ -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';
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@ export type Provider = {
2
2
  id: string;
3
3
  name: string;
4
4
  authUrl?: string;
5
- connectionType?: 'oauth' | 'url';
5
+ connectionType?: 'oauth' | 'url' | 'link';
6
6
  iconColor?: string;
7
7
  colors: {
8
8
  bg: string;
@@ -488,5 +488,9 @@ export interface AssessmentTimeseries {
488
488
  final_percent: TimeseriesPoint[];
489
489
  genres: Record<string, TimeseriesPoint[]>;
490
490
  categories: Record<string, TimeseriesPoint[]>;
491
+ // New: daily request counts aggregated from S3 request logs
492
+ online_activity?: TimeseriesPoint[];
493
+ // New: dynamic skills categories counts over time (per assessment date)
494
+ skills_categories?: Record<string, TimeseriesPoint[]>;
491
495
  }
492
496