@stackone/hub 0.1.0

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.
Files changed (48) hide show
  1. package/.github/workflows/node-ci.yml +20 -0
  2. package/.github/workflows/release-please.yml +37 -0
  3. package/.github/workflows/semantic-pull-request.yml +31 -0
  4. package/.nvmrc +1 -0
  5. package/.release-please-manifest.json +1 -0
  6. package/.yalc/@stackone/malachite/README.md +1 -0
  7. package/.yalc/@stackone/malachite/package.json +37 -0
  8. package/.yalc/@stackone/malachite/yalc.sig +1 -0
  9. package/CHANGELOG.md +30 -0
  10. package/README.md +225 -0
  11. package/biome.json +77 -0
  12. package/dev/index.html +11 -0
  13. package/dev/main.css +80 -0
  14. package/dev/main.tsx +96 -0
  15. package/dev/vite-env.d.ts +15 -0
  16. package/index.html +14 -0
  17. package/package.json +44 -0
  18. package/release-please-config.json +5 -0
  19. package/rollup.config.mjs +72 -0
  20. package/src/StackOneHub.tsx +99 -0
  21. package/src/WebComponentWrapper.tsx +14 -0
  22. package/src/index.ts +1 -0
  23. package/src/modules/csv-importer.tsx/CsvImporter.tsx +35 -0
  24. package/src/modules/integration-picker/IntegrationPicker.tsx +89 -0
  25. package/src/modules/integration-picker/components/IntegrationFields.tsx +115 -0
  26. package/src/modules/integration-picker/components/IntegrationList.tsx +71 -0
  27. package/src/modules/integration-picker/components/IntegrationPickerContent.tsx +107 -0
  28. package/src/modules/integration-picker/components/cardFooter.tsx +88 -0
  29. package/src/modules/integration-picker/components/cardTitle.tsx +51 -0
  30. package/src/modules/integration-picker/components/views/ErrorView.tsx +9 -0
  31. package/src/modules/integration-picker/components/views/IntegrationFormView.tsx +22 -0
  32. package/src/modules/integration-picker/components/views/IntegrationListView.tsx +19 -0
  33. package/src/modules/integration-picker/components/views/LoadingView.tsx +11 -0
  34. package/src/modules/integration-picker/components/views/SuccessView.tsx +10 -0
  35. package/src/modules/integration-picker/components/views/index.ts +5 -0
  36. package/src/modules/integration-picker/hooks/useIntegrationPicker.ts +221 -0
  37. package/src/modules/integration-picker/queries.ts +78 -0
  38. package/src/modules/integration-picker/types.ts +60 -0
  39. package/src/shared/categories.ts +55 -0
  40. package/src/shared/components/error.tsx +33 -0
  41. package/src/shared/components/errorBoundary.tsx +31 -0
  42. package/src/shared/components/loading.tsx +30 -0
  43. package/src/shared/components/success.tsx +33 -0
  44. package/src/shared/httpClient.ts +79 -0
  45. package/src/types/types.ts +1 -0
  46. package/tsconfig.json +19 -0
  47. package/vite.config.ts +11 -0
  48. package/yalc.lock +9 -0
