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 { 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 { errorToast } from '@components/Toaster';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
|
||||||
import { LinkBack } from '@components/LinkBack';
|
import { LinkBack } from '@components/LinkBack';
|
||||||
|
import { CreateSSOConnection } from '@boxyhq/react-ui/sso';
|
||||||
function getInitialState(connectionType, fieldCatalog: FieldCatalogItem[]) {
|
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||||
const _state = {};
|
import { AdminPortalSSODefaults } from '@lib/utils';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreateConnection = ({
|
const CreateConnection = ({
|
||||||
setupLinkToken,
|
|
||||||
isSettingsView = false,
|
isSettingsView = false,
|
||||||
adminPortalSSODefaults,
|
adminPortalSSODefaults,
|
||||||
}: {
|
}: {
|
||||||
setupLinkToken?: string;
|
|
||||||
idpEntityID?: string;
|
idpEntityID?: string;
|
||||||
isSettingsView?: boolean;
|
isSettingsView?: boolean;
|
||||||
adminPortalSSODefaults?: AdminPortalSSODefaults;
|
adminPortalSSODefaults?: AdminPortalSSODefaults;
|
||||||
}) => {
|
}) => {
|
||||||
const fieldCatalog = useFieldCatalog({ isSettingsView });
|
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// STATE: New connection type
|
const redirectUrl = isSettingsView ? '/admin/settings/sso-connection' : '/admin/sso-connection';
|
||||||
const [newConnectionType, setNewConnectionType] = useState<'saml' | 'oidc'>('saml');
|
|
||||||
|
|
||||||
const handleNewConnectionTypeChange = (event) => {
|
const backUrl = redirectUrl;
|
||||||
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 };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{backUrl && <LinkBack href={backUrl} />}
|
{backUrl && <LinkBack href={backUrl} />}
|
||||||
<div>
|
<h2 className='mb-8 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
{t('create_sso_connection')}
|
||||||
{t('create_sso_connection')}
|
</h2>
|
||||||
</h2>
|
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||||
<div className='mb-4 flex items-center'>
|
<CreateSSOConnection
|
||||||
<div className='mr-2 py-3'>{t('select_sso_type')}:</div>
|
defaults={isSettingsView ? adminPortalSSODefaults : undefined}
|
||||||
<div className='flex w-52'>
|
variant={{ saml: 'advanced', oidc: 'advanced' }}
|
||||||
<div className='form-control'>
|
urls={{
|
||||||
<label className='label mr-4 cursor-pointer'>
|
post: '/api/admin/connections',
|
||||||
<input
|
}}
|
||||||
type='radio'
|
excludeFields={{ saml: ['label'], oidc: ['label'] }}
|
||||||
name='connection'
|
successCallback={() => router.replace(redirectUrl)}
|
||||||
value='saml'
|
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||||
className='radio-primary radio'
|
classNames={BOXYHQ_UI_CSS}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,56 +1,10 @@
|
||||||
import { useRouter } from 'next/router';
|
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 { errorToast, successToast } from '@components/Toaster';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { LinkBack } from '@components/LinkBack';
|
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';
|
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||||
|
import { EditSAMLConnection, EditOIDCConnection } from '@boxyhq/react-ui/sso';
|
||||||
function getInitialState(connection, fieldCatalog: FieldCatalogItem[], connectionType) {
|
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditProps = {
|
type EditProps = {
|
||||||
connection: SAMLSSORecord | OIDCSSORecord;
|
connection: SAMLSSORecord | OIDCSSORecord;
|
||||||
|
@ -59,8 +13,6 @@ type EditProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }: EditProps) => {
|
const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }: EditProps) => {
|
||||||
const fieldCatalog = useFieldCatalog({ isEditView: true, isSettingsView });
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
|
|
||||||
|
@ -69,117 +21,16 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
|
||||||
const connectionIsSAML = 'idpMetadata' in connection && typeof connection.idpMetadata === 'object';
|
const connectionIsSAML = 'idpMetadata' in connection && typeof connection.idpMetadata === 'object';
|
||||||
const connectionIsOIDC = 'oidcProvider' in connection && typeof connection.oidcProvider === '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
|
const backUrl = setupLinkToken
|
||||||
? `/setup/${setupLinkToken}`
|
? `/setup/${setupLinkToken}`
|
||||||
: isSettingsView
|
: isSettingsView
|
||||||
? '/admin/settings/sso-connection'
|
? '/admin/settings/sso-connection'
|
||||||
: '/admin/sso-connection';
|
: '/admin/sso-connection';
|
||||||
|
|
||||||
const fieldsToHideInSetupView = ['forceAuthn', 'clientID', 'clientSecret', 'idpCertExpiry', 'idpMetadata'];
|
const apiUrl = setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : `/api/admin/connections`;
|
||||||
const readOnlyFields = filteredFieldsByConnection
|
const connectionFetchUrl = setupLinkToken
|
||||||
.filter((field) => field.attributes.editable === false)
|
? `/api/setup/${setupLinkToken}/sso-connection/${connectionClientId}`
|
||||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
: `/api/admin/connections/${connectionClientId}`;
|
||||||
.filter(excludeFallback(formObj))
|
|
||||||
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true));
|
|
||||||
|
|
||||||
return (
|
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'>
|
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||||
{t('edit_sso_connection')}
|
{t('edit_sso_connection')}
|
||||||
</h2>
|
</h2>
|
||||||
<ToggleConnectionStatus connection={connection} setupLinkToken={setupLinkToken} />
|
|
||||||
</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'>
|
||||||
<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'>
|
{connectionIsSAML && (
|
||||||
<div className='flex flex-col gap-0 lg:flex-row lg:gap-4'>
|
<EditSAMLConnection
|
||||||
<div
|
displayHeader={false}
|
||||||
className={`w-full rounded border-gray-200 dark:border-gray-700 lg:border lg:p-3 ${readOnlyFields.length > 0 ? 'lg:w-3/5' : ''}`}>
|
displayIdpMetadata={true}
|
||||||
{filteredFieldsByConnection
|
displayInfo={setupLinkToken ? false : true}
|
||||||
.filter((field) => field.attributes.editable !== false)
|
excludeFields={
|
||||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
setupLinkToken
|
||||||
.filter(excludeFallback(formObj))
|
? [
|
||||||
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true))
|
'name',
|
||||||
.map(renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback }))}
|
'tenant',
|
||||||
</div>
|
'description',
|
||||||
{readOnlyFields.length > 0 && (
|
'defaultRedirectUrl',
|
||||||
<div className='w-full rounded border-gray-200 dark:border-gray-700 lg:w-2/5 lg:border lg:p-3'>
|
'redirectUrl',
|
||||||
{readOnlyFields.map(
|
'product',
|
||||||
renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback })
|
'label',
|
||||||
)}
|
'sortOrder',
|
||||||
</div>
|
]
|
||||||
)}
|
: ['label']
|
||||||
</div>
|
}
|
||||||
<div className='flex w-full lg:mt-6'>
|
classNames={BOXYHQ_UI_CSS}
|
||||||
<ButtonPrimary type='submit'>{t('save_changes')}</ButtonPrimary>
|
variant='advanced'
|
||||||
</div>
|
urls={{
|
||||||
</div>
|
delete: apiUrl,
|
||||||
{connection?.clientID && connection.clientSecret && (
|
patch: apiUrl,
|
||||||
<section className='mt-10 flex items-center rounded bg-red-100 p-6 text-red-900'>
|
get: connectionFetchUrl,
|
||||||
<div className='flex-1'>
|
}}
|
||||||
<h6 className='mb-1 font-medium'>{t('delete_this_connection')}</h6>
|
successCallback={({ operation }) => {
|
||||||
<p className='font-light'>{t('all_your_apps_using_this_connection_will_stop_working')}</p>
|
operation === 'UPDATE'
|
||||||
</div>
|
? successToast(t('saved'))
|
||||||
<ButtonDanger
|
: operation === 'DELETE'
|
||||||
type='button'
|
? successToast(t('sso_connection_deleted_successfully'))
|
||||||
onClick={toggleDelConfirm}
|
: successToast(t('copied'));
|
||||||
data-modal-toggle='popup-modal'
|
if (operation !== 'COPY') {
|
||||||
data-testid='delete-connection'>
|
router.replace(
|
||||||
{t('delete')}
|
setupLinkToken
|
||||||
</ButtonDanger>
|
? `/setup/${setupLinkToken}/sso-connection`
|
||||||
</section>
|
: isSettingsView
|
||||||
|
? '/admin/settings/sso-connection'
|
||||||
|
: '/admin/sso-connection'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</form>
|
{connectionIsOIDC && (
|
||||||
<ConfirmationModal
|
<EditOIDCConnection
|
||||||
title={t('delete_the_connection')}
|
displayHeader={false}
|
||||||
description={t('confirmation_modal_description')}
|
displayInfo={setupLinkToken ? false : true}
|
||||||
visible={delModalVisible}
|
variant='advanced'
|
||||||
onConfirm={deleteConnection}
|
excludeFields={
|
||||||
onCancel={toggleDelConfirm}
|
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>
|
</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 { useTranslation } from 'next-i18next';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { ApiResponse } from 'types';
|
|
||||||
import { errorToast, successToast } from '@components/Toaster';
|
import { errorToast, successToast } from '@components/Toaster';
|
||||||
import type { Directory } from '@boxyhq/saml-jackson';
|
|
||||||
import { LinkBack } from '@components/LinkBack';
|
import { LinkBack } from '@components/LinkBack';
|
||||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
import { CreateDirectory as CreateDSync } from '@boxyhq/react-ui/dsync';
|
||||||
import useDirectoryProviders from '@lib/ui/hooks/useDirectoryProviders';
|
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||||
|
|
||||||
interface CreateDirectoryProps {
|
interface CreateDirectoryProps {
|
||||||
setupLinkToken?: string;
|
setupLinkToken?: string;
|
||||||
defaultWebhookEndpoint: string | undefined;
|
defaultWebhookEndpoint?: string;
|
||||||
|
defaultWebhookSecret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnSavedDirectory = Omit<Directory, 'id' | 'log_webhook_events' | 'scim' | 'deactivated' | 'webhook'> & {
|
const CreateDirectory = ({
|
||||||
webhook_url: string;
|
setupLinkToken,
|
||||||
webhook_secret: string;
|
defaultWebhookEndpoint,
|
||||||
};
|
defaultWebhookSecret,
|
||||||
|
}: CreateDirectoryProps) => {
|
||||||
const defaultDirectory: UnSavedDirectory = {
|
|
||||||
name: '',
|
|
||||||
tenant: '',
|
|
||||||
product: '',
|
|
||||||
webhook_url: '',
|
|
||||||
webhook_secret: '',
|
|
||||||
type: 'azure-scim-v2',
|
|
||||||
google_domain: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirectoryProps) => {
|
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const router = useRouter();
|
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';
|
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<LinkBack href={backUrl} />
|
<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'>
|
<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}>
|
<CreateDSync
|
||||||
<div className='flex flex-col space-y-3'>
|
displayHeader={false}
|
||||||
{!setupLinkToken && (
|
defaultWebhookEndpoint={defaultWebhookEndpoint}
|
||||||
<div className='form-control w-full'>
|
defaultWebhookSecret={defaultWebhookSecret}
|
||||||
<label className='label'>
|
classNames={BOXYHQ_UI_CSS}
|
||||||
<span className='label-text'>{t('directory_name')}</span>
|
successCallback={({ connection }) => {
|
||||||
</label>
|
successToast(t('directory_created_successfully'));
|
||||||
<input type='text' id='name' className='input-bordered input w-full' onChange={onChange} />
|
connection?.id &&
|
||||||
</div>
|
router.replace(
|
||||||
)}
|
setupLinkToken
|
||||||
<div className='form-control w-full'>
|
? `/setup/${setupLinkToken}/directory-sync/${connection.id}`
|
||||||
<label className='label'>
|
: `/admin/directory-sync/${connection.id}`
|
||||||
<span className='label-text'>{t('directory_provider')}</span>
|
);
|
||||||
</label>
|
}}
|
||||||
<select className='select-bordered select w-full' id='type' onChange={onChange} required>
|
errorCallback={(errorMessage) => {
|
||||||
{providers &&
|
errorToast(errorMessage);
|
||||||
Object.keys(providers).map((key) => {
|
}}
|
||||||
return (
|
excludeFields={
|
||||||
<option key={key} value={key}>
|
setupLinkToken
|
||||||
{providers[key]}
|
? ['name', 'tenant', 'product', 'webhook_url', 'webhook_secret', 'log_webhook_events']
|
||||||
</option>
|
: undefined
|
||||||
);
|
}
|
||||||
})}
|
urls={{
|
||||||
</select>
|
post: setupLinkToken
|
||||||
</div>
|
? `/api/setup/${setupLinkToken}/directory-sync`
|
||||||
{showDomain && (
|
: '/api/admin/directory-sync',
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,50 +1,19 @@
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import type { Directory } from '@boxyhq/saml-jackson';
|
|
||||||
import type { ApiResponse } from 'types';
|
|
||||||
import { errorToast, successToast } from '@components/Toaster';
|
import { errorToast, successToast } from '@components/Toaster';
|
||||||
import { LinkBack } from '@components/LinkBack';
|
import { LinkBack } from '@components/LinkBack';
|
||||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
|
||||||
import Loading from '@components/Loading';
|
import Loading from '@components/Loading';
|
||||||
import useDirectory from '@lib/ui/hooks/useDirectory';
|
import useDirectory from '@lib/ui/hooks/useDirectory';
|
||||||
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
|
import { EditDirectory as EditDSync } from '@boxyhq/react-ui/dsync';
|
||||||
import { DeleteDirectory } from './DeleteDirectory';
|
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||||
|
|
||||||
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook' | 'google_domain'>;
|
|
||||||
|
|
||||||
const defaultFormState: FormState = {
|
|
||||||
name: '',
|
|
||||||
log_webhook_events: false,
|
|
||||||
webhook: {
|
|
||||||
endpoint: '',
|
|
||||||
secret: '',
|
|
||||||
},
|
|
||||||
google_domain: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
|
const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [directoryUpdated, setDirectoryUpdated] = useState<FormState>(defaultFormState);
|
|
||||||
const { directory, isLoading, isValidating, error } = useDirectory(directoryId, setupLinkToken);
|
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) {
|
if (isLoading || !directory || isValidating) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
@ -55,147 +24,54 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
||||||
}
|
}
|
||||||
|
|
||||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||||
const patchUrl = setupLinkToken
|
|
||||||
|
const apiUrl = setupLinkToken
|
||||||
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
|
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
|
||||||
: `/api/admin/directory-sync/${directoryId}`;
|
: `/api/admin/directory-sync/${directoryId}`;
|
||||||
const redirectUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<LinkBack href={backUrl} />
|
<LinkBack href={backUrl} />
|
||||||
<div className='flex items-center justify-between'>
|
<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>
|
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('edit_directory')}</h2>
|
||||||
<ToggleConnectionStatus connection={directory} setupLinkToken={setupLinkToken} />
|
|
||||||
</div>
|
</div>
|
||||||
{!setupLinkToken && (
|
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||||
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
<EditDSync
|
||||||
<form onSubmit={onSubmit}>
|
displayHeader={false}
|
||||||
<div className='flex flex-col space-y-3'>
|
urls={{
|
||||||
<div className='form-control w-full'>
|
patch: apiUrl,
|
||||||
<label className='label'>
|
delete: apiUrl,
|
||||||
<span className='label-text'>{t('directory_name')}</span>
|
get: apiUrl,
|
||||||
</label>
|
}}
|
||||||
<input
|
excludeFields={
|
||||||
type='text'
|
setupLinkToken
|
||||||
id='name'
|
? [
|
||||||
className='input-bordered input w-full'
|
'name',
|
||||||
required
|
'product',
|
||||||
onChange={onChange}
|
'log_webhook_events',
|
||||||
value={directoryUpdated.name}
|
'tenant',
|
||||||
/>
|
'google_domain',
|
||||||
</div>
|
'type',
|
||||||
{directory.type === 'google' && (
|
'webhook_url',
|
||||||
<div className='form-control w-full'>
|
'webhook_secret',
|
||||||
<label className='label'>
|
'scim_endpoint',
|
||||||
<span className='label-text'>{t('directory_domain')}</span>
|
'scim_token',
|
||||||
</label>
|
'google_authorization_url',
|
||||||
<input
|
]
|
||||||
type='text'
|
: undefined
|
||||||
id='google_domain'
|
}
|
||||||
className='input-bordered input w-full'
|
successCallback={() => {
|
||||||
onChange={onChange}
|
successToast(t('directory_updated_successfully'));
|
||||||
value={directoryUpdated.google_domain}
|
router.replace(redirectUrl);
|
||||||
/>
|
}}
|
||||||
</div>
|
errorCallback={(errMessage) => {
|
||||||
)}
|
errorToast(errMessage);
|
||||||
<div className='form-control w-full'>
|
}}
|
||||||
<label className='label'>
|
hideSave={setupLinkToken ? true : false}
|
||||||
<span className='label-text'>{t('webhook_url')}</span>
|
classNames={BOXYHQ_UI_CSS}
|
||||||
</label>
|
/>
|
||||||
<input
|
</div>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
import { CreateSAMLConnection as CreateSAML, CreateOIDCConnection as CreateOIDC } from '@boxyhq/react-ui/sso';
|
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 { errorToast, successToast } from '@components/Toaster';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||||
|
|
||||||
interface CreateSSOConnectionProps {
|
interface CreateSSOConnectionProps {
|
||||||
setupLinkToken: string;
|
setupLinkToken: string;
|
||||||
|
@ -30,19 +30,13 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
|
||||||
post: `/api/setup/${setupLinkToken}/sso-connection`,
|
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' ? (
|
return idpType === 'saml' ? (
|
||||||
<CreateSAML
|
<CreateSAML
|
||||||
variant='basic'
|
variant='basic'
|
||||||
urls={urls}
|
urls={urls}
|
||||||
successCallback={onSuccess}
|
successCallback={onSuccess}
|
||||||
errorCallback={onError}
|
errorCallback={onError}
|
||||||
classNames={_CSS}
|
classNames={BOXYHQ_UI_CSS}
|
||||||
displayHeader={false}
|
displayHeader={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
@ -51,7 +45,7 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
|
||||||
urls={urls}
|
urls={urls}
|
||||||
successCallback={onSuccess}
|
successCallback={onSuccess}
|
||||||
errorCallback={onError}
|
errorCallback={onError}
|
||||||
classNames={_CSS}
|
classNames={BOXYHQ_UI_CSS}
|
||||||
displayHeader={false}
|
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
|
// Find the new connection button and click on it
|
||||||
await page.getByTestId('create-connection').click();
|
await page.getByTestId('create-connection').click();
|
||||||
// Fill the name for the connection
|
// 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);
|
await nameInput.fill(TEST_SAML_SSO_CONNECTION_NAME);
|
||||||
// Enter the metadata url for mocksaml in the form
|
// 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);
|
await metadataUrlInput.fill(MOCKSAML_METADATA_URL);
|
||||||
// submit the form
|
// 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
|
// check if the added connection appears in the connection list
|
||||||
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).toBeVisible();
|
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');
|
const editButton = page.getByText(TEST_SAML_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
|
||||||
await editButton.click();
|
await editButton.click();
|
||||||
// click the delete and confirm deletion
|
// click the delete and confirm deletion
|
||||||
await page.getByTestId('delete-connection').click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
await page.getByTestId('confirm-delete').click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
// check that the SSO connection is deleted from the connection list
|
// check that the SSO connection is deleted from the connection list
|
||||||
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).not.toBeVisible();
|
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
|
// Find the new connection button and click on it
|
||||||
await page.getByTestId('create-connection').click();
|
await page.getByTestId('create-connection').click();
|
||||||
// Toggle connection type to OIDC
|
// Toggle connection type to OIDC
|
||||||
await page.getByTestId('sso-type-oidc').click();
|
await page.getByLabel('OIDC').check();
|
||||||
// Fill the name for the connection
|
// 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);
|
await nameInput.fill(TEST_OIDC_SSO_CONNECTION_NAME);
|
||||||
if (mode === 'discoveryUrl') {
|
if (mode === 'discoveryUrl') {
|
||||||
// Enter the OIDC discovery url for mocklab in the form
|
// 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);
|
await discoveryUrlInput.fill(MOCKLAB_DISCOVERY_ENDPOINT);
|
||||||
} else {
|
} else {
|
||||||
// Activate the oidc discovery fallback fields
|
|
||||||
await page.getByTestId('oidcDiscoveryUrl-fallback-switch').click();
|
|
||||||
// Enter the OIDC issuer value for mocklab in the form
|
// 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);
|
await issuerInput.fill(MOCKLAB_ISSUER);
|
||||||
// Enter the OIDC authorization_endpoint value for mocklab in the form
|
// 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);
|
await authzEndpointInput.fill(MOCKLAB_AUTHORIZATION_ENDPOINT);
|
||||||
// Enter the OIDC token_endpoint value for mocklab in the form
|
// 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);
|
await tokenEndpointInput.fill(MOCKLAB_TOKEN_ENDPOINT);
|
||||||
// Enter the OIDC userinfo_endpoint value for mocklab in the form
|
// 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);
|
await userInfoEndpointInput.fill(MOCKLAB_USERINFO_ENDPOINT);
|
||||||
// Enter the OIDC jwks_uri value for mocklab in the form
|
// 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);
|
await jwksUriInput.fill(MOCKLAB_JWKS_URI);
|
||||||
}
|
}
|
||||||
// Enter the OIDC client credentials for mocklab in the form
|
// 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);
|
await clientIdInput.fill(MOCKLAB_CLIENT_ID);
|
||||||
const clientSecretInput = page.getByTestId('oidcClientSecret');
|
const clientSecretInput = page.getByLabel('Client Secret');
|
||||||
await clientSecretInput.fill(MOCKLAB_CLIENT_SECRET);
|
await clientSecretInput.fill(MOCKLAB_CLIENT_SECRET);
|
||||||
// submit the form
|
// 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
|
// check if the added connection appears in the connection list
|
||||||
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).toBeVisible();
|
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');
|
const editButton = page.getByText(TEST_OIDC_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
|
||||||
await editButton.click();
|
await editButton.click();
|
||||||
// click the delete and confirm deletion
|
// click the delete and confirm deletion
|
||||||
await page.getByTestId('delete-connection').click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
await page.getByTestId('confirm-delete').click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
// check that the SSO connection is deleted from the connection list
|
// check that the SSO connection is deleted from the connection list
|
||||||
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).not.toBeVisible();
|
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'
|
className='input input-bordered w-full text-sm'
|
||||||
name='expiryDays'
|
name='expiryDays'
|
||||||
required
|
required
|
||||||
|
min={1}
|
||||||
onChange={formik.handleChange}
|
onChange={formik.handleChange}
|
||||||
value={formik.values.expiryDays}
|
value={formik.values.expiryDays}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -168,6 +168,7 @@ export const SSOForm = ({
|
||||||
className='input input-bordered w-full text-sm'
|
className='input input-bordered w-full text-sm'
|
||||||
name='expiryDays'
|
name='expiryDays'
|
||||||
required
|
required
|
||||||
|
min={1}
|
||||||
onChange={formik.handleChange}
|
onChange={formik.handleChange}
|
||||||
value={formik.values.expiryDays}
|
value={formik.values.expiryDays}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -100,3 +100,10 @@ export const parsePaginateApiParams = (params: NextApiRequest['query']): Paginat
|
||||||
pageToken,
|
pageToken,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminPortalSSODefaults = {
|
||||||
|
tenant: string;
|
||||||
|
product: string;
|
||||||
|
redirectUrl: string;
|
||||||
|
defaultRedirectUrl: string;
|
||||||
|
};
|
||||||
|
|
|
@ -2,26 +2,19 @@
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"all_your_apps_using_this_connection_will_stop_working": "All your apps using this connection will stop working.",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"close_sidebar": "Close sidebar",
|
"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_new": "Create New",
|
||||||
"create_sso_connection": "Create SSO Connection",
|
"create_sso_connection": "Create SSO Connection",
|
||||||
|
"create_dsync_connection": "Create DSync Connection",
|
||||||
"delete": "Delete",
|
"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",
|
"directory_sync": "Directory Sync",
|
||||||
"edit_sso_connection": "Edit SSO Connection",
|
"edit_sso_connection": "Edit SSO Connection",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"enable_webhook_events_logging": "Enable Webhook events logging",
|
|
||||||
"boxyhq_tagline": "Security Building Blocks for Developers.",
|
"boxyhq_tagline": "Security Building Blocks for Developers.",
|
||||||
"enterprise_sso": "Enterprise SSO",
|
"enterprise_sso": "Enterprise SSO",
|
||||||
"idp_entity_id": "IdP Entity ID",
|
"idp_entity_id": "IdP Entity ID",
|
||||||
|
@ -33,24 +26,17 @@
|
||||||
"connections": "Connections",
|
"connections": "Connections",
|
||||||
"new_connection": "New Connection",
|
"new_connection": "New Connection",
|
||||||
"no_projects_found": "No projects found.",
|
"no_projects_found": "No projects found.",
|
||||||
"oidc": "OIDC",
|
|
||||||
"open_menu": "Open menu",
|
"open_menu": "Open menu",
|
||||||
"open_sidebar": "Open sidebar",
|
"open_sidebar": "Open sidebar",
|
||||||
"product": "Product",
|
|
||||||
"save_changes": "Save Changes",
|
"save_changes": "Save Changes",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"server_error": "Server error",
|
"server_error": "Server error",
|
||||||
"sign_out": "Sign out",
|
"sign_out": "Sign out",
|
||||||
"saml": "SAML",
|
|
||||||
"sso_error": "SSO error",
|
"sso_error": "SSO error",
|
||||||
"select_sso_type": "Select SSO type",
|
|
||||||
"select_an_app": "Select an App to continue",
|
"select_an_app": "Select an App to continue",
|
||||||
"send_magic_link": "Send Magic Link",
|
"send_magic_link": "Send Magic Link",
|
||||||
"setup_links": "Setup Links",
|
"setup_links": "Setup Links",
|
||||||
"tenant": "Tenant",
|
|
||||||
"edit_directory": "Edit Directory",
|
"edit_directory": "Edit Directory",
|
||||||
"webhook_secret": "Webhook secret",
|
|
||||||
"webhook_url": "Webhook URL",
|
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"saml_federation_new_success": "Identity Federation app created successfully.",
|
"saml_federation_new_success": "Identity Federation app created successfully.",
|
||||||
"entity_id": "Entity ID / Audience URI / Audience Restriction",
|
"entity_id": "Entity ID / Audience URI / Audience Restriction",
|
||||||
|
@ -131,9 +117,6 @@
|
||||||
"delete_this_directory": "Delete this directory connection?",
|
"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.",
|
"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_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",
|
"retraced_project_created": "Project created successfully",
|
||||||
"project_name": "Project name",
|
"project_name": "Project name",
|
||||||
"create_project": "Create Project",
|
"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.",
|
"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.",
|
"no_saml_response_try_again": "No SAMLResponse found. Please try again.",
|
||||||
"sso_connection_created_successfully": "SSO Connection created successfully",
|
"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",
|
"saml_federation_entity_id_generated": "SP Entity ID generated",
|
||||||
"setup-link-created": "A new setup link created.",
|
"setup-link-created": "A new setup link created.",
|
||||||
"setup-link-regenerated": "The setup link regenerated.",
|
"setup-link-regenerated": "The setup link regenerated.",
|
||||||
|
|
|
@ -216,7 +216,7 @@ const saml = {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
forceAuthn = false,
|
forceAuthn,
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
...clientInfo
|
...clientInfo
|
||||||
} = body;
|
} = body;
|
||||||
|
@ -302,7 +302,7 @@ const saml = {
|
||||||
metadataUrl: newMetadata ? newMetadataUrl : _savedConnection.metadataUrl,
|
metadataUrl: newMetadata ? newMetadataUrl : _savedConnection.metadataUrl,
|
||||||
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _savedConnection.defaultRedirectUrl,
|
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _savedConnection.defaultRedirectUrl,
|
||||||
redirectUrl: redirectUrlList ? redirectUrlList : _savedConnection.redirectUrl,
|
redirectUrl: redirectUrlList ? redirectUrlList : _savedConnection.redirectUrl,
|
||||||
forceAuthn,
|
forceAuthn: typeof forceAuthn === 'boolean' ? forceAuthn : _savedConnection.forceAuthn,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ('sortOrder' in body) {
|
if ('sortOrder' in body) {
|
||||||
|
|
|
@ -233,7 +233,8 @@ export class SetupLinkController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = crypto.randomBytes(24).toString('hex');
|
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 setupID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, service));
|
||||||
|
|
||||||
const setupLink: SetupLink = {
|
const setupLink: SetupLink = {
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boxyhq/internal-ui": "file:internal-ui",
|
"@boxyhq/internal-ui": "file:internal-ui",
|
||||||
"@boxyhq/metrics": "0.2.6",
|
"@boxyhq/metrics": "0.2.6",
|
||||||
"@boxyhq/react-ui": "3.3.39",
|
"@boxyhq/react-ui": "3.3.41",
|
||||||
"@boxyhq/saml-jackson": "file:npm",
|
"@boxyhq/saml-jackson": "file:npm",
|
||||||
"@heroicons/react": "2.1.3",
|
"@heroicons/react": "2.1.3",
|
||||||
"@retracedhq/logs-viewer": "2.7.1",
|
"@retracedhq/logs-viewer": "2.7.1",
|
||||||
|
@ -2030,9 +2030,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@boxyhq/react-ui": {
|
"node_modules/@boxyhq/react-ui": {
|
||||||
"version": "3.3.39",
|
"version": "3.3.41",
|
||||||
"resolved": "https://registry.npmjs.org/@boxyhq/react-ui/-/react-ui-3.3.39.tgz",
|
"resolved": "https://registry.npmjs.org/@boxyhq/react-ui/-/react-ui-3.3.41.tgz",
|
||||||
"integrity": "sha512-1kSQIc5AjWbh8rUwIlG+BhRZy8kXJg6fgv/2SxWbkf874XILUK+TobwtVXnUncRC3pQthh7sVYfR6XW7FF/J0w==",
|
"integrity": "sha512-j0c4VVNoqx73i3gkQQx16KOhfK7PRdUO6wvgxGwhaRC7cqf+NoSSqZuyazYu1PyDCTeiQv6vv/07yIuAQuP+ZA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
"react-dom": "^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": {
|
"dependencies": {
|
||||||
"@boxyhq/internal-ui": "file:internal-ui",
|
"@boxyhq/internal-ui": "file:internal-ui",
|
||||||
"@boxyhq/metrics": "0.2.6",
|
"@boxyhq/metrics": "0.2.6",
|
||||||
"@boxyhq/react-ui": "3.3.39",
|
"@boxyhq/react-ui": "3.3.41",
|
||||||
"@boxyhq/saml-jackson": "file:npm",
|
"@boxyhq/saml-jackson": "file:npm",
|
||||||
"@heroicons/react": "2.1.3",
|
"@heroicons/react": "2.1.3",
|
||||||
"@retracedhq/logs-viewer": "2.7.1",
|
"@retracedhq/logs-viewer": "2.7.1",
|
||||||
|
|
|
@ -5,9 +5,14 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||||
import { jacksonOptions } from '@lib/env';
|
import { jacksonOptions } from '@lib/env';
|
||||||
|
|
||||||
const DirectoryCreatePage: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = (props) => {
|
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) => {
|
export const getServerSideProps = async ({ locale }: GetServerSidePropsContext) => {
|
||||||
|
@ -15,6 +20,7 @@ export const getServerSideProps = async ({ locale }: GetServerSidePropsContext)
|
||||||
props: {
|
props: {
|
||||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||||
defaultWebhookEndpoint: jacksonOptions.webhook?.endpoint,
|
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 { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||||
import Loading from '@components/Loading';
|
import Loading from '@components/Loading';
|
||||||
import { errorToast } from '@components/Toaster';
|
import { errorToast } from '@components/Toaster';
|
||||||
import type { ApiError, ApiSuccess } from 'types';
|
|
||||||
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||||
|
|
||||||
const EditSSOConnection: NextPage = () => {
|
const EditSSOConnection: NextPage = () => {
|
||||||
|
@ -15,7 +14,7 @@ const EditSSOConnection: NextPage = () => {
|
||||||
|
|
||||||
const { id } = router.query as { id: string };
|
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,
|
id ? `/api/admin/connections/${id}` : null,
|
||||||
fetcher,
|
fetcher,
|
||||||
{
|
{
|
||||||
|
@ -32,11 +31,11 @@ const EditSSOConnection: NextPage = () => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data?.data) {
|
if (!data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <EditConnection connection={data?.data} isSettingsView />;
|
return <EditConnection connection={data[0]} isSettingsView />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { GetServerSidePropsContext, NextPage } from 'next';
|
import type { GetServerSidePropsContext, NextPage } from 'next';
|
||||||
import CreateConnection from '@components/connection/CreateConnection';
|
import CreateConnection from '@components/connection/CreateConnection';
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||||
import type { AdminPortalSSODefaults } from '@components/connection/utils';
|
import type { AdminPortalSSODefaults } from '@lib/utils';
|
||||||
import { adminPortalSSODefaults } from '@lib/env';
|
import { adminPortalSSODefaults } from '@lib/env';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { useRouter } from 'next/router';
|
||||||
import { fetcher } from '@lib/ui/utils';
|
import { fetcher } from '@lib/ui/utils';
|
||||||
import EditConnection from '@components/connection/EditConnection';
|
import EditConnection from '@components/connection/EditConnection';
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||||
import type { ApiError, ApiSuccess } from 'types';
|
|
||||||
import Loading from '@components/Loading';
|
import Loading from '@components/Loading';
|
||||||
import { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
import { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||||
import { errorToast } from '@components/Toaster';
|
import { errorToast } from '@components/Toaster';
|
||||||
|
@ -15,12 +14,13 @@ const ConnectionEditPage: NextPage = () => {
|
||||||
|
|
||||||
const { id } = router.query as { id: string };
|
const { id } = router.query as { id: string };
|
||||||
|
|
||||||
const { data, error, isLoading, isValidating } = useSWR<
|
const { data, error, isLoading, isValidating } = useSWR<SAMLSSORecord | OIDCSSORecord>(
|
||||||
ApiSuccess<SAMLSSORecord | OIDCSSORecord>,
|
id ? `/api/admin/connections/${id}` : null,
|
||||||
ApiError
|
fetcher,
|
||||||
>(id ? `/api/admin/connections/${id}` : null, fetcher, {
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading || isValidating) {
|
if (isLoading || isValidating) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
|
@ -31,11 +31,11 @@ const ConnectionEditPage: NextPage = () => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data?.data) {
|
if (!data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <EditConnection connection={data?.data} />;
|
return <EditConnection connection={data[0]} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
||||||
|
|
|
@ -24,7 +24,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
throw new ApiError('Connection not found', 404);
|
throw new ApiError('Connection not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ data: connections[0] });
|
res.json(connections);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default handler;
|
export default handler;
|
||||||
|
|
|
@ -39,15 +39,16 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
|
||||||
const connection = connections[0];
|
const connection = connections[0];
|
||||||
|
|
||||||
res.json({
|
res.json([
|
||||||
data: {
|
{
|
||||||
clientID: connection.clientID,
|
clientID: connection.clientID,
|
||||||
clientSecret: connection.clientSecret,
|
clientSecret: connection.clientSecret,
|
||||||
deactivated: connection.deactivated,
|
deactivated: connection.deactivated,
|
||||||
|
...('forceAuthn' in connection ? { forceAuthn: connection.forceAuthn } : undefined),
|
||||||
...('idpMetadata' in connection ? { idpMetadata: {}, metadataUrl: connection.metadataUrl } : undefined),
|
...('idpMetadata' in connection ? { idpMetadata: {}, metadataUrl: connection.metadataUrl } : undefined),
|
||||||
...('oidcProvider' in connection ? { oidcProvider: connection.oidcProvider } : undefined),
|
...('oidcProvider' in connection ? { oidcProvider: connection.oidcProvider } : undefined),
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default handler;
|
export default handler;
|
||||||
|
|
|
@ -45,6 +45,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse, setupLink: S
|
||||||
clientID: connection.clientID,
|
clientID: connection.clientID,
|
||||||
name: connection.name,
|
name: connection.name,
|
||||||
deactivated: connection.deactivated,
|
deactivated: connection.deactivated,
|
||||||
|
...('forceAuthn' in connection ? { forceAuthn: connection.forceAuthn } : undefined),
|
||||||
...('idpMetadata' in connection
|
...('idpMetadata' in connection
|
||||||
? {
|
? {
|
||||||
idpMetadata: {
|
idpMetadata: {
|
||||||
|
@ -106,9 +107,11 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
clientID,
|
clientID,
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
encodedRawMetadata,
|
encodedRawMetadata,
|
||||||
|
forceAuthn,
|
||||||
oidcClientId,
|
oidcClientId,
|
||||||
oidcClientSecret,
|
oidcClientSecret,
|
||||||
oidcDiscoveryUrl,
|
oidcDiscoveryUrl,
|
||||||
|
oidcMetadata,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const connections = await connectionAPIController.getConnections({
|
const connections = await connectionAPIController.getConnections({
|
||||||
|
@ -128,12 +131,13 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
clientID,
|
clientID,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
...('deactivated' in req.body ? { deactivated } : undefined),
|
...('deactivated' in req.body ? { deactivated } : undefined),
|
||||||
...(isSAML ? { metadataUrl, encodedRawMetadata } : undefined),
|
...(isSAML ? { metadataUrl, encodedRawMetadata, forceAuthn } : undefined),
|
||||||
...(isOIDC
|
...(isOIDC
|
||||||
? {
|
? {
|
||||||
oidcClientId,
|
oidcClientId,
|
||||||
oidcClientSecret,
|
oidcClientSecret,
|
||||||
oidcDiscoveryUrl,
|
oidcDiscoveryUrl,
|
||||||
|
oidcMetadata,
|
||||||
}
|
}
|
||||||
: undefined),
|
: undefined),
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,23 +3,19 @@ import React from 'react';
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import CreateDirectory from '@components/dsync/CreateDirectory';
|
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 router = useRouter();
|
||||||
|
|
||||||
const { token } = router.query as { token: string };
|
const { token } = router.query as { token: string };
|
||||||
|
|
||||||
return <CreateDirectory setupLinkToken={token} defaultWebhookEndpoint={defaultWebhookEndpoint} />;
|
return <CreateDirectory setupLinkToken={token} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerSideProps = async ({ locale }: GetServerSidePropsContext) => {
|
export const getServerSideProps = async ({ locale }: GetServerSidePropsContext) => {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||||
defaultWebhookEndpoint: jacksonOptions.webhook?.endpoint,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -28,10 +28,11 @@ const ConnectionEditPage: NextPage = () => {
|
||||||
errorToast(error.message);
|
errorToast(error.message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const connection = data.data;
|
return <EditConnection connection={data[0]} setupLinkToken={token} />;
|
||||||
|
|
||||||
return <EditConnection connection={connection} setupLinkToken={token} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getServerSideProps({ locale }) {
|
export async function getServerSideProps({ locale }) {
|
||||||
|
|
|
@ -52,10 +52,18 @@ a {
|
||||||
@apply text-white;
|
@apply text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary:focus-visible {
|
||||||
|
@apply outline-primary;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-error {
|
.btn-error {
|
||||||
@apply text-white;
|
@apply text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-error:focus-visible {
|
||||||
|
@apply outline-error;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-box {
|
.modal-box {
|
||||||
@apply rounded;
|
@apply rounded;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/* Override SDK styles */
|
/* Override SDK styles */
|
||||||
.sdk-input:focus {
|
.sdk-input:focus,
|
||||||
|
.sdk-select:focus-visible + span {
|
||||||
/* Below styles copied from the tailwindcss/forms plugin */
|
/* Below styles copied from the tailwindcss/forms plugin */
|
||||||
outline: 2px solid hsla(var(--bc) / 0.2);
|
outline: 2px solid hsla(var(--bc) / 0.2);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|
Loading…
Reference in New Issue