EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
Integrations.jsx
Go to the documentation of this file.
1import { useState, useEffect } from 'react';
2import { apiFetch } from '../lib/api';
3import MainLayout from '../components/Layout/MainLayout';
4import './Integrations.css';
5
6function Integrations() {
7 // Exchange integration state
8 const [exchangeEmail, setExchangeEmail] = useState('');
9 const [exchangeStatus, setExchangeStatus] = useState('');
10 const [exchangeLoading, setExchangeLoading] = useState(false);
11 const [tenantExchangeEmail, setTenantExchangeEmail] = useState('');
12
13 // Simulate login (replace with real OAuth flow)
14 const handleExchangeLogin = async () => {
15 setExchangeLoading(true);
16 setExchangeStatus('');
17 try {
18 // TODO: Replace with backend OAuth/Graph login
19 await new Promise(res => setTimeout(res, 1200));
20 setTenantExchangeEmail(exchangeEmail);
21 setExchangeStatus('Connected to Exchange as ' + exchangeEmail);
22 } catch (err) {
23 setExchangeStatus('Login failed: ' + err.message);
24 }
25 setExchangeLoading(false);
26 };
27 // selectedTenantId should come from props/context, not localStorage
28 const [selectedTenantId, setSelectedTenantId] = useState('');
29 const [integrations, setIntegrations] = useState([]);
30 const [loading, setLoading] = useState(true);
31 const [error, setError] = useState(null);
32 const [configuring, setConfiguring] = useState(null); // integration being configured
33 const [configForm, setConfigForm] = useState({});
34 const [tenantMap, setTenantMap] = useState({});
35 const [meshcentralEnabled, setMeshcentralEnabled] = useState(true);
36 const [meshcentralLoading, setMeshcentralLoading] = useState(false);
37 // No need for separate root detection, handled by TenantSelector
38
39 // Stripe Connect state
40 const [stripeConfig, setStripeConfig] = useState(null);
41 const [stripeLoading, setStripeLoading] = useState(false);
42 const [stripeError, setStripeError] = useState(null);
43
44 // Integration categories and definitions
45 const integrationGroups = {
46 'Remote Desktop': [
47 {
48 type: 'meshcentral',
49 name: 'MeshCentral',
50 description: 'Open-source web-based remote management and desktop control. Supports RDP, VNC, terminal, and file transfer with browser-based access.',
51 icon: '🌐',
52 iconBg: 'linear-gradient(135deg, #0078d4 0%, #005ba3 100%)',
53 details: 'MeshCentral provides full remote control, file transfer, and terminal access directly in your browser.',
54 serverInfo: 'mesh.everydaytech.au:4430'
55 }
56 ],
57 'Communication': [
58 {
59 type: 'slack',
60 name: 'Slack',
61 description: 'Send real-time notifications and ticket updates to Slack channels. Keep your team informed instantly.',
62 icon: 'đŸ’Ŧ',
63 iconBg: '#4A154B',
64 configFields: [
65 { key: 'webhook_url', label: 'Webhook URL', type: 'text', required: true },
66 { key: 'default_channel', label: 'Default Channel', type: 'text', required: false },
67 { key: 'notify_on_ticket_create', label: 'Notify on Ticket Create', type: 'checkbox', required: false }
68 ]
69 },
70 {
71 type: 'mailgun',
72 name: 'Mailgun',
73 description: 'Powerful email delivery service for sending ticket notifications, alerts, and transactional emails reliably.',
74 icon: '📧',
75 iconBg: '#F06A6A',
76 configFields: [
77 { key: 'api_key', label: 'API Key', type: 'password', required: true },
78 { key: 'domain', label: 'Domain', type: 'text', required: true },
79 { key: 'from_email', label: 'From Email', type: 'email', required: true },
80 { key: 'from_name', label: 'From Name', type: 'text', required: false }
81 ]
82 }
83 ],
84 'Accounting': [
85 {
86 type: 'xero',
87 name: 'Xero',
88 description: 'Seamlessly sync invoices, payments, and financial data with Xero accounting software. Automated bookkeeping.',
89 icon: '💰',
90 iconBg: '#13B5EA',
91 configFields: [
92 { key: 'client_id', label: 'Client ID', type: 'text', required: true },
93 { key: 'client_secret', label: 'Client Secret', type: 'password', required: true },
94 { key: 'tenant_id', label: 'Xero Tenant ID', type: 'text', required: true },
95 { key: 'auto_sync_invoices', label: 'Auto-sync Invoices', type: 'checkbox', required: false }
96 ]
97 }
98 ],
99 'Payments': [
100 {
101 type: 'stripe',
102 name: 'Stripe Connect',
103 description: 'Accept payments, manage subscriptions, and automate billing with Stripe. Secure payment processing with multi-tenant isolation.',
104 icon: 'đŸ’ŗ',
105 iconBg: 'linear-gradient(135deg, #635BFF 0%, #0073E6 100%)',
106 details: 'Connect your Stripe account to enable payment processing, recurring billing, and invoice payments.',
107 requiresOAuth: true // Special handling for OAuth flow
108 }
109 ]
110 };
111
112 const getTenantHeaders = async (baseHeaders = {}) => {
113 const headers = { ...baseHeaders };
114 const token = localStorage.getItem('token');
115
116 if (!token) return headers;
117
118 // Parse token to check if user is MSP
119 try {
120 const payload = JSON.parse(atob(token.split('.')[1]));
121 const isMSP = payload.role === 'msp' || payload.role === 'admin';
122
123 if (isMSP && selectedTenantId) {
124 // Fetch tenant subdomain
125 const tenantRes = await apiFetch('/tenants', {
126 headers: { Authorization: `Bearer ${token}`, 'X-Tenant-Subdomain': 'admin' }
127 });
128 if (tenantRes.ok) {
129 const data = await tenantRes.json();
130 const tenants = Array.isArray(data) ? data : (data.tenants || []);
131 const tenant = tenants.find(t => String(t.tenant_id) === String(selectedTenantId));
132 if (tenant) {
133 headers['X-Tenant-Subdomain'] = tenant.subdomain;
134 }
135 }
136 }
137 } catch (err) {
138 console.error('Error parsing token:', err);
139 }
140
141 return headers;
142 };
143
144 const fetchIntegrations = async () => {
145 setLoading(true);
146 try {
147 const token = localStorage.getItem('token');
148 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
149
150 const res = await apiFetch('/integrations');
151 if (!res.ok) throw new Error('Failed to load integrations');
152
153 const data = await res.json();
154 setIntegrations(data);
155
156 // Check if MeshCentral is enabled
157 const meshcentral = data.find(i => i.integration_type === 'meshcentral');
158 setMeshcentralEnabled(meshcentral?.is_active ?? true); // Default to true
159
160 setError(null);
161 } catch (err) {
162 setError(err.message || 'Error loading integrations');
163 } finally {
164 setLoading(false);
165 }
166 };
167
168 useEffect(() => {
169 fetchIntegrations();
170 fetchStripeConfig();
171 }, [selectedTenantId]);
172
173 // Fetch Stripe Connect configuration
174 const fetchStripeConfig = async () => {
175 try {
176 const token = localStorage.getItem('token');
177 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
178
179 const res = await apiFetch('/stripe/config', {
180 headers
181 });
182
183 if (!res.ok) {
184 if (res.status === 404) {
185 setStripeConfig({ configured: false });
186 return;
187 }
188 throw new Error('Failed to fetch Stripe configuration');
189 }
190
191 const data = await res.json();
192 setStripeConfig(data);
193 } catch (err) {
194 console.error('Error fetching Stripe config:', err);
195 setStripeConfig({ configured: false });
196 }
197 };
198
199 // Start Stripe Connect onboarding
200 const handleStripeConnect = async () => {
201 setStripeLoading(true);
202 setStripeError(null);
203
204 try {
205 const token = localStorage.getItem('token');
206 const headers = await getTenantHeaders({
207 Authorization: `Bearer ${token}`,
208 'Content-Type': 'application/json'
209 });
210
211 // Get user email from token
212 const payload = JSON.parse(atob(token.split('.')[1]));
213 const email = payload.email || 'billing@example.com';
214
215 const res = await apiFetch('/stripe/connect/start', {
216 method: 'POST',
217 headers: { 'Content-Type': 'application/json' },
218 body: JSON.stringify({
219 email: email,
220 country: 'US'
221 })
222 });
223
224 if (!res.ok) {
225 const error = await res.json();
226 throw new Error(error.message || 'Failed to start Stripe onboarding');
227 }
228
229 const data = await res.json();
230
231 // Redirect to Stripe onboarding
232 window.location.href = data.onboarding_url;
233 } catch (err) {
234 console.error('Stripe Connect error:', err);
235 setStripeError(err.message);
236 } finally {
237 setStripeLoading(false);
238 }
239 };
240
241 // Sync Stripe account status
242 const handleStripeSyncStatus = async () => {
243 setStripeLoading(true);
244 setStripeError(null);
245
246 try {
247 const token = localStorage.getItem('token');
248 const headers = await getTenantHeaders({
249 Authorization: `Bearer ${token}`,
250 'Content-Type': 'application/json'
251 });
252
253 const res = await apiFetch('/stripe/connect/sync', {
254 method: 'POST',
255 headers: { 'Content-Type': 'application/json' }
256 });
257
258 if (!res.ok) {
259 throw new Error('Failed to sync Stripe account status');
260 }
261
262 // Refresh config after sync
263 await fetchStripeConfig();
264 } catch (err) {
265 console.error('Stripe sync error:', err);
266 setStripeError(err.message);
267 } finally {
268 setStripeLoading(false);
269 }
270 };
271
272 // Disconnect Stripe account
273 const handleStripeDisconnect = async () => {
274 if (!confirm('Are you sure you want to disconnect your Stripe account? This will disable payment processing.')) {
275 return;
276 }
277
278 setStripeLoading(true);
279 setStripeError(null);
280
281 try {
282 const token = localStorage.getItem('token');
283 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
284
285 const res = await apiFetch('/stripe/connect', {
286 method: 'DELETE'
287 });
288
289 if (!res.ok) {
290 throw new Error('Failed to disconnect Stripe account');
291 }
292
293 // Refresh config
294 await fetchStripeConfig();
295 } catch (err) {
296 console.error('Stripe disconnect error:', err);
297 setStripeError(err.message);
298 } finally {
299 setStripeLoading(false);
300 }
301 };
302
303 // Check for Stripe onboarding return
304 useEffect(() => {
305 const urlParams = new URLSearchParams(window.location.search);
306 const onboardingStatus = urlParams.get('stripe_onboarding');
307
308 if (onboardingStatus === 'success') {
309 // Sync account status after successful onboarding
310 handleStripeSyncStatus();
311 // Clean URL
312 window.history.replaceState({}, document.title, window.location.pathname);
313 } else if (onboardingStatus === 'refresh') {
314 // Link expired - show message
315 setStripeError('Onboarding link expired. Please try connecting again.');
316 window.history.replaceState({}, document.title, window.location.pathname);
317 }
318 }, []);
319
320 useEffect(() => {
321 fetchIntegrations();
322 }, [selectedTenantId]);
323
324 const handleConfigure = (integration) => {
325 const existing = integrations.find(i => i.integration_type === integration.type);
326
327 if (existing) {
328 // Edit existing
329 setConfigForm(existing.config || {});
330 } else {
331 // New integration - initialize with defaults
332 const defaults = {};
333 integration.configFields.forEach(field => {
334 defaults[field.key] = field.type === 'checkbox' ? false : '';
335 });
336 setConfigForm(defaults);
337 }
338
339 setConfiguring(integration);
340 };
341
342 const handleSaveIntegration = async () => {
343 try {
344 const token = localStorage.getItem('token');
345 const headers = await getTenantHeaders({
346 Authorization: `Bearer ${token}`,
347 'Content-Type': 'application/json'
348 });
349
350 const existing = integrations.find(i => i.integration_type === configuring.type);
351 const method = existing ? 'PUT' : 'POST';
352 const url = existing
353 ? `/integrations/${existing.integration_id}`
354 : '/integrations';
355
356 const body = {
357 integration_type: configuring.type,
358 config: configForm,
359 is_active: true
360 };
361
362 const res = await apiFetch(url, {
363 method,
364 headers: { 'Content-Type': 'application/json' },
365 body: JSON.stringify(body)
366 });
367
368 if (!res.ok) throw new Error('Failed to save integration');
369
370 await fetchIntegrations();
371 setConfiguring(null);
372 setConfigForm({});
373 } catch (err) {
374 alert(err.message || 'Error saving integration');
375 }
376 };
377
378 const handleToggleIntegration = async (integration) => {
379 try {
380 const token = localStorage.getItem('token');
381 const headers = await getTenantHeaders({
382 Authorization: `Bearer ${token}`,
383 'Content-Type': 'application/json'
384 });
385
386 const res = await apiFetch(`/integrations/${integration.integration_id}`, {
387 method: 'PUT',
388 headers: { 'Content-Type': 'application/json' },
389 body: JSON.stringify({
390 ...integration,
391 is_active: !integration.is_active
392 })
393 });
394
395 if (!res.ok) throw new Error('Failed to update integration');
396
397 await fetchIntegrations();
398 } catch (err) {
399 alert(err.message || 'Error updating integration');
400 }
401 };
402
403 const handleDeleteIntegration = async (integration) => {
404 if (!confirm(`Are you sure you want to delete the ${integration.integration_type} integration?`)) {
405 return;
406 }
407
408 try {
409 const token = localStorage.getItem('token');
410 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
411
412 const res = await apiFetch(`/integrations/${integration.integration_id}`, {
413 method: 'DELETE'
414 });
415
416 if (!res.ok) throw new Error('Failed to delete integration');
417
418 await fetchIntegrations();
419 } catch (err) {
420 alert(err.message || 'Error deleting integration');
421 }
422 };
423
424 const isIntegrationConfigured = (type) => {
425 return integrations.some(i => i.integration_type === type);
426 };
427
428 const getIntegrationStatus = (type) => {
429 const integration = integrations.find(i => i.integration_type === type);
430 return integration?.is_active ? 'active' : 'inactive';
431 };
432
433 // Azure AD SSO integration state
434 const [azureStatus, setAzureStatus] = useState('');
435 const [azureLoading, setAzureLoading] = useState(false);
436 const [azureUser, setAzureUser] = useState(null);
437
438 const handleAzureStart = async () => {
439 setAzureLoading(true);
440 setAzureStatus('');
441 try {
442 // Get Azure AD OAuth2 URL from backend
443 const res = await apiFetch(`/sso/azure/${selectedTenantId}/start`);
444 if (!res.ok) throw new Error('Failed to get Azure AD login URL');
445 const data = await res.json();
446 window.location.href = data.url; // Redirect to Azure AD login
447 } catch (err) {
448 setAzureStatus('Azure AD SSO setup failed: ' + err.message);
449 }
450 setAzureLoading(false);
451 };
452
453 // MeshCentral toggle handler
454 const handleMeshcentralToggle = async (enabled) => {
455 setMeshcentralLoading(true);
456 try {
457 const response = await apiFetch('/integrations/meshcentral', {
458 method: enabled ? 'POST' : 'DELETE',
459 headers: { 'Content-Type': 'application/json' },
460 body: JSON.stringify({ enabled })
461 });
462
463 if (!response.ok) {
464 throw new Error('Failed to update MeshCentral integration');
465 }
466
467 setMeshcentralEnabled(enabled);
468 } catch (err) {
469 console.error('MeshCentral toggle error:', err);
470 alert('Failed to update MeshCentral integration: ' + err.message);
471 } finally {
472 setMeshcentralLoading(false);
473 }
474 };
475
476 return (
477 <MainLayout>
478 <div className="integrations-page">
479 <div className="page-header">
480 <h1>Integrations</h1>
481 </div>
482
483 {error && <div className="error-message">{error}</div>}
484
485 {loading ? (
486 <div className="loading">Loading integrations...</div>
487 ) : (
488 <>
489 {Object.entries(integrationGroups).map(([groupName, groupIntegrations]) => (
490 <div key={groupName}>
491 <h2 className="section-title">{groupName}</h2>
492
493 {groupIntegrations.map((integration) => {
494 // Special handling for Stripe Connect (OAuth flow)
495 if (integration.type === 'stripe') {
496 const isConnected = stripeConfig?.configured && stripeConfig?.charges_enabled;
497 const isVerified = stripeConfig?.account_status === 'verified';
498 const isPending = stripeConfig?.configured && !stripeConfig?.charges_enabled;
499
500 return (
501 <div key={integration.type} className="remote-desktop-card">
502 <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
503 <div style={{ flex: 1 }}>
504 <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
505 <div style={{
506 width: '48px',
507 height: '48px',
508 borderRadius: '8px',
509 background: integration.iconBg,
510 display: 'flex',
511 alignItems: 'center',
512 justifyContent: 'center',
513 fontSize: '24px'
514 }}>
515 {integration.icon}
516 </div>
517 <div>
518 <h3 style={{ margin: 0, marginBottom: '4px' }}>{integration.name}</h3>
519 <div className={`status-badge ${isConnected ? 'enabled' : 'disabled'}`}>
520 {isConnected ? (isVerified ? 'VERIFIED' : 'CONNECTED') : (isPending ? 'PENDING' : 'NOT CONNECTED')}
521 </div>
522 </div>
523 </div>
524
525 <p className="description">{integration.description}</p>
526
527 {stripeError && (
528 <div style={{ color: '#dc3545', fontSize: '14px', marginTop: '8px', padding: '8px', background: '#ffe6e6', borderRadius: '4px' }}>
529 âš ī¸ {stripeError}
530 </div>
531 )}
532
533 {isConnected && (
534 <div className="config-details" style={{ marginTop: '12px' }}>
535 <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '8px' }}>
536 <div>
537 <strong>Account ID:</strong>
538 <code style={{ fontSize: '11px', display: 'block', marginTop: '4px' }}>
539 {stripeConfig.stripe_account_id}
540 </code>
541 </div>
542 <div>
543 <strong>Country:</strong> {stripeConfig.country || 'US'}
544 </div>
545 </div>
546
547 <div style={{ display: 'flex', gap: '16px', marginTop: '8px' }}>
548 <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
549 <span style={{ fontSize: '18px' }}>
550 {stripeConfig.charges_enabled ? '✅' : '❌'}
551 </span>
552 <span style={{ fontSize: '13px' }}>Charges</span>
553 </div>
554 <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
555 <span style={{ fontSize: '18px' }}>
556 {stripeConfig.payouts_enabled ? '✅' : '❌'}
557 </span>
558 <span style={{ fontSize: '13px' }}>Payouts</span>
559 </div>
560 </div>
561
562 {stripeConfig.requirements_currently_due?.length > 0 && (
563 <div style={{ marginTop: '12px', padding: '8px', background: '#fff3cd', borderRadius: '4px', fontSize: '13px' }}>
564 âš ī¸ <strong>Action Required:</strong> {stripeConfig.requirements_currently_due.length} verification step(s) needed
565 </div>
566 )}
567
568 {stripeConfig.onboarding_completed_at && (
569 <div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--text-secondary)' }}>
570 Connected on {new Date(stripeConfig.onboarding_completed_at).toLocaleDateString()}
571 </div>
572 )}
573 </div>
574 )}
575 </div>
576
577 <div style={{ marginLeft: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
578 {!isConnected ? (
579 <button
580 className="btn-primary"
581 onClick={handleStripeConnect}
582 disabled={stripeLoading}
583 style={{ whiteSpace: 'nowrap' }}
584 >
585 {stripeLoading ? 'Connecting...' : '🔗 Connect with Stripe'}
586 </button>
587 ) : (
588 <>
589 <button
590 className="btn-secondary"
591 onClick={handleStripeSyncStatus}
592 disabled={stripeLoading}
593 style={{ whiteSpace: 'nowrap', fontSize: '13px' }}
594 >
595 {stripeLoading ? 'Syncing...' : '🔄 Sync Status'}
596 </button>
597 <button
598 className="btn-secondary"
599 onClick={handleStripeDisconnect}
600 disabled={stripeLoading}
601 style={{ whiteSpace: 'nowrap', fontSize: '13px', color: '#dc3545' }}
602 >
603 Disconnect
604 </button>
605 </>
606 )}
607 </div>
608 </div>
609 </div>
610 );
611 }
612
613 // Standard integration handling (toggle switch)
614 const configured = integrations.find(i => i.integration_type === integration.type);
615 const isEnabled = integration.type === 'meshcentral' ? meshcentralEnabled : configured?.is_active || false;
616 const isLoading = integration.type === 'meshcentral' && meshcentralLoading;
617
618 return (
619 <div key={integration.type} className="remote-desktop-card">
620 <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
621 <div style={{ flex: 1 }}>
622 <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
623 <div style={{
624 width: '48px',
625 height: '48px',
626 borderRadius: '8px',
627 background: integration.iconBg,
628 display: 'flex',
629 alignItems: 'center',
630 justifyContent: 'center',
631 fontSize: '24px'
632 }}>
633 {integration.icon}
634 </div>
635 <div>
636 <h3 style={{ margin: 0, marginBottom: '4px' }}>{integration.name}</h3>
637 <div className={`status-badge ${isEnabled ? 'enabled' : 'disabled'}`}>
638 {isEnabled ? 'ENABLED' : 'DISABLED'}
639 </div>
640 </div>
641 </div>
642
643 <p className="description">{integration.description}</p>
644
645 {isEnabled && integration.details && (
646 <div className="config-details">
647 {integration.serverInfo && (
648 <div style={{ marginBottom: '8px' }}>
649 <strong>Server:</strong>
650 <code>{integration.serverInfo}</code>
651 </div>
652 )}
653 <div style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
654 â„šī¸ {integration.details}
655 </div>
656 </div>
657 )}
658 </div>
659
660 <div style={{ marginLeft: '24px' }}>
661 <label className="switch">
662 <input
663 type="checkbox"
664 checked={isEnabled}
665 onChange={(e) => {
666 if (integration.type === 'meshcentral') {
667 handleMeshcentralToggle(e.target.checked);
668 } else {
669 handleToggleIntegration(integration.type, e.target.checked);
670 }
671 }}
672 disabled={isLoading}
673 />
674 <span className="slider"></span>
675 </label>
676 </div>
677 </div>
678 </div>
679 );
680 })}
681 </div>
682 ))}
683 </>
684 )}
685
686 {/* Configuration Modal */}
687 {configuring && (
688 <div className="modal-overlay" onClick={() => setConfiguring(null)}>
689 <div className="modal-content" onClick={(e) => e.stopPropagation()}>
690 <div className="modal-header">
691 <h2>Configure {configuring.name}</h2>
692 <button className="close-btn" onClick={() => setConfiguring(null)}>×</button>
693 </div>
694
695 <div className="modal-body">
696 <p className="integration-description">{configuring.description}</p>
697
698 <form className="integration-form">
699 {configuring.configFields?.map((field) => (
700 <div key={field.key} className="form-group">
701 <label htmlFor={field.key}>
702 {field.label}
703 {field.required && <span className="required">*</span>}
704 </label>
705
706 {field.type === 'checkbox' ? (
707 <input
708 type="checkbox"
709 id={field.key}
710 checked={configForm[field.key] || false}
711 onChange={(e) => setConfigForm({ ...configForm, [field.key]: e.target.checked })}
712 />
713 ) : (
714 <input
715 type={field.type}
716 id={field.key}
717 value={configForm[field.key] || ''}
718 onChange={(e) => setConfigForm({ ...configForm, [field.key]: e.target.value })}
719 required={field.required}
720 placeholder={field.label}
721 />
722 )}
723 </div>
724 ))}
725 </form>
726 </div>
727
728 <div className="modal-footer">
729 <button onClick={() => setConfiguring(null)} className="btn-secondary">
730 Cancel
731 </button>
732 <button onClick={handleSaveIntegration} className="btn-primary">
733 Save Integration
734 </button>
735 </div>
736 </div>
737 </div>
738 )}
739 </div>
740 </MainLayout>
741 )
742}
743export default Integrations;