@@ -0,0 +1,88 @@
1
+ import {
2
+ Button,
3
+ Flex,
4
+ FlexDirection,
5
+ FlexJustify,
6
+ FooterLinks,
7
+ Padded,
8
+ Spacer,
9
+ } from '@stackone/malachite';
10
+ import { useMemo } from 'react';
11
+ import { Integration } from '../types';
12
+
13
+ interface CardFooterProps {
14
+ selectedIntegration: Integration | null;
15
+ fullWidth?: boolean;
16
+ onBack?: () => void;
17
+ onNext: () => void;
18
+ }
19
+
20
+ const CardFooter: React.FC<CardFooterProps> = ({
21
+ fullWidth = true,
22
+ selectedIntegration,
23
+ onBack,
24
+ onNext,
25
+ }) => {
26
+ const buttons = useMemo(() => {
27
+ if (!selectedIntegration) return [];
28
+
29
+ const buttons: Array<{
30
+ label: string;
31
+ type: 'outline' | 'filled';
32
+ onClick: () => void;
33
+ disabled: boolean;
34
+ loading: boolean;
35
+ }> = [
36
+ {
37
+ label: 'Next',
38
+ type: 'filled' as const,
39
+ onClick: onNext,
40
+ disabled: false,
41
+ loading: false,
42
+ },
43
+ ];
44
+
45
+ if (onBack) {
46
+ buttons.push({
47
+ label: 'Back',
48
+ type: 'outline' as const,
49
+ onClick: onBack,
50
+ disabled: false,
51
+ loading: false,
52
+ });
53
+ }
54
+
55
+ return buttons;
56
+ }, [selectedIntegration, onBack, onNext]);
57
+
58
+ if (buttons.length === 0) {
59
+ return <FooterLinks fullWidth={fullWidth} />;
60
+ }
61
+
62
+ return (
63
+ <Spacer direction="horizontal" size={0} justifyContent="space-between">
64
+ <FooterLinks fullWidth={fullWidth} />
65
+ <Padded vertical="medium" horizontal="medium" fullHeight={false}>
66
+ <Flex direction={FlexDirection.Horizontal} justify={FlexJustify.Right}>
67
+ <Spacer direction="horizontal" size={10}>
68
+ {buttons.map((button) => (
69
+ <Button
70
+ key={button.label}
71
+ size="small"
72
+ type={button.type}
73
+ onClick={button.onClick}
74
+ disabled={button.disabled}
75
+ loading={button.loading}
76
+ iconPosition="end"
77
+ >
78
+ {button.label}
79
+ </Button>
80
+ ))}
81
+ </Spacer>
82
+ </Flex>
83
+ </Padded>
84
+ </Spacer>
85
+ );
86
+ };
87
+
88
+ export default CardFooter;
@@ -0,0 +1,51 @@
1
+ import {
2
+ Button,
3
+ Flex,
4
+ FlexAlign,
5
+ FlexDirection,
6
+ FlexGapSize,
7
+ FlexJustify,
8
+ Typography,
9
+ } from '@stackone/malachite';
10
+ import { Integration } from '../types';
11
+
12
+ interface CardTitleProps {
13
+ selectedIntegration: Integration;
14
+ onBack?: () => void;
15
+ guide?: { supportLink?: string; description: string };
16
+ }
17
+
18
+ const CardTitle: React.FC<CardTitleProps> = ({ selectedIntegration, onBack, guide }) => {
19
+ return (
20
+ <Flex
21
+ direction={FlexDirection.Horizontal}
22
+ align={FlexAlign.Center}
23
+ gapSize={FlexGapSize.Small}
24
+ justify={FlexJustify.SpaceBetween}
25
+ >
26
+ <Flex
27
+ direction={FlexDirection.Horizontal}
28
+ align={FlexAlign.Center}
29
+ gapSize={FlexGapSize.Small}
30
+ justify={FlexJustify.Left}
31
+ >
32
+ {onBack && <Button type="ghost" onClick={onBack} icon="←" size="small" />}
33
+ <img
34
+ src={`https://app.stackone.com/assets/logos/${selectedIntegration.provider}.png`}
35
+ alt={selectedIntegration.provider}
36
+ style={{ width: '24px', height: '24px' }}
37
+ />
38
+ <Typography.Text fontWeight="semi-bold" size="large">
39
+ {selectedIntegration.name}
40
+ </Typography.Text>
41
+ </Flex>
42
+ <Typography.Link href={guide?.supportLink} target="_blank">
43
+ <Button type="outline" size="medium">
44
+ Connection guide
45
+ </Button>
46
+ </Typography.Link>
47
+ </Flex>
48
+ );
49
+ };
50
+
51
+ export default CardTitle;
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+
3
+ interface ErrorViewProps {
4
+ message: string;
5
+ }
6
+
7
+ export const ErrorView: React.FC<ErrorViewProps> = ({ message }) => {
8
+ return <div>Error: {message}</div>;
9
+ };
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { ConnectorConfigField } from '../../types';
3
+ import { IntegrationForm } from '../IntegrationFields';
4
+
5
+ interface IntegrationFormViewProps {
6
+ fields: ConnectorConfigField[];
7
+ error?: {
8
+ message: string;
9
+ provider_response: string;
10
+ };
11
+ guide?: { supportLink?: string; description: string };
12
+ onChange: (data: Record<string, string>) => void;
13
+ }
14
+
15
+ export const IntegrationFormView: React.FC<IntegrationFormViewProps> = ({
16
+ fields,
17
+ error,
18
+ guide,
19
+ onChange,
20
+ }) => {
21
+ return <IntegrationForm fields={fields} error={error} onChange={onChange} guide={guide} />;
22
+ };
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { Integration } from '../../types';
3
+ import { IntegrationList } from '../IntegrationList';
4
+
5
+ interface IntegrationListViewProps {
6
+ integrations: Integration[];
7
+ onSelect: (integration: Integration) => void;
8
+ }
9
+
10
+ export const IntegrationListView: React.FC<IntegrationListViewProps> = ({
11
+ integrations,
12
+ onSelect,
13
+ }) => {
14
+ if (!integrations.length) {
15
+ return <div>No integrations found.</div>;
16
+ }
17
+
18
+ return <IntegrationList integrations={integrations} onSelect={onSelect} />;
19
+ };
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import { Loading } from '../../../../shared/components/loading';
3
+
4
+ interface LoadingViewProps {
5
+ title: string;
6
+ description: string;
7
+ }
8
+
9
+ export const LoadingView: React.FC<LoadingViewProps> = ({ title, description }) => {
10
+ return <Loading title={title} description={description} />;
11
+ };
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import Success from '../../../../shared/components/success';
3
+
4
+ interface SuccessViewProps {
5
+ integrationName: string;
6
+ }
7
+
8
+ export const SuccessView: React.FC<SuccessViewProps> = ({ integrationName }) => {
9
+ return <Success integrationName={integrationName} />;
10
+ };
@@ -0,0 +1,5 @@
1
+ export { IntegrationListView } from './IntegrationListView';
2
+ export { IntegrationFormView } from './IntegrationFormView';
3
+ export { LoadingView } from './LoadingView';
4
+ export { ErrorView } from './ErrorView';
5
+ export { SuccessView } from './SuccessView';
@@ -0,0 +1,221 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import {
4
+ connectAccount,
5
+ getAccountData,
6
+ getConnectorConfig,
7
+ getHubData,
8
+ updateAccount,
9
+ } from '../queries';
10
+ import { Integration } from '../types';
11
+
12
+ const DUMMY_VALUE = 'totally-fake-value';
13
+
14
+ interface UseIntegrationPickerProps {
15
+ token: string;
16
+ baseUrl: string;
17
+ accountId?: string;
18
+ onSuccess?: () => void;
19
+ }
20
+
21
+ export const useIntegrationPicker = ({
22
+ token,
23
+ baseUrl,
24
+ accountId,
25
+ onSuccess,
26
+ }: UseIntegrationPickerProps) => {
27
+ const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
28
+ const [formData, setFormData] = useState<Record<string, string>>({});
29
+ const [connectionState, setConnectionState] = useState<{
30
+ loading: boolean;
31
+ success: boolean;
32
+ error?: {
33
+ message: string;
34
+ provider_response: string;
35
+ };
36
+ }>({
37
+ loading: false,
38
+ success: false,
39
+ });
40
+
41
+ // Fetch account data for editing scenario
42
+ const {
43
+ data: accountData,
44
+ isLoading: isLoadingAccountData,
45
+ error: errorAccountData,
46
+ } = useQuery({
47
+ queryKey: ['accountData', accountId],
48
+ queryFn: async () => {
49
+ if (!accountId) return null;
50
+ return getAccountData(baseUrl, token, accountId);
51
+ },
52
+ enabled: !!accountId,
53
+ });
54
+
55
+ // Fetch hub data (list of integrations)
56
+ const {
57
+ data: hubData,
58
+ isLoading: isLoadingHubData,
59
+ error: errorHubData,
60
+ } = useQuery({
61
+ queryKey: ['hubData', accountData?.provider],
62
+ queryFn: () => {
63
+ // For account editing: fetch hub data with specific provider
64
+ if (accountData?.provider) {
65
+ return getHubData(token, baseUrl, accountData.provider);
66
+ }
67
+ // For new setup: fetch all integrations
68
+ return getHubData(token, baseUrl);
69
+ },
70
+ enabled: !accountId || !!accountData, // Enable when no accountId OR when we have account data
71
+ });
72
+
73
+ // Auto-select integration when editing an account
74
+ useEffect(() => {
75
+ if (accountData && hubData) {
76
+ const matchingIntegration = hubData.integrations.find(
77
+ (integration) => integration.provider === accountData.provider,
78
+ );
79
+ setSelectedIntegration(matchingIntegration ?? null);
80
+ }
81
+ }, [accountData, hubData]);
82
+
83
+ // Fetch connector configuration
84
+ const {
85
+ data: connectorData,
86
+ isLoading: isLoadingConnectorData,
87
+ error: errorConnectorData,
88
+ } = useQuery({
89
+ queryKey: ['connectorData', selectedIntegration?.provider, accountData?.provider],
90
+ queryFn: async () => {
91
+ if (selectedIntegration) {
92
+ return getConnectorConfig(baseUrl, token, selectedIntegration.provider);
93
+ }
94
+ if (accountData) {
95
+ return getConnectorConfig(baseUrl, token, accountData.provider);
96
+ }
97
+ return null;
98
+ },
99
+ enabled: Boolean(selectedIntegration) || Boolean(accountData),
100
+ });
101
+
102
+ // Extract fields and guide from connector config
103
+ const { fields, guide } = useMemo(() => {
104
+ if (!connectorData || !selectedIntegration) {
105
+ return { fields: [] };
106
+ }
107
+
108
+ const authConfig =
109
+ connectorData.authentication?.[selectedIntegration.authentication_config_key];
110
+ const authConfigForEnvironment = authConfig?.[selectedIntegration.environment];
111
+
112
+ const baseFields = authConfigForEnvironment?.fields || [];
113
+
114
+ const fieldsWithPrefilledValues = baseFields.map((field) => {
115
+ const setupValue = accountData?.setupInformation?.[field.key];
116
+
117
+ if (accountData && (field.secret || field.type === 'password')) {
118
+ return {
119
+ ...field,
120
+ value: DUMMY_VALUE,
121
+ };
122
+ }
123
+
124
+ return {
125
+ ...field,
126
+ value: setupValue !== undefined ? setupValue : field.value,
127
+ };
128
+ });
129
+
130
+ return {
131
+ fields: fieldsWithPrefilledValues,
132
+ guide: authConfigForEnvironment?.guide,
133
+ };
134
+ }, [connectorData, selectedIntegration, accountData]);
135
+
136
+ const handleConnect = useCallback(async () => {
137
+ if (!selectedIntegration) return;
138
+
139
+ setConnectionState({ loading: true, success: false });
140
+
141
+ try {
142
+ // Clean up dummy values for secret fields before submission
143
+ const cleanedFormData = { ...formData };
144
+ if (accountData) {
145
+ fields.forEach((field) => {
146
+ if (
147
+ (field.secret || field.type === 'password') &&
148
+ cleanedFormData[field.key] === DUMMY_VALUE
149
+ ) {
150
+ delete cleanedFormData[field.key];
151
+ }
152
+ });
153
+ }
154
+
155
+ if (accountId) {
156
+ await updateAccount(
157
+ baseUrl,
158
+ accountId,
159
+ token,
160
+ selectedIntegration.provider,
161
+ cleanedFormData,
162
+ );
163
+ } else {
164
+ await connectAccount(baseUrl, token, selectedIntegration.provider, cleanedFormData);
165
+ }
166
+
167
+ setConnectionState({ loading: false, success: true });
168
+ setTimeout(() => {
169
+ onSuccess?.();
170
+ }, 2000);
171
+ } catch (error) {
172
+ const parsedError = JSON.parse((error as Error).message) as {
173
+ status: number;
174
+ message: string;
175
+ };
176
+
177
+ const doubleParsedError = JSON.parse(parsedError.message) as {
178
+ message: string;
179
+ provider_response: string;
180
+ };
181
+
182
+ setConnectionState({
183
+ loading: false,
184
+ success: false,
185
+ error: {
186
+ message: doubleParsedError.message,
187
+ provider_response: doubleParsedError.provider_response,
188
+ },
189
+ });
190
+ }
191
+ }, [baseUrl, token, selectedIntegration, formData, onSuccess, accountData, fields, accountId]);
192
+
193
+ const isLoading = isLoadingHubData || isLoadingConnectorData || isLoadingAccountData;
194
+ const hasError = !!(errorHubData || errorConnectorData || errorAccountData);
195
+
196
+ return {
197
+ // Data
198
+ hubData,
199
+ accountData,
200
+ connectorData,
201
+ selectedIntegration,
202
+ fields,
203
+ guide,
204
+
205
+ // State
206
+ formData,
207
+ connectionState,
208
+ isLoading,
209
+ hasError,
210
+
211
+ // Errors
212
+ errorHubData,
213
+ errorConnectorData,
214
+ errorAccountData,
215
+
216
+ // Actions
217
+ setSelectedIntegration,
218
+ setFormData,
219
+ handleConnect,
220
+ };
221
+ };
@@ -0,0 +1,78 @@
1
+ import { getRequest, patchRequest, postRequest } from '../../shared/httpClient';
2
+ import { AccountData, ConnectorConfig, HubData } from './types';
3
+
4
+ export const getHubData = async (token: string, baseUrl: string, provider?: string) => {
5
+ const headers: Record<string, string> = {
6
+ 'Content-Type': 'application/json',
7
+ 'x-hub-session-token': token,
8
+ };
9
+
10
+ // Add provider header when filtering by specific provider
11
+ if (provider) {
12
+ headers['x-hub-provider'] = provider;
13
+ }
14
+
15
+ return await getRequest<HubData>({
16
+ url: `${baseUrl}/hub/connectors`,
17
+ headers,
18
+ });
19
+ };
20
+
21
+ export const getConnectorConfig = async (baseUrl: string, token: string, connectorKey: string) => {
22
+ return await getRequest<ConnectorConfig>({
23
+ url: `${baseUrl}/hub/connectors/${connectorKey}`,
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ 'x-hub-session-token': token,
27
+ },
28
+ });
29
+ };
30
+
31
+ export const connectAccount = async (
32
+ baseUrl: string,
33
+ token: string,
34
+ provider: string,
35
+ credentials: Record<string, unknown>,
36
+ ) => {
37
+ return await postRequest<ConnectorConfig>({
38
+ url: `${baseUrl}/hub/accounts`,
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ 'x-hub-session-token': token,
42
+ },
43
+ body: {
44
+ provider,
45
+ credentials,
46
+ },
47
+ });
48
+ };
49
+
50
+ export const updateAccount = async (
51
+ baseUrl: string,
52
+ accountId: string,
53
+ token: string,
54
+ provider: string,
55
+ credentials: Record<string, unknown>,
56
+ ) => {
57
+ return await patchRequest<ConnectorConfig>({
58
+ url: `${baseUrl}/hub/accounts/${accountId}`,
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'x-hub-session-token': token,
62
+ },
63
+ body: {
64
+ provider,
65
+ credentials,
66
+ },
67
+ });
68
+ };
69
+
70
+ export const getAccountData = async (baseUrl: string, token: string, accountId: string) => {
71
+ return await getRequest<AccountData>({
72
+ url: `${baseUrl}/hub/accounts/${accountId}`,
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'x-hub-session-token': token,
76
+ },
77
+ });
78
+ };
@@ -0,0 +1,60 @@
1
+ export interface Integration {
2
+ active: boolean;
3
+ name: string;
4
+ provider: string;
5
+ type: string;
6
+ version: string;
7
+ authentication_config_key: string;
8
+ environment: string;
9
+ }
10
+
11
+ export interface HubData {
12
+ integrations: Array<Integration>;
13
+ }
14
+
15
+ export interface ConnectorConfigField {
16
+ type?: 'text' | 'password' | 'number' | 'select' | 'text_area';
17
+ label: string;
18
+ key: string;
19
+ required: boolean;
20
+ readOnly: boolean;
21
+ secret: boolean;
22
+ placeholder: string;
23
+ options?: Array<{
24
+ label: string;
25
+ value: string;
26
+ }>;
27
+ guide?: {
28
+ description: string;
29
+ tooltip: string;
30
+ };
31
+ value?: string | number;
32
+ condition?: string;
33
+ validation?: {
34
+ type: 'html-pattern' | 'domain';
35
+ pattern: string;
36
+ error?: string;
37
+ };
38
+ }
39
+
40
+ export interface ConnectorConfig {
41
+ key: string;
42
+ name: string;
43
+ authentication: {
44
+ [authKey: string]: {
45
+ [environment: string]: {
46
+ fields: Array<ConnectorConfigField>;
47
+ guide?: {
48
+ supportLink?: string;
49
+ description: string;
50
+ };
51
+ };
52
+ };
53
+ };
54
+ }
55
+
56
+ export interface AccountData {
57
+ account_id: string;
58
+ provider: string;
59
+ setupInformation: Record<string, string>;
60
+ }
@@ -0,0 +1,55 @@
1
+ export const CATEGORIES = [
2
+ 'ats',
3
+ 'crm',
4
+ 'hris',
5
+ 'marketing',
6
+ 'iam',
7
+ 'lms',
8
+ 'documents',
9
+ 'ticketing',
10
+ 'screening',
11
+ 'messaging',
12
+ ];
13
+
14
+ export const CATEGORIES_WITH_LABELS = [
15
+ {
16
+ label: 'HRIS',
17
+ value: 'hris',
18
+ },
19
+ {
20
+ label: 'ATS',
21
+ value: 'ats',
22
+ },
23
+ {
24
+ label: 'CRM',
25
+ value: 'crm',
26
+ },
27
+ {
28
+ label: 'Marketing',
29
+ value: 'marketing',
30
+ },
31
+ {
32
+ label: 'IAM',
33
+ value: 'iam',
34
+ },
35
+ {
36
+ label: 'LMS',
37
+ value: 'lms',
38
+ },
39
+ {
40
+ label: 'Documents',
41
+ value: 'documents',
42
+ },
43
+ {
44
+ label: 'Ticketing',
45
+ value: 'ticketing',
46
+ },
47
+ {
48
+ label: 'Screening',
49
+ value: 'screening',
50
+ },
51
+ {
52
+ label: 'Messaging',
53
+ value: 'messaging',
54
+ },
55
+ ];
@@ -0,0 +1,33 @@
1
+ import {
2
+ CustomIcons,
3
+ Flex,
4
+ FlexAlign,
5
+ FlexDirection,
6
+ FlexGapSize,
7
+ FlexJustify,
8
+ Typography,
9
+ useTheme,
10
+ } from '@stackone/malachite';
11
+
12
+ const ErrorContainer: React.FC = () => {
13
+ const { colors } = useTheme();
14
+ return (
15
+ <Flex
16
+ justify={FlexJustify.Center}
17
+ align={FlexAlign.Center}
18
+ direction={FlexDirection.Vertical}
19
+ gapSize={FlexGapSize.Small}
20
+ fullHeight
21
+ >
22
+ <CustomIcons.RejectIcon style={{ color: colors.redForeground }} />
23
+ <Typography.Text fontWeight="bold" size="large">
24
+ Error
25
+ </Typography.Text>
26
+ <Typography.SecondaryText>
27
+ Something went wrong, our team has been notified.
28
+ </Typography.SecondaryText>
29
+ </Flex>
30
+ );
31
+ };
32
+
33
+ export default ErrorContainer;