mirror of https://github.com/boxyhq/jackson.git
Use UI SDK (#2464)
* Tweak sdk style import order * WIP * Override SDK styles * Cleanup and pass props to component * Cleanup setup link related code as it's handled via setup-link instructions * Cleanup locale * Fix e2e tests * Fix selectors in e2e test * Add select dropdown style override * Use component from SDK * Cleanup locale * Use Edit DSync from SDK * Remove default webhook props from setup token page * Ability to set default webhook secret * Tweak header text * Revert sdk style import order - app styles should be latest * Override default SDK focus style * Update locale * Use Edit component from SDK * Allow patching oidcMetadata fields * Tweak return data format * Route change on edit success and other fixes * Fix button styles * Fix data access from API * Fix focus styling for error btn * Sync lock file * Cleanup unused files * Set `displayInfo` to false for setup link and fix exclude fields for SAML under setup link * Allow forceAuthn in setup links * Only update forceAuthn if its a boolean value coming from body * Cleanup and hideSave only for setup link * Update UI SDK * Cleanup locales * Fix failing e2e * Reuse styles * Set min value for expiry field to 1 * Validate expiry before using * Update SDK and set idpMetadata display to true --------- Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
parent
7f28a6c0a8
commit
67f111711a
|
@ -1,187 +1,44 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
saveConnection,
|
||||
fieldCatalogFilterByConnection,
|
||||
renderFieldList,
|
||||
useFieldCatalog,
|
||||
excludeFallback,
|
||||
type AdminPortalSSODefaults,
|
||||
type FormObj,
|
||||
type FieldCatalogItem,
|
||||
} from './utils';
|
||||
import { mutate } from 'swr';
|
||||
import { ApiResponse } from 'types';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
|
||||
function getInitialState(connectionType, fieldCatalog: FieldCatalogItem[]) {
|
||||
const _state = {};
|
||||
|
||||
fieldCatalog.forEach(({ key, type, members, fallback, attributes: { connection } }) => {
|
||||
let value;
|
||||
if (connection && connection !== connectionType) {
|
||||
return;
|
||||
}
|
||||
/** By default those fields which do not have a fallback.activateCondition will be excluded */
|
||||
if (typeof fallback === 'object' && typeof fallback.activateCondition !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (type === 'object') {
|
||||
value = getInitialState(connectionType, members as FieldCatalogItem[]);
|
||||
}
|
||||
_state[key] = value ? value : '';
|
||||
});
|
||||
return _state;
|
||||
}
|
||||
import { CreateSSOConnection } from '@boxyhq/react-ui/sso';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
import { AdminPortalSSODefaults } from '@lib/utils';
|
||||
|
||||
const CreateConnection = ({
|
||||
setupLinkToken,
|
||||
isSettingsView = false,
|
||||
adminPortalSSODefaults,
|
||||
}: {
|
||||
setupLinkToken?: string;
|
||||
idpEntityID?: string;
|
||||
isSettingsView?: boolean;
|
||||
adminPortalSSODefaults?: AdminPortalSSODefaults;
|
||||
}) => {
|
||||
const fieldCatalog = useFieldCatalog({ isSettingsView });
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// STATE: New connection type
|
||||
const [newConnectionType, setNewConnectionType] = useState<'saml' | 'oidc'>('saml');
|
||||
const redirectUrl = isSettingsView ? '/admin/settings/sso-connection' : '/admin/sso-connection';
|
||||
|
||||
const handleNewConnectionTypeChange = (event) => {
|
||||
setNewConnectionType(event.target.value);
|
||||
};
|
||||
|
||||
const connectionIsSAML = newConnectionType === 'saml';
|
||||
const connectionIsOIDC = newConnectionType === 'oidc';
|
||||
|
||||
const backUrl = setupLinkToken
|
||||
? null
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
const redirectUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
const mutationUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/api/admin/connections?isSystemSSO'
|
||||
: '/api/admin/connections';
|
||||
|
||||
// FORM LOGIC: SUBMIT
|
||||
const save = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
await saveConnection({
|
||||
formObj: formObj,
|
||||
connectionIsSAML: connectionIsSAML,
|
||||
connectionIsOIDC: connectionIsOIDC,
|
||||
setupLinkToken,
|
||||
callback: async (rawResponse) => {
|
||||
setLoading(false);
|
||||
|
||||
const response: ApiResponse = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawResponse.ok) {
|
||||
await mutate(mutationUrl);
|
||||
router.replace(redirectUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// STATE: FORM
|
||||
const [formObj, setFormObj] = useState<FormObj>(() =>
|
||||
isSettingsView
|
||||
? { ...getInitialState(newConnectionType, fieldCatalog), ...adminPortalSSODefaults }
|
||||
: { ...getInitialState(newConnectionType, fieldCatalog) }
|
||||
);
|
||||
// Resync form state on save
|
||||
useEffect(() => {
|
||||
const _state = getInitialState(newConnectionType, fieldCatalog);
|
||||
setFormObj(isSettingsView ? { ..._state, ...adminPortalSSODefaults } : _state);
|
||||
}, [newConnectionType, fieldCatalog, isSettingsView, adminPortalSSODefaults]);
|
||||
|
||||
// HANDLER: Track fallback display
|
||||
const activateFallback = (key, fallbackKey) => {
|
||||
setFormObj((cur) => {
|
||||
const temp = { ...cur };
|
||||
delete temp[key];
|
||||
const fallbackItem = fieldCatalog.find(({ key }) => key === fallbackKey);
|
||||
const fallbackItemVal = fallbackItem?.type === 'object' ? {} : '';
|
||||
return { ...temp, [fallbackKey]: fallbackItemVal };
|
||||
});
|
||||
};
|
||||
const backUrl = redirectUrl;
|
||||
|
||||
return (
|
||||
<>
|
||||
{backUrl && <LinkBack href={backUrl} />}
|
||||
<div>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('create_sso_connection')}
|
||||
</h2>
|
||||
<div className='mb-4 flex items-center'>
|
||||
<div className='mr-2 py-3'>{t('select_sso_type')}:</div>
|
||||
<div className='flex w-52'>
|
||||
<div className='form-control'>
|
||||
<label className='label mr-4 cursor-pointer'>
|
||||
<input
|
||||
type='radio'
|
||||
name='connection'
|
||||
value='saml'
|
||||
className='radio-primary radio'
|
||||
checked={newConnectionType === 'saml'}
|
||||
onChange={handleNewConnectionTypeChange}
|
||||
/>
|
||||
<span className='label-text ml-1'>{t('saml')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='form-control'>
|
||||
<label className='label mr-4 cursor-pointer' data-testid='sso-type-oidc'>
|
||||
<input
|
||||
type='radio'
|
||||
name='connection'
|
||||
value='oidc'
|
||||
className='radio-primary radio'
|
||||
checked={newConnectionType === 'oidc'}
|
||||
onChange={handleNewConnectionTypeChange}
|
||||
/>
|
||||
<span className='label-text ml-1'>{t('oidc')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={save}>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
{fieldCatalog
|
||||
.filter(fieldCatalogFilterByConnection(newConnectionType))
|
||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
||||
.filter(excludeFallback(formObj))
|
||||
.map(renderFieldList({ formObj, setFormObj, activateFallback }))}
|
||||
<div className='flex'>
|
||||
<ButtonPrimary loading={loading} data-testid='submit-form-create-sso'>
|
||||
{t('save_changes')}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<h2 className='mb-8 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('create_sso_connection')}
|
||||
</h2>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<CreateSSOConnection
|
||||
defaults={isSettingsView ? adminPortalSSODefaults : undefined}
|
||||
variant={{ saml: 'advanced', oidc: 'advanced' }}
|
||||
urls={{
|
||||
post: '/api/admin/connections',
|
||||
}}
|
||||
excludeFields={{ saml: ['label'], oidc: ['label'] }}
|
||||
successCallback={() => router.replace(redirectUrl)}
|
||||
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,56 +1,10 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import {
|
||||
saveConnection,
|
||||
fieldCatalogFilterByConnection,
|
||||
renderFieldList,
|
||||
useFieldCatalog,
|
||||
type FormObj,
|
||||
type FieldCatalogItem,
|
||||
excludeFallback,
|
||||
} from './utils';
|
||||
import { ApiResponse } from 'types';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { ButtonDanger } from '@components/ButtonDanger';
|
||||
import { isObjectEmpty } from '@lib/ui/utils';
|
||||
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
|
||||
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||
|
||||
function getInitialState(connection, fieldCatalog: FieldCatalogItem[], connectionType) {
|
||||
const _state = {};
|
||||
|
||||
fieldCatalog.forEach(({ key, attributes, type, members }) => {
|
||||
let value;
|
||||
if (attributes.connection && attributes.connection !== connectionType) {
|
||||
return;
|
||||
}
|
||||
if (type === 'object') {
|
||||
value = getInitialState(connection, members as FieldCatalogItem[], connectionType);
|
||||
if (isObjectEmpty(value)) {
|
||||
return;
|
||||
}
|
||||
} else if (typeof attributes.accessor === 'function') {
|
||||
if (attributes.accessor(connection) === undefined) {
|
||||
return;
|
||||
}
|
||||
value = attributes.accessor(connection);
|
||||
} else {
|
||||
value = connection?.[key];
|
||||
}
|
||||
_state[key] = value
|
||||
? attributes.isArray
|
||||
? value.join('\r\n') // render list of items on newline eg:- redirect URLs
|
||||
: value
|
||||
: '';
|
||||
});
|
||||
return _state;
|
||||
}
|
||||
import { EditSAMLConnection, EditOIDCConnection } from '@boxyhq/react-ui/sso';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
type EditProps = {
|
||||
connection: SAMLSSORecord | OIDCSSORecord;
|
||||
|
@ -59,8 +13,6 @@ type EditProps = {
|
|||
};
|
||||
|
||||
const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }: EditProps) => {
|
||||
const fieldCatalog = useFieldCatalog({ isEditView: true, isSettingsView });
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
|
@ -69,117 +21,16 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
|
|||
const connectionIsSAML = 'idpMetadata' in connection && typeof connection.idpMetadata === 'object';
|
||||
const connectionIsOIDC = 'oidcProvider' in connection && typeof connection.oidcProvider === 'object';
|
||||
|
||||
// FORM LOGIC: SUBMIT
|
||||
const save = async (event) => {
|
||||
event.preventDefault();
|
||||
saveConnection({
|
||||
formObj: formObj,
|
||||
connectionIsSAML: connectionIsSAML,
|
||||
connectionIsOIDC: connectionIsOIDC,
|
||||
isEditView: true,
|
||||
setupLinkToken,
|
||||
callback: async (res) => {
|
||||
if (res.ok) {
|
||||
successToast(t('saved'));
|
||||
// revalidate on save
|
||||
mutate(
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection`
|
||||
: `/api/admin/connections/${connectionClientId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = await res.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// LOGIC: DELETE
|
||||
const [delModalVisible, setDelModalVisible] = useState(false);
|
||||
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
|
||||
const deleteConnection = async () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
clientID: connection.clientID,
|
||||
clientSecret: connection.clientSecret,
|
||||
});
|
||||
const res = await fetch(
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection?${queryParams}`
|
||||
: `/api/admin/connections?${queryParams}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
const response: ApiResponse = await res.json();
|
||||
|
||||
toggleDelConfirm();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
await mutate(
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/connections`
|
||||
: isSettingsView
|
||||
? `/api/admin/connections?isSystemSSO`
|
||||
: '/api/admin/connections'
|
||||
);
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const connectionType = connectionIsSAML ? 'saml' : connectionIsOIDC ? 'oidc' : null;
|
||||
|
||||
// STATE: FORM
|
||||
const [formObj, setFormObj] = useState<FormObj>(() =>
|
||||
getInitialState(connection, fieldCatalog, connectionType)
|
||||
);
|
||||
// Resync form state on save
|
||||
useEffect(() => {
|
||||
const _state = getInitialState(connection, fieldCatalog, connectionType);
|
||||
setFormObj(_state);
|
||||
}, [connection, fieldCatalog, connectionType]);
|
||||
|
||||
const filteredFieldsByConnection = fieldCatalog.filter(fieldCatalogFilterByConnection(connectionType));
|
||||
|
||||
const activateFallback = (activeKey, fallbackKey) => {
|
||||
setFormObj((cur) => {
|
||||
const temp = { ...cur };
|
||||
delete temp[activeKey];
|
||||
const fallbackItem = fieldCatalog.find(({ key }) => key === fallbackKey);
|
||||
const fallbackItemVal = fallbackItem?.type === 'object' ? {} : '';
|
||||
return { ...temp, [fallbackKey]: fallbackItemVal };
|
||||
});
|
||||
};
|
||||
|
||||
const backUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
|
||||
const fieldsToHideInSetupView = ['forceAuthn', 'clientID', 'clientSecret', 'idpCertExpiry', 'idpMetadata'];
|
||||
const readOnlyFields = filteredFieldsByConnection
|
||||
.filter((field) => field.attributes.editable === false)
|
||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
||||
.filter(excludeFallback(formObj))
|
||||
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true));
|
||||
const apiUrl = setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : `/api/admin/connections`;
|
||||
const connectionFetchUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection/${connectionClientId}`
|
||||
: `/api/admin/connections/${connectionClientId}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -189,55 +40,99 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
|
|||
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('edit_sso_connection')}
|
||||
</h2>
|
||||
<ToggleConnectionStatus connection={connection} setupLinkToken={setupLinkToken} />
|
||||
</div>
|
||||
<form onSubmit={save}>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 lg:border-none lg:p-0'>
|
||||
<div className='flex flex-col gap-0 lg:flex-row lg:gap-4'>
|
||||
<div
|
||||
className={`w-full rounded border-gray-200 dark:border-gray-700 lg:border lg:p-3 ${readOnlyFields.length > 0 ? 'lg:w-3/5' : ''}`}>
|
||||
{filteredFieldsByConnection
|
||||
.filter((field) => field.attributes.editable !== false)
|
||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
||||
.filter(excludeFallback(formObj))
|
||||
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true))
|
||||
.map(renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback }))}
|
||||
</div>
|
||||
{readOnlyFields.length > 0 && (
|
||||
<div className='w-full rounded border-gray-200 dark:border-gray-700 lg:w-2/5 lg:border lg:p-3'>
|
||||
{readOnlyFields.map(
|
||||
renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex w-full lg:mt-6'>
|
||||
<ButtonPrimary type='submit'>{t('save_changes')}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
{connection?.clientID && connection.clientSecret && (
|
||||
<section className='mt-10 flex items-center rounded bg-red-100 p-6 text-red-900'>
|
||||
<div className='flex-1'>
|
||||
<h6 className='mb-1 font-medium'>{t('delete_this_connection')}</h6>
|
||||
<p className='font-light'>{t('all_your_apps_using_this_connection_will_stop_working')}</p>
|
||||
</div>
|
||||
<ButtonDanger
|
||||
type='button'
|
||||
onClick={toggleDelConfirm}
|
||||
data-modal-toggle='popup-modal'
|
||||
data-testid='delete-connection'>
|
||||
{t('delete')}
|
||||
</ButtonDanger>
|
||||
</section>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
{connectionIsSAML && (
|
||||
<EditSAMLConnection
|
||||
displayHeader={false}
|
||||
displayIdpMetadata={true}
|
||||
displayInfo={setupLinkToken ? false : true}
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? [
|
||||
'name',
|
||||
'tenant',
|
||||
'description',
|
||||
'defaultRedirectUrl',
|
||||
'redirectUrl',
|
||||
'product',
|
||||
'label',
|
||||
'sortOrder',
|
||||
]
|
||||
: ['label']
|
||||
}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
variant='advanced'
|
||||
urls={{
|
||||
delete: apiUrl,
|
||||
patch: apiUrl,
|
||||
get: connectionFetchUrl,
|
||||
}}
|
||||
successCallback={({ operation }) => {
|
||||
operation === 'UPDATE'
|
||||
? successToast(t('saved'))
|
||||
: operation === 'DELETE'
|
||||
? successToast(t('sso_connection_deleted_successfully'))
|
||||
: successToast(t('copied'));
|
||||
if (operation !== 'COPY') {
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection'
|
||||
);
|
||||
}
|
||||
}}
|
||||
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
<ConfirmationModal
|
||||
title={t('delete_the_connection')}
|
||||
description={t('confirmation_modal_description')}
|
||||
visible={delModalVisible}
|
||||
onConfirm={deleteConnection}
|
||||
onCancel={toggleDelConfirm}
|
||||
/>
|
||||
{connectionIsOIDC && (
|
||||
<EditOIDCConnection
|
||||
displayHeader={false}
|
||||
displayInfo={setupLinkToken ? false : true}
|
||||
variant='advanced'
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? [
|
||||
'name',
|
||||
'tenant',
|
||||
'description',
|
||||
'defaultRedirectUrl',
|
||||
'redirectUrl',
|
||||
'product',
|
||||
'oidcClientId',
|
||||
'label',
|
||||
'sortOrder',
|
||||
]
|
||||
: ['label']
|
||||
}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
urls={{
|
||||
delete: apiUrl,
|
||||
patch: apiUrl,
|
||||
get: connectionFetchUrl,
|
||||
}}
|
||||
successCallback={({ operation }) => {
|
||||
operation === 'UPDATE'
|
||||
? successToast(t('saved'))
|
||||
: operation === 'DELETE'
|
||||
? successToast(t('sso_connection_deleted_successfully'))
|
||||
: successToast(t('copied'));
|
||||
if (operation !== 'COPY') {
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection'
|
||||
);
|
||||
}
|
||||
}}
|
||||
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import type { ApiResponse } from 'types';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ConnectionToggle } from '@components/ConnectionToggle';
|
||||
|
||||
interface Props {
|
||||
connection: SAMLSSORecord | OIDCSSORecord;
|
||||
setupLinkToken?: string;
|
||||
}
|
||||
|
||||
export const ToggleConnectionStatus: FC<Props> = (props) => {
|
||||
const { connection, setupLinkToken } = props;
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const [active, setActive] = useState(!connection.deactivated);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(!connection.deactivated);
|
||||
}, [connection]);
|
||||
|
||||
const updateConnectionStatus = async (active: boolean) => {
|
||||
setActive(active);
|
||||
|
||||
const body = {
|
||||
clientID: connection?.clientID,
|
||||
clientSecret: connection?.clientSecret,
|
||||
tenant: connection?.tenant,
|
||||
product: connection?.product,
|
||||
deactivated: !active,
|
||||
};
|
||||
|
||||
if ('idpMetadata' in connection) {
|
||||
body['isSAML'] = true;
|
||||
} else {
|
||||
body['isOIDC'] = true;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : '/api/admin/connections',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const response: ApiResponse = await res.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.deactivated) {
|
||||
successToast(t('connection_deactivated'));
|
||||
} else {
|
||||
successToast(t('connection_activated'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionToggle connection={{ active, type: 'sso' }} onChange={updateConnectionStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,272 +0,0 @@
|
|||
/**
|
||||
* Edit view will have extra fields to render parsed metadata and other attributes.
|
||||
* All fields are editable unless they have `editable` set to false.
|
||||
* All fields are required unless they have `required` set to false.
|
||||
* `accessor` - only used to set initial state and retrieve saved value. Useful when key is different from retrieved payload.
|
||||
* `fallback` - use this key to activate a fallback catalog item that will take in the values. The fallback will be activated
|
||||
* by means of a switch control in the UI that allows us to deactivate the fallback catalog item and revert to the main field.
|
||||
*/
|
||||
|
||||
import type { FieldCatalogItem } from './utils';
|
||||
|
||||
export const getCommonFields = ({
|
||||
isEditView,
|
||||
isSettingsView,
|
||||
}: {
|
||||
isEditView?: boolean;
|
||||
isSettingsView?: boolean;
|
||||
}): FieldCatalogItem[] => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
placeholder: 'MyApp',
|
||||
attributes: { required: false, hideInSetupView: true, 'data-testid': 'name' },
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
type: 'text',
|
||||
placeholder: 'A short description not more than 100 characters',
|
||||
attributes: { maxLength: 100, required: false, hideInSetupView: true },
|
||||
},
|
||||
{
|
||||
key: 'tenant',
|
||||
label: 'Tenant',
|
||||
type: 'text',
|
||||
placeholder: 'acme.com',
|
||||
attributes: isEditView
|
||||
? {
|
||||
editable: false,
|
||||
hideInSetupView: true,
|
||||
}
|
||||
: {
|
||||
editable: !isSettingsView,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'product',
|
||||
label: 'Product',
|
||||
type: 'text',
|
||||
placeholder: 'demo',
|
||||
attributes: isEditView
|
||||
? {
|
||||
editable: false,
|
||||
hideInSetupView: true,
|
||||
}
|
||||
: {
|
||||
editable: !isSettingsView,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'redirectUrl',
|
||||
label: 'Allowed redirect URLs (newline separated)',
|
||||
type: 'textarea',
|
||||
placeholder: 'http://localhost:3366',
|
||||
attributes: { isArray: true, rows: 3, hideInSetupView: true, editable: !isSettingsView },
|
||||
},
|
||||
{
|
||||
key: 'defaultRedirectUrl',
|
||||
label: 'Default redirect URL',
|
||||
type: 'url',
|
||||
placeholder: 'http://localhost:3366/login/saml',
|
||||
attributes: { hideInSetupView: true, editable: !isSettingsView },
|
||||
},
|
||||
{
|
||||
key: 'oidcClientId',
|
||||
label: 'Client ID [OIDC Provider]',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
attributes: {
|
||||
'data-testid': 'oidcClientId',
|
||||
connection: 'oidc',
|
||||
accessor: (o) => o?.oidcProvider?.clientId,
|
||||
hideInSetupView: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'oidcClientSecret',
|
||||
label: 'Client Secret [OIDC Provider]',
|
||||
type: 'password',
|
||||
placeholder: '',
|
||||
attributes: {
|
||||
'data-testid': 'oidcClientSecret',
|
||||
connection: 'oidc',
|
||||
accessor: (o) => o?.oidcProvider?.clientSecret,
|
||||
hideInSetupView: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'oidcDiscoveryUrl',
|
||||
label: 'Well-known URL of OpenID Provider',
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com/.well-known/openid-configuration',
|
||||
attributes: {
|
||||
'data-testid': 'oidcDiscoveryUrl',
|
||||
connection: 'oidc',
|
||||
accessor: (o) => o?.oidcProvider?.discoveryUrl,
|
||||
hideInSetupView: false,
|
||||
},
|
||||
fallback: {
|
||||
key: 'oidcMetadata',
|
||||
activateCondition: (fieldValue) => !fieldValue,
|
||||
switch: {
|
||||
label: 'Missing the discovery URL? Click here to set the individual attributes',
|
||||
'data-testid': 'oidcDiscoveryUrl-fallback-switch',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'oidcMetadata',
|
||||
type: 'object',
|
||||
members: [
|
||||
{
|
||||
key: 'issuer',
|
||||
label: 'Issuer',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.issuer,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'issuer',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'authorization_endpoint',
|
||||
label: 'Authorization Endpoint',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.authorization_endpoint,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'authorization_endpoint',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'token_endpoint',
|
||||
label: 'Token endpoint',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.token_endpoint,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'token_endpoint',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'jwks_uri',
|
||||
label: 'JWKS URI',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.jwks_uri,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'jwks_uri',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'userinfo_endpoint',
|
||||
label: 'UserInfo endpoint',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.userinfo_endpoint,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'userinfo_endpoint',
|
||||
},
|
||||
},
|
||||
],
|
||||
attributes: { connection: 'oidc', hideInSetupView: false },
|
||||
fallback: {
|
||||
key: 'oidcDiscoveryUrl',
|
||||
switch: {
|
||||
label: 'Have a discovery URL? Click here to set it',
|
||||
'data-testid': 'oidcMetadata-fallback-switch',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rawMetadata',
|
||||
label: `Raw IdP XML ${isEditView ? '(fully replaces the current one)' : ''}`,
|
||||
type: 'textarea',
|
||||
placeholder: 'Paste the raw XML here',
|
||||
attributes: {
|
||||
rows: 5,
|
||||
required: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'metadataUrl',
|
||||
label: `Metadata URL ${isEditView ? '(fully replaces the current one)' : ''}`,
|
||||
type: 'url',
|
||||
placeholder: 'Paste the Metadata URL here',
|
||||
attributes: {
|
||||
required: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'metadataUrl',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
label: 'Sort Order',
|
||||
type: 'number',
|
||||
placeholder: '10',
|
||||
attributes: {
|
||||
required: false,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'forceAuthn',
|
||||
label: 'Force Authentication',
|
||||
type: 'checkbox',
|
||||
attributes: { required: false, connection: 'saml', hideInSetupView: false },
|
||||
},
|
||||
];
|
||||
|
||||
export const EditViewOnlyFields: FieldCatalogItem[] = [
|
||||
{
|
||||
key: 'idpMetadata',
|
||||
label: 'IdP Metadata',
|
||||
type: 'pre',
|
||||
attributes: {
|
||||
isArray: false,
|
||||
rows: 10,
|
||||
editable: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
formatForDisplay: (value) => {
|
||||
const obj = JSON.parse(JSON.stringify(value));
|
||||
delete obj.validTo;
|
||||
return JSON.stringify(obj, null, 2);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'idpCertExpiry',
|
||||
label: 'IdP Certificate Validity',
|
||||
type: 'pre',
|
||||
attributes: {
|
||||
isHidden: (value): boolean => !value || new Date(value).toString() == 'Invalid Date',
|
||||
rows: 10,
|
||||
editable: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
accessor: (o) => o?.idpMetadata?.validTo,
|
||||
showWarning: (value) => new Date(value) < new Date(),
|
||||
formatForDisplay: (value) => new Date(value).toString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'clientID',
|
||||
label: 'Client ID',
|
||||
type: 'text',
|
||||
attributes: { editable: false, hideInSetupView: false },
|
||||
},
|
||||
{
|
||||
key: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
type: 'password',
|
||||
attributes: { editable: false, hideInSetupView: false },
|
||||
},
|
||||
];
|
|
@ -1,388 +0,0 @@
|
|||
import { ButtonLink } from '@components/ButtonLink';
|
||||
import { Dispatch, FormEvent, SetStateAction, useMemo, useState } from 'react';
|
||||
import { EditViewOnlyFields, getCommonFields } from './fieldCatalog';
|
||||
import { CopyToClipboardButton } from '@components/ClipboardButton';
|
||||
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
|
||||
import EyeSlashIcon from '@heroicons/react/24/outline/EyeSlashIcon';
|
||||
import { IconButton } from '@components/IconButton';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
export const saveConnection = async ({
|
||||
formObj,
|
||||
isEditView,
|
||||
connectionIsSAML,
|
||||
connectionIsOIDC,
|
||||
setupLinkToken,
|
||||
callback,
|
||||
}: {
|
||||
formObj: FormObj;
|
||||
isEditView?: boolean;
|
||||
connectionIsSAML: boolean;
|
||||
connectionIsOIDC: boolean;
|
||||
setupLinkToken?: string;
|
||||
callback: (res: Response) => Promise<void>;
|
||||
}) => {
|
||||
const {
|
||||
rawMetadata,
|
||||
redirectUrl,
|
||||
oidcDiscoveryUrl,
|
||||
oidcMetadata,
|
||||
oidcClientId,
|
||||
oidcClientSecret,
|
||||
metadataUrl,
|
||||
...rest
|
||||
} = formObj;
|
||||
|
||||
const encodedRawMetadata = btoa((rawMetadata as string) || '');
|
||||
const redirectUrlList = (redirectUrl as string)?.split(/\r\n|\r|\n/);
|
||||
|
||||
const res = await fetch(
|
||||
setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : '/api/admin/connections',
|
||||
{
|
||||
method: isEditView ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...rest,
|
||||
encodedRawMetadata: connectionIsSAML ? encodedRawMetadata : undefined,
|
||||
oidcDiscoveryUrl: connectionIsOIDC ? oidcDiscoveryUrl : undefined,
|
||||
oidcMetadata: connectionIsOIDC ? oidcMetadata : undefined,
|
||||
oidcClientId: connectionIsOIDC ? oidcClientId : undefined,
|
||||
oidcClientSecret: connectionIsOIDC ? oidcClientSecret : undefined,
|
||||
redirectUrl: redirectUrl && redirectUrlList ? JSON.stringify(redirectUrlList) : undefined,
|
||||
metadataUrl: connectionIsSAML ? metadataUrl : undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
callback(res);
|
||||
};
|
||||
|
||||
export function fieldCatalogFilterByConnection(connection) {
|
||||
return ({ attributes }) =>
|
||||
attributes.connection && connection !== null ? attributes.connection === connection : true;
|
||||
}
|
||||
|
||||
/** If a field item has a fallback attribute, only render it if the form state has the field item */
|
||||
export function excludeFallback(formObj: FormObj) {
|
||||
return ({ key, fallback }: FieldCatalogItem) => {
|
||||
if (typeof fallback === 'object') {
|
||||
if (!(key in formObj)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function getHandleChange(
|
||||
setFormObj: Dispatch<SetStateAction<FormObj>>,
|
||||
opts: { key?: string; formObjParentKey?: string } = {}
|
||||
) {
|
||||
return (event: FormEvent) => {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
setFormObj((cur) =>
|
||||
opts.formObjParentKey
|
||||
? {
|
||||
...cur,
|
||||
[opts.formObjParentKey]: {
|
||||
...(cur[opts.formObjParentKey] as FormObj),
|
||||
[target.id]: target[opts.key || 'value'],
|
||||
},
|
||||
}
|
||||
: { ...cur, [target.id]: target[opts.key || 'value'] }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type fieldAttributes = {
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
editable?: boolean;
|
||||
isArray?: boolean;
|
||||
rows?: number;
|
||||
accessor?: (any) => unknown;
|
||||
formatForDisplay?: (value) => string;
|
||||
isHidden?: (value) => boolean;
|
||||
showWarning?: (value) => boolean;
|
||||
hideInSetupView: boolean;
|
||||
connection?: string;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export type FieldCatalogItem = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type: 'url' | 'object' | 'pre' | 'text' | 'password' | 'textarea' | 'checkbox' | 'number';
|
||||
placeholder?: string;
|
||||
attributes: fieldAttributes;
|
||||
members?: FieldCatalogItem[];
|
||||
fallback?: {
|
||||
key: string;
|
||||
activateCondition?: (fieldValue) => boolean;
|
||||
switch: { label: string; 'data-testid'?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminPortalSSODefaults = {
|
||||
tenant: string;
|
||||
product: string;
|
||||
redirectUrl: string;
|
||||
defaultRedirectUrl: string;
|
||||
};
|
||||
|
||||
type FormObjValues = string | boolean | string[];
|
||||
|
||||
export type FormObj = Record<string, FormObjValues | Record<string, FormObjValues>>;
|
||||
|
||||
export const useFieldCatalog = ({
|
||||
isEditView,
|
||||
isSettingsView,
|
||||
}: {
|
||||
isEditView?: boolean;
|
||||
isSettingsView?: boolean;
|
||||
}) => {
|
||||
const fieldCatalog = useMemo(() => {
|
||||
if (isEditView) {
|
||||
return [...getCommonFields({ isEditView: true, isSettingsView }), ...EditViewOnlyFields];
|
||||
}
|
||||
return [...getCommonFields({ isSettingsView })];
|
||||
}, [isEditView, isSettingsView]);
|
||||
return fieldCatalog;
|
||||
};
|
||||
|
||||
function SecretInputFormControl({
|
||||
label,
|
||||
value,
|
||||
isHiddenClassName,
|
||||
id,
|
||||
placeholder,
|
||||
required,
|
||||
maxLength,
|
||||
readOnly,
|
||||
args,
|
||||
dataTestId,
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isSecretShown, setisSecretShown] = useState(false);
|
||||
return (
|
||||
<div className='mb-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={'mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300' + isHiddenClassName}>
|
||||
{label}
|
||||
</label>
|
||||
<div className='flex'>
|
||||
<IconButton
|
||||
tooltip={isSecretShown ? t('hide_secret') : t('show_secret')}
|
||||
Icon={isSecretShown ? EyeSlashIcon : EyeIcon}
|
||||
className='hover:text-primary mr-2'
|
||||
onClick={() => {
|
||||
setisSecretShown(!isSecretShown);
|
||||
}}
|
||||
/>
|
||||
<CopyToClipboardButton text={value} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type={isSecretShown ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) || ''}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, { formObjParentKey: args.formObjParentKey })}
|
||||
className={'input-bordered input w-full' + isHiddenClassName + (readOnly ? ' bg-gray-50' : '')}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderFieldList(args: {
|
||||
isEditView?: boolean;
|
||||
formObj: FormObj;
|
||||
setFormObj: Dispatch<SetStateAction<FormObj>>;
|
||||
formObjParentKey?: string;
|
||||
activateFallback: (activeKey, fallbackKey) => void;
|
||||
}) {
|
||||
const FieldList = ({
|
||||
key,
|
||||
placeholder,
|
||||
label,
|
||||
type,
|
||||
members,
|
||||
attributes: {
|
||||
isHidden,
|
||||
isArray,
|
||||
rows,
|
||||
formatForDisplay,
|
||||
editable,
|
||||
maxLength,
|
||||
showWarning,
|
||||
required = true,
|
||||
'data-testid': dataTestId,
|
||||
},
|
||||
fallback,
|
||||
}: FieldCatalogItem) => {
|
||||
const readOnly = editable === false;
|
||||
const value =
|
||||
readOnly && typeof formatForDisplay === 'function'
|
||||
? formatForDisplay(
|
||||
args.formObjParentKey ? args.formObj[args.formObjParentKey]?.[key] : args.formObj[key]
|
||||
)
|
||||
: args.formObjParentKey
|
||||
? args.formObj[args.formObjParentKey]?.[key]
|
||||
: args.formObj[key];
|
||||
|
||||
if (type === 'object') {
|
||||
return (
|
||||
<div key={key}>
|
||||
{typeof fallback === 'object' &&
|
||||
(typeof fallback.activateCondition === 'function' ? fallback.activateCondition(value) : true) && (
|
||||
<ButtonLink
|
||||
className='mb-2 px-0'
|
||||
type='button'
|
||||
data-testid={fallback.switch['data-testid']}
|
||||
onClick={() => {
|
||||
/** Switch to fallback.key*/
|
||||
args.activateFallback(key, fallback.key);
|
||||
}}>
|
||||
{fallback.switch.label}
|
||||
</ButtonLink>
|
||||
)}
|
||||
{members?.map(
|
||||
renderFieldList({
|
||||
...args,
|
||||
formObjParentKey: key,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHiddenClassName =
|
||||
typeof isHidden === 'function' && isHidden(args.formObj[key]) == true ? ' hidden' : '';
|
||||
|
||||
if (type === 'password') {
|
||||
return (
|
||||
<SecretInputFormControl
|
||||
key={key}
|
||||
label={label}
|
||||
value={value}
|
||||
isHiddenClassName={isHiddenClassName}
|
||||
id={key}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
readOnly={readOnly}
|
||||
args={args}
|
||||
dataTestId={dataTestId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-6 ' key={key}>
|
||||
{type !== 'checkbox' && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className={
|
||||
'mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300' + isHiddenClassName
|
||||
}>
|
||||
{label}
|
||||
</label>
|
||||
{typeof fallback === 'object' &&
|
||||
(typeof fallback.activateCondition === 'function'
|
||||
? fallback.activateCondition(value)
|
||||
: true) && (
|
||||
<ButtonLink
|
||||
className='mb-2 px-0'
|
||||
type='button'
|
||||
data-testid={fallback.switch['data-testid']}
|
||||
onClick={() => {
|
||||
/** Switch to fallback.key*/
|
||||
args.activateFallback(key, fallback.key);
|
||||
}}>
|
||||
{fallback.switch.label}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === 'pre' ? (
|
||||
<pre
|
||||
className={
|
||||
'block w-full overflow-auto rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500' +
|
||||
isHiddenClassName +
|
||||
(typeof showWarning === 'function' && showWarning(args.formObj[key])
|
||||
? ' border-2 border-rose-500'
|
||||
: '')
|
||||
}
|
||||
data-testid={dataTestId}>
|
||||
{value}
|
||||
</pre>
|
||||
) : type === 'textarea' ? (
|
||||
<textarea
|
||||
id={key}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) || ''}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, { formObjParentKey: args.formObjParentKey })}
|
||||
className={
|
||||
'textarea-bordered textarea h-24 w-full' +
|
||||
(isArray ? ' whitespace-pre' : '') +
|
||||
isHiddenClassName +
|
||||
(readOnly ? ' bg-gray-50' : '')
|
||||
}
|
||||
rows={rows}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
) : type === 'checkbox' ? (
|
||||
<>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className={
|
||||
'inline-block align-middle text-sm font-medium text-gray-900 dark:text-gray-300' +
|
||||
isHiddenClassName
|
||||
}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={key}
|
||||
type={type}
|
||||
checked={!!value}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, {
|
||||
key: 'checked',
|
||||
formObjParentKey: args.formObjParentKey,
|
||||
})}
|
||||
className={'checkbox-primary checkbox ml-5 align-middle' + isHiddenClassName}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
id={key}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) || ''}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, { formObjParentKey: args.formObjParentKey })}
|
||||
className={'input-bordered input w-full' + isHiddenClassName + (readOnly ? ' bg-gray-50' : '')}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return FieldList;
|
||||
}
|
|
@ -1,200 +1,60 @@
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { ApiResponse } from 'types';
|
||||
import React from 'react';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import type { Directory } from '@boxyhq/saml-jackson';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import useDirectoryProviders from '@lib/ui/hooks/useDirectoryProviders';
|
||||
import { CreateDirectory as CreateDSync } from '@boxyhq/react-ui/dsync';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
interface CreateDirectoryProps {
|
||||
setupLinkToken?: string;
|
||||
defaultWebhookEndpoint: string | undefined;
|
||||
defaultWebhookEndpoint?: string;
|
||||
defaultWebhookSecret?: string;
|
||||
}
|
||||
|
||||
type UnSavedDirectory = Omit<Directory, 'id' | 'log_webhook_events' | 'scim' | 'deactivated' | 'webhook'> & {
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
};
|
||||
|
||||
const defaultDirectory: UnSavedDirectory = {
|
||||
name: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
webhook_url: '',
|
||||
webhook_secret: '',
|
||||
type: 'azure-scim-v2',
|
||||
google_domain: '',
|
||||
};
|
||||
|
||||
const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirectoryProps) => {
|
||||
const CreateDirectory = ({
|
||||
setupLinkToken,
|
||||
defaultWebhookEndpoint,
|
||||
defaultWebhookSecret,
|
||||
}: CreateDirectoryProps) => {
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const { providers } = useDirectoryProviders(setupLinkToken);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDomain, setShowDomain] = useState(false);
|
||||
const [directory, setDirectory] = useState<UnSavedDirectory>({
|
||||
...defaultDirectory,
|
||||
webhook_url: defaultWebhookEndpoint || '',
|
||||
});
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch(
|
||||
setupLinkToken ? `/api/setup/${setupLinkToken}/directory-sync` : '/api/admin/directory-sync',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(directory),
|
||||
}
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const response: ApiResponse<Directory> = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawResponse.ok) {
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/directory-sync/${response.data.id}`
|
||||
: `/admin/directory-sync/${response.data.id}`
|
||||
);
|
||||
successToast(t('directory_created_successfully'));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
setDirectory({
|
||||
...directory,
|
||||
[target.id]: target.value,
|
||||
});
|
||||
|
||||
// Ask for domain if google is selected
|
||||
if (target.id === 'type') {
|
||||
target.value === 'google' ? setShowDomain(true) : setShowDomain(false);
|
||||
}
|
||||
};
|
||||
|
||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LinkBack href={backUrl} />
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('new_directory')}</h2>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('create_dsync_connection')}</h2>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
{!setupLinkToken && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_name')}</span>
|
||||
</label>
|
||||
<input type='text' id='name' className='input-bordered input w-full' onChange={onChange} />
|
||||
</div>
|
||||
)}
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_provider')}</span>
|
||||
</label>
|
||||
<select className='select-bordered select w-full' id='type' onChange={onChange} required>
|
||||
{providers &&
|
||||
Object.keys(providers).map((key) => {
|
||||
return (
|
||||
<option key={key} value={key}>
|
||||
{providers[key]}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
{showDomain && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_domain')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='google_domain'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directory.google_domain}
|
||||
pattern='^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$'
|
||||
title='Please enter a valid domain (e.g: boxyhq.com)'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!setupLinkToken && (
|
||||
<>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('tenant')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='tenant'
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('product')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='product'
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_url')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_url'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_secret')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_secret'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<ButtonPrimary loading={loading}>{t('create_directory')}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<CreateDSync
|
||||
displayHeader={false}
|
||||
defaultWebhookEndpoint={defaultWebhookEndpoint}
|
||||
defaultWebhookSecret={defaultWebhookSecret}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
successCallback={({ connection }) => {
|
||||
successToast(t('directory_created_successfully'));
|
||||
connection?.id &&
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/directory-sync/${connection.id}`
|
||||
: `/admin/directory-sync/${connection.id}`
|
||||
);
|
||||
}}
|
||||
errorCallback={(errorMessage) => {
|
||||
errorToast(errorMessage);
|
||||
}}
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? ['name', 'tenant', 'product', 'webhook_url', 'webhook_secret', 'log_webhook_events']
|
||||
: undefined
|
||||
}
|
||||
urls={{
|
||||
post: setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync`
|
||||
: '/api/admin/directory-sync',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,50 +1,19 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import type { Directory } from '@boxyhq/saml-jackson';
|
||||
import type { ApiResponse } from 'types';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import Loading from '@components/Loading';
|
||||
import useDirectory from '@lib/ui/hooks/useDirectory';
|
||||
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
|
||||
import { DeleteDirectory } from './DeleteDirectory';
|
||||
|
||||
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook' | 'google_domain'>;
|
||||
|
||||
const defaultFormState: FormState = {
|
||||
name: '',
|
||||
log_webhook_events: false,
|
||||
webhook: {
|
||||
endpoint: '',
|
||||
secret: '',
|
||||
},
|
||||
google_domain: '',
|
||||
};
|
||||
import { EditDirectory as EditDSync } from '@boxyhq/react-ui/dsync';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [directoryUpdated, setDirectoryUpdated] = useState<FormState>(defaultFormState);
|
||||
const { directory, isLoading, isValidating, error } = useDirectory(directoryId, setupLinkToken);
|
||||
|
||||
useEffect(() => {
|
||||
if (directory) {
|
||||
setDirectoryUpdated({
|
||||
name: directory.name,
|
||||
log_webhook_events: directory.log_webhook_events,
|
||||
webhook: {
|
||||
endpoint: directory.webhook?.endpoint,
|
||||
secret: directory.webhook?.secret,
|
||||
},
|
||||
google_domain: directory.google_domain,
|
||||
});
|
||||
}
|
||||
}, [directory]);
|
||||
|
||||
if (isLoading || !directory || isValidating) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
@ -55,147 +24,54 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
}
|
||||
|
||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
const patchUrl = setupLinkToken
|
||||
|
||||
const apiUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
|
||||
: `/api/admin/directory-sync/${directoryId}`;
|
||||
const redirectUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch(patchUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(directoryUpdated),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const response: ApiResponse<Directory> = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawResponse.ok) {
|
||||
successToast(t('directory_updated_successfully'));
|
||||
router.replace(redirectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
|
||||
if (target.id === 'webhook.endpoint' || target.id === 'webhook.secret') {
|
||||
setDirectoryUpdated({
|
||||
...directoryUpdated,
|
||||
webhook: {
|
||||
...directoryUpdated.webhook,
|
||||
[target.id.split('.')[1]]: value,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectoryUpdated({
|
||||
...directoryUpdated,
|
||||
[target.id]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LinkBack href={backUrl} />
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('edit_directory')}</h2>
|
||||
<ToggleConnectionStatus connection={directory} setupLinkToken={setupLinkToken} />
|
||||
</div>
|
||||
{!setupLinkToken && (
|
||||
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_name')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='name'
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.name}
|
||||
/>
|
||||
</div>
|
||||
{directory.type === 'google' && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_domain')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='google_domain'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.google_domain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_url')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook.endpoint'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.webhook.endpoint}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_secret')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook.secret'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.webhook.secret}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full py-2'>
|
||||
<div className='flex items-center'>
|
||||
<input
|
||||
id='log_webhook_events'
|
||||
type='checkbox'
|
||||
checked={directoryUpdated.log_webhook_events}
|
||||
onChange={onChange}
|
||||
className='h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600'
|
||||
/>
|
||||
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||
{t('enable_webhook_events_logging')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ButtonPrimary type='submit' loading={loading}>
|
||||
{t('save_changes')}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<DeleteDirectory directoryId={directoryId} setupLinkToken={setupLinkToken} />
|
||||
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<EditDSync
|
||||
displayHeader={false}
|
||||
urls={{
|
||||
patch: apiUrl,
|
||||
delete: apiUrl,
|
||||
get: apiUrl,
|
||||
}}
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? [
|
||||
'name',
|
||||
'product',
|
||||
'log_webhook_events',
|
||||
'tenant',
|
||||
'google_domain',
|
||||
'type',
|
||||
'webhook_url',
|
||||
'webhook_secret',
|
||||
'scim_endpoint',
|
||||
'scim_token',
|
||||
'google_authorization_url',
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
successCallback={() => {
|
||||
successToast(t('directory_updated_successfully'));
|
||||
router.replace(redirectUrl);
|
||||
}}
|
||||
errorCallback={(errMessage) => {
|
||||
errorToast(errMessage);
|
||||
}}
|
||||
hideSave={setupLinkToken ? true : false}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { useRouter } from 'next/router';
|
||||
|
||||
import { CreateSAMLConnection as CreateSAML, CreateOIDCConnection as CreateOIDC } from '@boxyhq/react-ui/sso';
|
||||
import styles from 'styles/sdk-override.module.css';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
interface CreateSSOConnectionProps {
|
||||
setupLinkToken: string;
|
||||
|
@ -30,19 +30,13 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
|
|||
post: `/api/setup/${setupLinkToken}/sso-connection`,
|
||||
};
|
||||
|
||||
const _CSS = {
|
||||
input: `${styles['sdk-input']} input input-bordered`,
|
||||
button: { ctoa: 'btn btn-primary' },
|
||||
textarea: styles['sdk-input'],
|
||||
};
|
||||
|
||||
return idpType === 'saml' ? (
|
||||
<CreateSAML
|
||||
variant='basic'
|
||||
urls={urls}
|
||||
successCallback={onSuccess}
|
||||
errorCallback={onError}
|
||||
classNames={_CSS}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
displayHeader={false}
|
||||
/>
|
||||
) : (
|
||||
|
@ -51,7 +45,7 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
|
|||
urls={urls}
|
||||
successCallback={onSuccess}
|
||||
errorCallback={onError}
|
||||
classNames={_CSS}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
displayHeader={false}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import styles from '@styles/sdk-override.module.css';
|
||||
|
||||
export const BOXYHQ_UI_CSS = {
|
||||
button: {
|
||||
ctoa: 'btn btn-primary',
|
||||
destructive: 'btn btn-md btn-error',
|
||||
},
|
||||
input: `${styles['sdk-input']} input input-bordered`,
|
||||
select: styles['sdk-select'],
|
||||
textarea: styles['sdk-input'],
|
||||
confirmationPrompt: {
|
||||
button: {
|
||||
ctoa: 'btn btn-md',
|
||||
cancel: 'btn btn-md btn-outline',
|
||||
},
|
||||
},
|
||||
secretInput: 'input input-bordered',
|
||||
section: 'mb-8',
|
||||
};
|
|
@ -24,13 +24,13 @@ test.describe('Admin Portal SSO - SAML', () => {
|
|||
// Find the new connection button and click on it
|
||||
await page.getByTestId('create-connection').click();
|
||||
// Fill the name for the connection
|
||||
const nameInput = page.getByTestId('name');
|
||||
const nameInput = page.getByLabel('Connection name (Optional)');
|
||||
await nameInput.fill(TEST_SAML_SSO_CONNECTION_NAME);
|
||||
// Enter the metadata url for mocksaml in the form
|
||||
const metadataUrlInput = page.getByTestId('metadataUrl');
|
||||
const metadataUrlInput = page.getByLabel('Metadata URL');
|
||||
await metadataUrlInput.fill(MOCKSAML_METADATA_URL);
|
||||
// submit the form
|
||||
await page.getByTestId('submit-form-create-sso').click();
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
// check if the added connection appears in the connection list
|
||||
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).toBeVisible();
|
||||
});
|
||||
|
@ -78,8 +78,8 @@ test.describe('Admin Portal SSO - SAML', () => {
|
|||
const editButton = page.getByText(TEST_SAML_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
|
||||
await editButton.click();
|
||||
// click the delete and confirm deletion
|
||||
await page.getByTestId('delete-connection').click();
|
||||
await page.getByTestId('confirm-delete').click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
// check that the SSO connection is deleted from the connection list
|
||||
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).not.toBeVisible();
|
||||
});
|
||||
|
@ -94,40 +94,38 @@ test.describe('Admin Portal SSO - OIDC', () => {
|
|||
// Find the new connection button and click on it
|
||||
await page.getByTestId('create-connection').click();
|
||||
// Toggle connection type to OIDC
|
||||
await page.getByTestId('sso-type-oidc').click();
|
||||
await page.getByLabel('OIDC').check();
|
||||
// Fill the name for the connection
|
||||
const nameInput = page.getByTestId('name');
|
||||
const nameInput = page.getByLabel('Connection name (Optional)');
|
||||
await nameInput.fill(TEST_OIDC_SSO_CONNECTION_NAME);
|
||||
if (mode === 'discoveryUrl') {
|
||||
// Enter the OIDC discovery url for mocklab in the form
|
||||
const discoveryUrlInput = page.getByTestId('oidcDiscoveryUrl');
|
||||
const discoveryUrlInput = page.getByLabel('Well-known URL of OpenID Provider');
|
||||
await discoveryUrlInput.fill(MOCKLAB_DISCOVERY_ENDPOINT);
|
||||
} else {
|
||||
// Activate the oidc discovery fallback fields
|
||||
await page.getByTestId('oidcDiscoveryUrl-fallback-switch').click();
|
||||
// Enter the OIDC issuer value for mocklab in the form
|
||||
const issuerInput = page.getByTestId('issuer');
|
||||
const issuerInput = page.getByLabel('issuer');
|
||||
await issuerInput.fill(MOCKLAB_ISSUER);
|
||||
// Enter the OIDC authorization_endpoint value for mocklab in the form
|
||||
const authzEndpointInput = page.getByTestId('authorization_endpoint');
|
||||
const authzEndpointInput = page.getByLabel('Authorization Endpoint');
|
||||
await authzEndpointInput.fill(MOCKLAB_AUTHORIZATION_ENDPOINT);
|
||||
// Enter the OIDC token_endpoint value for mocklab in the form
|
||||
const tokenEndpointInput = page.getByTestId('token_endpoint');
|
||||
const tokenEndpointInput = page.getByLabel('Token endpoint');
|
||||
await tokenEndpointInput.fill(MOCKLAB_TOKEN_ENDPOINT);
|
||||
// Enter the OIDC userinfo_endpoint value for mocklab in the form
|
||||
const userInfoEndpointInput = page.getByTestId('userinfo_endpoint');
|
||||
const userInfoEndpointInput = page.getByLabel('UserInfo endpoint');
|
||||
await userInfoEndpointInput.fill(MOCKLAB_USERINFO_ENDPOINT);
|
||||
// Enter the OIDC jwks_uri value for mocklab in the form
|
||||
const jwksUriInput = page.getByTestId('jwks_uri');
|
||||
const jwksUriInput = page.getByLabel('JWKS URI');
|
||||
await jwksUriInput.fill(MOCKLAB_JWKS_URI);
|
||||
}
|
||||
// Enter the OIDC client credentials for mocklab in the form
|
||||
const clientIdInput = page.getByTestId('oidcClientId');
|
||||
const clientIdInput = page.getByLabel('Client ID');
|
||||
await clientIdInput.fill(MOCKLAB_CLIENT_ID);
|
||||
const clientSecretInput = page.getByTestId('oidcClientSecret');
|
||||
const clientSecretInput = page.getByLabel('Client Secret');
|
||||
await clientSecretInput.fill(MOCKLAB_CLIENT_SECRET);
|
||||
// submit the form
|
||||
await page.getByTestId('submit-form-create-sso').click();
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
// check if the added connection appears in the connection list
|
||||
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).toBeVisible();
|
||||
});
|
||||
|
@ -156,8 +154,8 @@ test.describe('Admin Portal SSO - OIDC', () => {
|
|||
const editButton = page.getByText(TEST_OIDC_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
|
||||
await editButton.click();
|
||||
// click the delete and confirm deletion
|
||||
await page.getByTestId('delete-connection').click();
|
||||
await page.getByTestId('confirm-delete').click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
// check that the SSO connection is deleted from the connection list
|
||||
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).not.toBeVisible();
|
||||
});
|
||||
|
|
|
@ -153,6 +153,7 @@ export const DSyncForm = ({
|
|||
className='input input-bordered w-full text-sm'
|
||||
name='expiryDays'
|
||||
required
|
||||
min={1}
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.expiryDays}
|
||||
/>
|
||||
|
|
|
@ -168,6 +168,7 @@ export const SSOForm = ({
|
|||
className='input input-bordered w-full text-sm'
|
||||
name='expiryDays'
|
||||
required
|
||||
min={1}
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.expiryDays}
|
||||
/>
|
||||
|
|
|
@ -100,3 +100,10 @@ export const parsePaginateApiParams = (params: NextApiRequest['query']): Paginat
|
|||
pageToken,
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminPortalSSODefaults = {
|
||||
tenant: string;
|
||||
product: string;
|
||||
redirectUrl: string;
|
||||
defaultRedirectUrl: string;
|
||||
};
|
||||
|
|
|
@ -2,26 +2,19 @@
|
|||
"documentation": "Documentation",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"all_your_apps_using_this_connection_will_stop_working": "All your apps using this connection will stop working.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"client_error": "Client error",
|
||||
"close_sidebar": "Close sidebar",
|
||||
"confirmation_modal_description": "This action cannot be undone. This will permanently delete the Connection.",
|
||||
"create_directory": "Create Directory",
|
||||
"create_new": "Create New",
|
||||
"create_sso_connection": "Create SSO Connection",
|
||||
"create_dsync_connection": "Create DSync Connection",
|
||||
"delete": "Delete",
|
||||
"delete_the_connection": "Delete the Connection?",
|
||||
"delete_this_connection": "Delete this Connection",
|
||||
"directory_name": "Directory name",
|
||||
"directory_provider": "Directory provider",
|
||||
"directory_sync": "Directory Sync",
|
||||
"edit_sso_connection": "Edit SSO Connection",
|
||||
"email": "Email",
|
||||
"enable_webhook_events_logging": "Enable Webhook events logging",
|
||||
"boxyhq_tagline": "Security Building Blocks for Developers.",
|
||||
"enterprise_sso": "Enterprise SSO",
|
||||
"idp_entity_id": "IdP Entity ID",
|
||||
|
@ -33,24 +26,17 @@
|
|||
"connections": "Connections",
|
||||
"new_connection": "New Connection",
|
||||
"no_projects_found": "No projects found.",
|
||||
"oidc": "OIDC",
|
||||
"open_menu": "Open menu",
|
||||
"open_sidebar": "Open sidebar",
|
||||
"product": "Product",
|
||||
"save_changes": "Save Changes",
|
||||
"saved": "Saved",
|
||||
"server_error": "Server error",
|
||||
"sign_out": "Sign out",
|
||||
"saml": "SAML",
|
||||
"sso_error": "SSO error",
|
||||
"select_sso_type": "Select SSO type",
|
||||
"select_an_app": "Select an App to continue",
|
||||
"send_magic_link": "Send Magic Link",
|
||||
"setup_links": "Setup Links",
|
||||
"tenant": "Tenant",
|
||||
"edit_directory": "Edit Directory",
|
||||
"webhook_secret": "Webhook secret",
|
||||
"webhook_url": "Webhook URL",
|
||||
"download": "Download",
|
||||
"saml_federation_new_success": "Identity Federation app created successfully.",
|
||||
"entity_id": "Entity ID / Audience URI / Audience Restriction",
|
||||
|
@ -131,9 +117,6 @@
|
|||
"delete_this_directory": "Delete this directory connection?",
|
||||
"delete_this_directory_desc": "This action cannot be undone. This will permanently delete the directory connection, users, and groups.",
|
||||
"directory_connection_deleted_successfully": "Directory connection deleted successfully",
|
||||
"directory_domain": "Directory Domain",
|
||||
"show_secret": "Show secret",
|
||||
"hide_secret": "Hide secret",
|
||||
"retraced_project_created": "Project created successfully",
|
||||
"project_name": "Project name",
|
||||
"create_project": "Create Project",
|
||||
|
@ -159,6 +142,7 @@
|
|||
"choose_an_app_to_continue": "Choose an app to continue. If you don't see your app, please contact your administrator.",
|
||||
"no_saml_response_try_again": "No SAMLResponse found. Please try again.",
|
||||
"sso_connection_created_successfully": "SSO Connection created successfully",
|
||||
"sso_connection_deleted_successfully": "SSO Connection deleted successfully",
|
||||
"saml_federation_entity_id_generated": "SP Entity ID generated",
|
||||
"setup-link-created": "A new setup link created.",
|
||||
"setup-link-regenerated": "The setup link regenerated.",
|
||||
|
|
|
@ -216,7 +216,7 @@ const saml = {
|
|||
name,
|
||||
label,
|
||||
description,
|
||||
forceAuthn = false,
|
||||
forceAuthn,
|
||||
metadataUrl,
|
||||
...clientInfo
|
||||
} = body;
|
||||
|
@ -302,7 +302,7 @@ const saml = {
|
|||
metadataUrl: newMetadata ? newMetadataUrl : _savedConnection.metadataUrl,
|
||||
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _savedConnection.defaultRedirectUrl,
|
||||
redirectUrl: redirectUrlList ? redirectUrlList : _savedConnection.redirectUrl,
|
||||
forceAuthn,
|
||||
forceAuthn: typeof forceAuthn === 'boolean' ? forceAuthn : _savedConnection.forceAuthn,
|
||||
};
|
||||
|
||||
if ('sortOrder' in body) {
|
||||
|
|
|
@ -233,7 +233,8 @@ export class SetupLinkController {
|
|||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const expiryInDays = expiryDays || this.opts.setupLinkExpiryDays || 3;
|
||||
const expiryInDays =
|
||||
typeof expiryDays === 'number' && expiryDays > 0 ? expiryDays : this.opts.setupLinkExpiryDays || 3;
|
||||
const setupID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, service));
|
||||
|
||||
const setupLink: SetupLink = {
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"dependencies": {
|
||||
"@boxyhq/internal-ui": "file:internal-ui",
|
||||
"@boxyhq/metrics": "0.2.6",
|
||||
"@boxyhq/react-ui": "3.3.39",
|
||||
"@boxyhq/react-ui": "3.3.41",
|
||||
"@boxyhq/saml-jackson": "file:npm",
|
||||
"@heroicons/react": "2.1.3",
|
||||
"@retracedhq/logs-viewer": "2.7.1",
|
||||
|
@ -2030,9 +2030,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@boxyhq/react-ui": {
|
||||
"version": "3.3.39",
|
||||
"resolved": "https://registry.npmjs.org/@boxyhq/react-ui/-/react-ui-3.3.39.tgz",
|
||||
"integrity": "sha512-1kSQIc5AjWbh8rUwIlG+BhRZy8kXJg6fgv/2SxWbkf874XILUK+TobwtVXnUncRC3pQthh7sVYfR6XW7FF/J0w==",
|
||||
"version": "3.3.41",
|
||||
"resolved": "https://registry.npmjs.org/@boxyhq/react-ui/-/react-ui-3.3.41.tgz",
|
||||
"integrity": "sha512-j0c4VVNoqx73i3gkQQx16KOhfK7PRdUO6wvgxGwhaRC7cqf+NoSSqZuyazYu1PyDCTeiQv6vv/07yIuAQuP+ZA==",
|
||||
"dependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
|
|
|
@ -63,7 +63,7 @@
|
|||
"dependencies": {
|
||||
"@boxyhq/internal-ui": "file:internal-ui",
|
||||
"@boxyhq/metrics": "0.2.6",
|
||||
"@boxyhq/react-ui": "3.3.39",
|
||||
"@boxyhq/react-ui": "3.3.41",
|
||||
"@boxyhq/saml-jackson": "file:npm",
|
||||
"@heroicons/react": "2.1.3",
|
||||
"@retracedhq/logs-viewer": "2.7.1",
|
||||
|
|
|
@ -5,9 +5,14 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
|||
import { jacksonOptions } from '@lib/env';
|
||||
|
||||
const DirectoryCreatePage: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = (props) => {
|
||||
const { defaultWebhookEndpoint } = props;
|
||||
const { defaultWebhookEndpoint, defaultWebhookSecret } = props;
|
||||
|
||||
return <CreateDirectory defaultWebhookEndpoint={defaultWebhookEndpoint} />;
|
||||
return (
|
||||
<CreateDirectory
|
||||
defaultWebhookEndpoint={defaultWebhookEndpoint}
|
||||
defaultWebhookSecret={defaultWebhookSecret}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async ({ locale }: GetServerSidePropsContext) => {
|
||||
|
@ -15,6 +20,7 @@ export const getServerSideProps = async ({ locale }: GetServerSidePropsContext)
|
|||
props: {
|
||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||
defaultWebhookEndpoint: jacksonOptions.webhook?.endpoint,
|
||||
defaultWebhookSecret: jacksonOptions.webhook?.secret,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -7,7 +7,6 @@ import EditConnection from '@components/connection/EditConnection';
|
|||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import Loading from '@components/Loading';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||
|
||||
const EditSSOConnection: NextPage = () => {
|
||||
|
@ -15,7 +14,7 @@ const EditSSOConnection: NextPage = () => {
|
|||
|
||||
const { id } = router.query as { id: string };
|
||||
|
||||
const { data, error, isLoading } = useSWR<ApiSuccess<SAMLSSORecord | OIDCSSORecord>, ApiError>(
|
||||
const { data, error, isLoading } = useSWR<SAMLSSORecord | OIDCSSORecord>(
|
||||
id ? `/api/admin/connections/${id}` : null,
|
||||
fetcher,
|
||||
{
|
||||
|
@ -32,11 +31,11 @@ const EditSSOConnection: NextPage = () => {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!data?.data) {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EditConnection connection={data?.data} isSettingsView />;
|
||||
return <EditConnection connection={data[0]} isSettingsView />;
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { GetServerSidePropsContext, NextPage } from 'next';
|
||||
import CreateConnection from '@components/connection/CreateConnection';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import type { AdminPortalSSODefaults } from '@components/connection/utils';
|
||||
import type { AdminPortalSSODefaults } from '@lib/utils';
|
||||
import { adminPortalSSODefaults } from '@lib/env';
|
||||
|
||||
type Props = {
|
||||
|
|
|
@ -5,7 +5,6 @@ import { useRouter } from 'next/router';
|
|||
import { fetcher } from '@lib/ui/utils';
|
||||
import EditConnection from '@components/connection/EditConnection';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import Loading from '@components/Loading';
|
||||
import { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
|
@ -15,12 +14,13 @@ const ConnectionEditPage: NextPage = () => {
|
|||
|
||||
const { id } = router.query as { id: string };
|
||||
|
||||
const { data, error, isLoading, isValidating } = useSWR<
|
||||
ApiSuccess<SAMLSSORecord | OIDCSSORecord>,
|
||||
ApiError
|
||||
>(id ? `/api/admin/connections/${id}` : null, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data, error, isLoading, isValidating } = useSWR<SAMLSSORecord | OIDCSSORecord>(
|
||||
id ? `/api/admin/connections/${id}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading || isValidating) {
|
||||
return <Loading />;
|
||||
|
@ -31,11 +31,11 @@ const ConnectionEditPage: NextPage = () => {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!data?.data) {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EditConnection connection={data?.data} />;
|
||||
return <EditConnection connection={data[0]} />;
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
||||
|
|
|
@ -24,7 +24,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
throw new ApiError('Connection not found', 404);
|
||||
}
|
||||
|
||||
res.json({ data: connections[0] });
|
||||
res.json(connections);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
|
|
@ -39,15 +39,16 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
|
||||
const connection = connections[0];
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
res.json([
|
||||
{
|
||||
clientID: connection.clientID,
|
||||
clientSecret: connection.clientSecret,
|
||||
deactivated: connection.deactivated,
|
||||
...('forceAuthn' in connection ? { forceAuthn: connection.forceAuthn } : undefined),
|
||||
...('idpMetadata' in connection ? { idpMetadata: {}, metadataUrl: connection.metadataUrl } : undefined),
|
||||
...('oidcProvider' in connection ? { oidcProvider: connection.oidcProvider } : undefined),
|
||||
},
|
||||
});
|
||||
]);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
|
|
@ -45,6 +45,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse, setupLink: S
|
|||
clientID: connection.clientID,
|
||||
name: connection.name,
|
||||
deactivated: connection.deactivated,
|
||||
...('forceAuthn' in connection ? { forceAuthn: connection.forceAuthn } : undefined),
|
||||
...('idpMetadata' in connection
|
||||
? {
|
||||
idpMetadata: {
|
||||
|
@ -106,9 +107,11 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
clientID,
|
||||
metadataUrl,
|
||||
encodedRawMetadata,
|
||||
forceAuthn,
|
||||
oidcClientId,
|
||||
oidcClientSecret,
|
||||
oidcDiscoveryUrl,
|
||||
oidcMetadata,
|
||||
} = req.body;
|
||||
|
||||
const connections = await connectionAPIController.getConnections({
|
||||
|
@ -128,12 +131,13 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
clientID,
|
||||
clientSecret,
|
||||
...('deactivated' in req.body ? { deactivated } : undefined),
|
||||
...(isSAML ? { metadataUrl, encodedRawMetadata } : undefined),
|
||||
...(isSAML ? { metadataUrl, encodedRawMetadata, forceAuthn } : undefined),
|
||||
...(isOIDC
|
||||
? {
|
||||
oidcClientId,
|
||||
oidcClientSecret,
|
||||
oidcDiscoveryUrl,
|
||||
oidcMetadata,
|
||||
}
|
||||
: undefined),
|
||||
};
|
||||
|
|
|
@ -3,23 +3,19 @@ import React from 'react';
|
|||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { useRouter } from 'next/router';
|
||||
import CreateDirectory from '@components/dsync/CreateDirectory';
|
||||
import { jacksonOptions } from '@lib/env';
|
||||
|
||||
const DirectoryCreatePage: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = (props) => {
|
||||
const { defaultWebhookEndpoint } = props;
|
||||
|
||||
const DirectoryCreatePage: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { token } = router.query as { token: string };
|
||||
|
||||
return <CreateDirectory setupLinkToken={token} defaultWebhookEndpoint={defaultWebhookEndpoint} />;
|
||||
return <CreateDirectory setupLinkToken={token} />;
|
||||
};
|
||||
|
||||
export const getServerSideProps = async ({ locale }: GetServerSidePropsContext) => {
|
||||
return {
|
||||
props: {
|
||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||
defaultWebhookEndpoint: jacksonOptions.webhook?.endpoint,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -28,10 +28,11 @@ const ConnectionEditPage: NextPage = () => {
|
|||
errorToast(error.message);
|
||||
return null;
|
||||
}
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = data.data;
|
||||
|
||||
return <EditConnection connection={connection} setupLinkToken={token} />;
|
||||
return <EditConnection connection={data[0]} setupLinkToken={token} />;
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }) {
|
||||
|
|
|
@ -52,10 +52,18 @@ a {
|
|||
@apply text-white;
|
||||
}
|
||||
|
||||
.btn-primary:focus-visible {
|
||||
@apply outline-primary;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.btn-error:focus-visible {
|
||||
@apply outline-error;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
@apply rounded;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/* Override SDK styles */
|
||||
.sdk-input:focus {
|
||||
.sdk-input:focus,
|
||||
.sdk-select:focus-visible + span {
|
||||
/* Below styles copied from the tailwindcss/forms plugin */
|
||||
outline: 2px solid hsla(var(--bc) / 0.2);
|
||||
outline-offset: 2px;
|
||||
|
|
Loading…
Reference in New Issue