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:
Aswin V 2024-03-28 06:35:55 +05:30 committed by GitHub
parent 7f28a6c0a8
commit 67f111711a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 304 additions and 1528 deletions

View File

@ -1,187 +1,44 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import {
saveConnection,
fieldCatalogFilterByConnection,
renderFieldList,
useFieldCatalog,
excludeFallback,
type AdminPortalSSODefaults,
type FormObj,
type FieldCatalogItem,
} from './utils';
import { mutate } from 'swr';
import { ApiResponse } from 'types';
import { errorToast } from '@components/Toaster';
import { useTranslation } from 'next-i18next';
import { ButtonPrimary } from '@components/ButtonPrimary';
import { LinkBack } from '@components/LinkBack';
function getInitialState(connectionType, fieldCatalog: FieldCatalogItem[]) {
const _state = {};
fieldCatalog.forEach(({ key, type, members, fallback, attributes: { connection } }) => {
let value;
if (connection && connection !== connectionType) {
return;
}
/** By default those fields which do not have a fallback.activateCondition will be excluded */
if (typeof fallback === 'object' && typeof fallback.activateCondition !== 'function') {
return;
}
if (type === 'object') {
value = getInitialState(connectionType, members as FieldCatalogItem[]);
}
_state[key] = value ? value : '';
});
return _state;
}
import { CreateSSOConnection } from '@boxyhq/react-ui/sso';
import { BOXYHQ_UI_CSS } from '@components/styles';
import { AdminPortalSSODefaults } from '@lib/utils';
const CreateConnection = ({
setupLinkToken,
isSettingsView = false,
adminPortalSSODefaults,
}: {
setupLinkToken?: string;
idpEntityID?: string;
isSettingsView?: boolean;
adminPortalSSODefaults?: AdminPortalSSODefaults;
}) => {
const fieldCatalog = useFieldCatalog({ isSettingsView });
const { t } = useTranslation('common');
const router = useRouter();
const [loading, setLoading] = useState(false);
// STATE: New connection type
const [newConnectionType, setNewConnectionType] = useState<'saml' | 'oidc'>('saml');
const redirectUrl = isSettingsView ? '/admin/settings/sso-connection' : '/admin/sso-connection';
const handleNewConnectionTypeChange = (event) => {
setNewConnectionType(event.target.value);
};
const connectionIsSAML = newConnectionType === 'saml';
const connectionIsOIDC = newConnectionType === 'oidc';
const backUrl = setupLinkToken
? null
: isSettingsView
? '/admin/settings/sso-connection'
: '/admin/sso-connection';
const redirectUrl = setupLinkToken
? `/setup/${setupLinkToken}/sso-connection`
: isSettingsView
? '/admin/settings/sso-connection'
: '/admin/sso-connection';
const mutationUrl = setupLinkToken
? `/api/setup/${setupLinkToken}/sso-connection`
: isSettingsView
? '/api/admin/connections?isSystemSSO'
: '/api/admin/connections';
// FORM LOGIC: SUBMIT
const save = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
await saveConnection({
formObj: formObj,
connectionIsSAML: connectionIsSAML,
connectionIsOIDC: connectionIsOIDC,
setupLinkToken,
callback: async (rawResponse) => {
setLoading(false);
const response: ApiResponse = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if (rawResponse.ok) {
await mutate(mutationUrl);
router.replace(redirectUrl);
}
},
});
};
// STATE: FORM
const [formObj, setFormObj] = useState<FormObj>(() =>
isSettingsView
? { ...getInitialState(newConnectionType, fieldCatalog), ...adminPortalSSODefaults }
: { ...getInitialState(newConnectionType, fieldCatalog) }
);
// Resync form state on save
useEffect(() => {
const _state = getInitialState(newConnectionType, fieldCatalog);
setFormObj(isSettingsView ? { ..._state, ...adminPortalSSODefaults } : _state);
}, [newConnectionType, fieldCatalog, isSettingsView, adminPortalSSODefaults]);
// HANDLER: Track fallback display
const activateFallback = (key, fallbackKey) => {
setFormObj((cur) => {
const temp = { ...cur };
delete temp[key];
const fallbackItem = fieldCatalog.find(({ key }) => key === fallbackKey);
const fallbackItemVal = fallbackItem?.type === 'object' ? {} : '';
return { ...temp, [fallbackKey]: fallbackItemVal };
});
};
const backUrl = redirectUrl;
return (
<>
{backUrl && <LinkBack href={backUrl} />}
<div>
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
{t('create_sso_connection')}
</h2>
<div className='mb-4 flex items-center'>
<div className='mr-2 py-3'>{t('select_sso_type')}:</div>
<div className='flex w-52'>
<div className='form-control'>
<label className='label mr-4 cursor-pointer'>
<input
type='radio'
name='connection'
value='saml'
className='radio-primary radio'
checked={newConnectionType === 'saml'}
onChange={handleNewConnectionTypeChange}
/>
<span className='label-text ml-1'>{t('saml')}</span>
</label>
</div>
<div className='form-control'>
<label className='label mr-4 cursor-pointer' data-testid='sso-type-oidc'>
<input
type='radio'
name='connection'
value='oidc'
className='radio-primary radio'
checked={newConnectionType === 'oidc'}
onChange={handleNewConnectionTypeChange}
/>
<span className='label-text ml-1'>{t('oidc')}</span>
</label>
</div>
</div>
</div>
<form onSubmit={save}>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
{fieldCatalog
.filter(fieldCatalogFilterByConnection(newConnectionType))
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
.filter(excludeFallback(formObj))
.map(renderFieldList({ formObj, setFormObj, activateFallback }))}
<div className='flex'>
<ButtonPrimary loading={loading} data-testid='submit-form-create-sso'>
{t('save_changes')}
</ButtonPrimary>
</div>
</div>
</form>
<h2 className='mb-8 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
{t('create_sso_connection')}
</h2>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<CreateSSOConnection
defaults={isSettingsView ? adminPortalSSODefaults : undefined}
variant={{ saml: 'advanced', oidc: 'advanced' }}
urls={{
post: '/api/admin/connections',
}}
excludeFields={{ saml: ['label'], oidc: ['label'] }}
successCallback={() => router.replace(redirectUrl)}
errorCallback={(errMessage) => errorToast(errMessage)}
classNames={BOXYHQ_UI_CSS}
/>
</div>
</>
);

View File

@ -1,56 +1,10 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { mutate } from 'swr';
import ConfirmationModal from '@components/ConfirmationModal';
import {
saveConnection,
fieldCatalogFilterByConnection,
renderFieldList,
useFieldCatalog,
type FormObj,
type FieldCatalogItem,
excludeFallback,
} from './utils';
import { ApiResponse } from 'types';
import { errorToast, successToast } from '@components/Toaster';
import { useTranslation } from 'next-i18next';
import { LinkBack } from '@components/LinkBack';
import { ButtonPrimary } from '@components/ButtonPrimary';
import { ButtonDanger } from '@components/ButtonDanger';
import { isObjectEmpty } from '@lib/ui/utils';
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
function getInitialState(connection, fieldCatalog: FieldCatalogItem[], connectionType) {
const _state = {};
fieldCatalog.forEach(({ key, attributes, type, members }) => {
let value;
if (attributes.connection && attributes.connection !== connectionType) {
return;
}
if (type === 'object') {
value = getInitialState(connection, members as FieldCatalogItem[], connectionType);
if (isObjectEmpty(value)) {
return;
}
} else if (typeof attributes.accessor === 'function') {
if (attributes.accessor(connection) === undefined) {
return;
}
value = attributes.accessor(connection);
} else {
value = connection?.[key];
}
_state[key] = value
? attributes.isArray
? value.join('\r\n') // render list of items on newline eg:- redirect URLs
: value
: '';
});
return _state;
}
import { EditSAMLConnection, EditOIDCConnection } from '@boxyhq/react-ui/sso';
import { BOXYHQ_UI_CSS } from '@components/styles';
type EditProps = {
connection: SAMLSSORecord | OIDCSSORecord;
@ -59,8 +13,6 @@ type EditProps = {
};
const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }: EditProps) => {
const fieldCatalog = useFieldCatalog({ isEditView: true, isSettingsView });
const router = useRouter();
const { t } = useTranslation('common');
@ -69,117 +21,16 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
const connectionIsSAML = 'idpMetadata' in connection && typeof connection.idpMetadata === 'object';
const connectionIsOIDC = 'oidcProvider' in connection && typeof connection.oidcProvider === 'object';
// FORM LOGIC: SUBMIT
const save = async (event) => {
event.preventDefault();
saveConnection({
formObj: formObj,
connectionIsSAML: connectionIsSAML,
connectionIsOIDC: connectionIsOIDC,
isEditView: true,
setupLinkToken,
callback: async (res) => {
if (res.ok) {
successToast(t('saved'));
// revalidate on save
mutate(
setupLinkToken
? `/api/setup/${setupLinkToken}/sso-connection`
: `/api/admin/connections/${connectionClientId}`
);
return;
}
const response: ApiResponse = await res.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
},
});
};
// LOGIC: DELETE
const [delModalVisible, setDelModalVisible] = useState(false);
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
const deleteConnection = async () => {
const queryParams = new URLSearchParams({
clientID: connection.clientID,
clientSecret: connection.clientSecret,
});
const res = await fetch(
setupLinkToken
? `/api/setup/${setupLinkToken}/sso-connection?${queryParams}`
: `/api/admin/connections?${queryParams}`,
{
method: 'DELETE',
}
);
const response: ApiResponse = await res.json();
toggleDelConfirm();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if (res.ok) {
await mutate(
setupLinkToken
? `/api/setup/${setupLinkToken}/connections`
: isSettingsView
? `/api/admin/connections?isSystemSSO`
: '/api/admin/connections'
);
router.replace(
setupLinkToken
? `/setup/${setupLinkToken}/sso-connection`
: isSettingsView
? '/admin/settings/sso-connection'
: '/admin/sso-connection'
);
}
};
const connectionType = connectionIsSAML ? 'saml' : connectionIsOIDC ? 'oidc' : null;
// STATE: FORM
const [formObj, setFormObj] = useState<FormObj>(() =>
getInitialState(connection, fieldCatalog, connectionType)
);
// Resync form state on save
useEffect(() => {
const _state = getInitialState(connection, fieldCatalog, connectionType);
setFormObj(_state);
}, [connection, fieldCatalog, connectionType]);
const filteredFieldsByConnection = fieldCatalog.filter(fieldCatalogFilterByConnection(connectionType));
const activateFallback = (activeKey, fallbackKey) => {
setFormObj((cur) => {
const temp = { ...cur };
delete temp[activeKey];
const fallbackItem = fieldCatalog.find(({ key }) => key === fallbackKey);
const fallbackItemVal = fallbackItem?.type === 'object' ? {} : '';
return { ...temp, [fallbackKey]: fallbackItemVal };
});
};
const backUrl = setupLinkToken
? `/setup/${setupLinkToken}`
: isSettingsView
? '/admin/settings/sso-connection'
: '/admin/sso-connection';
const fieldsToHideInSetupView = ['forceAuthn', 'clientID', 'clientSecret', 'idpCertExpiry', 'idpMetadata'];
const readOnlyFields = filteredFieldsByConnection
.filter((field) => field.attributes.editable === false)
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
.filter(excludeFallback(formObj))
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true));
const apiUrl = setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : `/api/admin/connections`;
const connectionFetchUrl = setupLinkToken
? `/api/setup/${setupLinkToken}/sso-connection/${connectionClientId}`
: `/api/admin/connections/${connectionClientId}`;
return (
<>
@ -189,55 +40,99 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
{t('edit_sso_connection')}
</h2>
<ToggleConnectionStatus connection={connection} setupLinkToken={setupLinkToken} />
</div>
<form onSubmit={save}>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 lg:border-none lg:p-0'>
<div className='flex flex-col gap-0 lg:flex-row lg:gap-4'>
<div
className={`w-full rounded border-gray-200 dark:border-gray-700 lg:border lg:p-3 ${readOnlyFields.length > 0 ? 'lg:w-3/5' : ''}`}>
{filteredFieldsByConnection
.filter((field) => field.attributes.editable !== false)
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
.filter(excludeFallback(formObj))
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true))
.map(renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback }))}
</div>
{readOnlyFields.length > 0 && (
<div className='w-full rounded border-gray-200 dark:border-gray-700 lg:w-2/5 lg:border lg:p-3'>
{readOnlyFields.map(
renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback })
)}
</div>
)}
</div>
<div className='flex w-full lg:mt-6'>
<ButtonPrimary type='submit'>{t('save_changes')}</ButtonPrimary>
</div>
</div>
{connection?.clientID && connection.clientSecret && (
<section className='mt-10 flex items-center rounded bg-red-100 p-6 text-red-900'>
<div className='flex-1'>
<h6 className='mb-1 font-medium'>{t('delete_this_connection')}</h6>
<p className='font-light'>{t('all_your_apps_using_this_connection_will_stop_working')}</p>
</div>
<ButtonDanger
type='button'
onClick={toggleDelConfirm}
data-modal-toggle='popup-modal'
data-testid='delete-connection'>
{t('delete')}
</ButtonDanger>
</section>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
{connectionIsSAML && (
<EditSAMLConnection
displayHeader={false}
displayIdpMetadata={true}
displayInfo={setupLinkToken ? false : true}
excludeFields={
setupLinkToken
? [
'name',
'tenant',
'description',
'defaultRedirectUrl',
'redirectUrl',
'product',
'label',
'sortOrder',
]
: ['label']
}
classNames={BOXYHQ_UI_CSS}
variant='advanced'
urls={{
delete: apiUrl,
patch: apiUrl,
get: connectionFetchUrl,
}}
successCallback={({ operation }) => {
operation === 'UPDATE'
? successToast(t('saved'))
: operation === 'DELETE'
? successToast(t('sso_connection_deleted_successfully'))
: successToast(t('copied'));
if (operation !== 'COPY') {
router.replace(
setupLinkToken
? `/setup/${setupLinkToken}/sso-connection`
: isSettingsView
? '/admin/settings/sso-connection'
: '/admin/sso-connection'
);
}
}}
errorCallback={(errMessage) => errorToast(errMessage)}
/>
)}
</form>
<ConfirmationModal
title={t('delete_the_connection')}
description={t('confirmation_modal_description')}
visible={delModalVisible}
onConfirm={deleteConnection}
onCancel={toggleDelConfirm}
/>
{connectionIsOIDC && (
<EditOIDCConnection
displayHeader={false}
displayInfo={setupLinkToken ? false : true}
variant='advanced'
excludeFields={
setupLinkToken
? [
'name',
'tenant',
'description',
'defaultRedirectUrl',
'redirectUrl',
'product',
'oidcClientId',
'label',
'sortOrder',
]
: ['label']
}
classNames={BOXYHQ_UI_CSS}
urls={{
delete: apiUrl,
patch: apiUrl,
get: connectionFetchUrl,
}}
successCallback={({ operation }) => {
operation === 'UPDATE'
? successToast(t('saved'))
: operation === 'DELETE'
? successToast(t('sso_connection_deleted_successfully'))
: successToast(t('copied'));
if (operation !== 'COPY') {
router.replace(
setupLinkToken
? `/setup/${setupLinkToken}/sso-connection`
: isSettingsView
? '/admin/settings/sso-connection'
: '/admin/sso-connection'
);
}
}}
errorCallback={(errMessage) => errorToast(errMessage)}
/>
)}
</div>
</div>
</>
);

View File

@ -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} />
</>
);
};

View File

@ -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 },
},
];

View File

@ -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;
}

View File

@ -1,200 +1,60 @@
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { ApiResponse } from 'types';
import React from 'react';
import { errorToast, successToast } from '@components/Toaster';
import type { Directory } from '@boxyhq/saml-jackson';
import { LinkBack } from '@components/LinkBack';
import { ButtonPrimary } from '@components/ButtonPrimary';
import useDirectoryProviders from '@lib/ui/hooks/useDirectoryProviders';
import { CreateDirectory as CreateDSync } from '@boxyhq/react-ui/dsync';
import { BOXYHQ_UI_CSS } from '@components/styles';
interface CreateDirectoryProps {
setupLinkToken?: string;
defaultWebhookEndpoint: string | undefined;
defaultWebhookEndpoint?: string;
defaultWebhookSecret?: string;
}
type UnSavedDirectory = Omit<Directory, 'id' | 'log_webhook_events' | 'scim' | 'deactivated' | 'webhook'> & {
webhook_url: string;
webhook_secret: string;
};
const defaultDirectory: UnSavedDirectory = {
name: '',
tenant: '',
product: '',
webhook_url: '',
webhook_secret: '',
type: 'azure-scim-v2',
google_domain: '',
};
const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirectoryProps) => {
const CreateDirectory = ({
setupLinkToken,
defaultWebhookEndpoint,
defaultWebhookSecret,
}: CreateDirectoryProps) => {
const { t } = useTranslation('common');
const router = useRouter();
const { providers } = useDirectoryProviders(setupLinkToken);
const [loading, setLoading] = useState(false);
const [showDomain, setShowDomain] = useState(false);
const [directory, setDirectory] = useState<UnSavedDirectory>({
...defaultDirectory,
webhook_url: defaultWebhookEndpoint || '',
});
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch(
setupLinkToken ? `/api/setup/${setupLinkToken}/directory-sync` : '/api/admin/directory-sync',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(directory),
}
);
setLoading(false);
const response: ApiResponse<Directory> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if (rawResponse.ok) {
router.replace(
setupLinkToken
? `/setup/${setupLinkToken}/directory-sync/${response.data.id}`
: `/admin/directory-sync/${response.data.id}`
);
successToast(t('directory_created_successfully'));
return;
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setDirectory({
...directory,
[target.id]: target.value,
});
// Ask for domain if google is selected
if (target.id === 'type') {
target.value === 'google' ? setShowDomain(true) : setShowDomain(false);
}
};
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
return (
<div>
<LinkBack href={backUrl} />
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('new_directory')}</h2>
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('create_dsync_connection')}</h2>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
{!setupLinkToken && (
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('directory_name')}</span>
</label>
<input type='text' id='name' className='input-bordered input w-full' onChange={onChange} />
</div>
)}
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('directory_provider')}</span>
</label>
<select className='select-bordered select w-full' id='type' onChange={onChange} required>
{providers &&
Object.keys(providers).map((key) => {
return (
<option key={key} value={key}>
{providers[key]}
</option>
);
})}
</select>
</div>
{showDomain && (
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('directory_domain')}</span>
</label>
<input
type='text'
id='google_domain'
className='input-bordered input w-full'
onChange={onChange}
value={directory.google_domain}
pattern='^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$'
title='Please enter a valid domain (e.g: boxyhq.com)'
required
/>
</div>
)}
{!setupLinkToken && (
<>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('tenant')}</span>
</label>
<input
type='text'
id='tenant'
className='input-bordered input w-full'
required
onChange={onChange}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('product')}</span>
</label>
<input
type='text'
id='product'
className='input-bordered input w-full'
required
onChange={onChange}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('webhook_url')}</span>
</label>
<input
type='text'
id='webhook_url'
className='input-bordered input w-full'
onChange={onChange}
required
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('webhook_secret')}</span>
</label>
<input
type='text'
id='webhook_secret'
className='input-bordered input w-full'
onChange={onChange}
required
/>
</div>
</>
)}
<div className='flex justify-end'>
<ButtonPrimary loading={loading}>{t('create_directory')}</ButtonPrimary>
</div>
</div>
</form>
<CreateDSync
displayHeader={false}
defaultWebhookEndpoint={defaultWebhookEndpoint}
defaultWebhookSecret={defaultWebhookSecret}
classNames={BOXYHQ_UI_CSS}
successCallback={({ connection }) => {
successToast(t('directory_created_successfully'));
connection?.id &&
router.replace(
setupLinkToken
? `/setup/${setupLinkToken}/directory-sync/${connection.id}`
: `/admin/directory-sync/${connection.id}`
);
}}
errorCallback={(errorMessage) => {
errorToast(errorMessage);
}}
excludeFields={
setupLinkToken
? ['name', 'tenant', 'product', 'webhook_url', 'webhook_secret', 'log_webhook_events']
: undefined
}
urls={{
post: setupLinkToken
? `/api/setup/${setupLinkToken}/directory-sync`
: '/api/admin/directory-sync',
}}
/>
</div>
</div>
);

View File

@ -1,50 +1,19 @@
import { useRouter } from 'next/router';
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'next-i18next';
import type { Directory } from '@boxyhq/saml-jackson';
import type { ApiResponse } from 'types';
import { errorToast, successToast } from '@components/Toaster';
import { LinkBack } from '@components/LinkBack';
import { ButtonPrimary } from '@components/ButtonPrimary';
import Loading from '@components/Loading';
import useDirectory from '@lib/ui/hooks/useDirectory';
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
import { DeleteDirectory } from './DeleteDirectory';
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook' | 'google_domain'>;
const defaultFormState: FormState = {
name: '',
log_webhook_events: false,
webhook: {
endpoint: '',
secret: '',
},
google_domain: '',
};
import { EditDirectory as EditDSync } from '@boxyhq/react-ui/dsync';
import { BOXYHQ_UI_CSS } from '@components/styles';
const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
const router = useRouter();
const { t } = useTranslation('common');
const [loading, setLoading] = useState(false);
const [directoryUpdated, setDirectoryUpdated] = useState<FormState>(defaultFormState);
const { directory, isLoading, isValidating, error } = useDirectory(directoryId, setupLinkToken);
useEffect(() => {
if (directory) {
setDirectoryUpdated({
name: directory.name,
log_webhook_events: directory.log_webhook_events,
webhook: {
endpoint: directory.webhook?.endpoint,
secret: directory.webhook?.secret,
},
google_domain: directory.google_domain,
});
}
}, [directory]);
if (isLoading || !directory || isValidating) {
return <Loading />;
}
@ -55,147 +24,54 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
}
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
const patchUrl = setupLinkToken
const apiUrl = setupLinkToken
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
: `/api/admin/directory-sync/${directoryId}`;
const redirectUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch(patchUrl, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(directoryUpdated),
});
setLoading(false);
const response: ApiResponse<Directory> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return null;
}
if (rawResponse.ok) {
successToast(t('directory_updated_successfully'));
router.replace(redirectUrl);
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target as HTMLInputElement;
const value = target.type === 'checkbox' ? target.checked : target.value;
if (target.id === 'webhook.endpoint' || target.id === 'webhook.secret') {
setDirectoryUpdated({
...directoryUpdated,
webhook: {
...directoryUpdated.webhook,
[target.id.split('.')[1]]: value,
},
});
return;
}
setDirectoryUpdated({
...directoryUpdated,
[target.id]: value,
});
};
return (
<div>
<LinkBack href={backUrl} />
<div className='flex items-center justify-between'>
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('edit_directory')}</h2>
<ToggleConnectionStatus connection={directory} setupLinkToken={setupLinkToken} />
</div>
{!setupLinkToken && (
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('directory_name')}</span>
</label>
<input
type='text'
id='name'
className='input-bordered input w-full'
required
onChange={onChange}
value={directoryUpdated.name}
/>
</div>
{directory.type === 'google' && (
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('directory_domain')}</span>
</label>
<input
type='text'
id='google_domain'
className='input-bordered input w-full'
onChange={onChange}
value={directoryUpdated.google_domain}
/>
</div>
)}
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('webhook_url')}</span>
</label>
<input
type='text'
id='webhook.endpoint'
className='input-bordered input w-full'
onChange={onChange}
value={directoryUpdated.webhook.endpoint}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('webhook_secret')}</span>
</label>
<input
type='text'
id='webhook.secret'
className='input-bordered input w-full'
onChange={onChange}
value={directoryUpdated.webhook.secret}
/>
</div>
<div className='form-control w-full py-2'>
<div className='flex items-center'>
<input
id='log_webhook_events'
type='checkbox'
checked={directoryUpdated.log_webhook_events}
onChange={onChange}
className='h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600'
/>
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('enable_webhook_events_logging')}
</label>
</div>
</div>
<div>
<ButtonPrimary type='submit' loading={loading}>
{t('save_changes')}
</ButtonPrimary>
</div>
</div>
</form>
</div>
)}
<DeleteDirectory directoryId={directoryId} setupLinkToken={setupLinkToken} />
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<EditDSync
displayHeader={false}
urls={{
patch: apiUrl,
delete: apiUrl,
get: apiUrl,
}}
excludeFields={
setupLinkToken
? [
'name',
'product',
'log_webhook_events',
'tenant',
'google_domain',
'type',
'webhook_url',
'webhook_secret',
'scim_endpoint',
'scim_token',
'google_authorization_url',
]
: undefined
}
successCallback={() => {
successToast(t('directory_updated_successfully'));
router.replace(redirectUrl);
}}
errorCallback={(errMessage) => {
errorToast(errMessage);
}}
hideSave={setupLinkToken ? true : false}
classNames={BOXYHQ_UI_CSS}
/>
</div>
</div>
);
};

View File

@ -1,9 +1,9 @@
import { useRouter } from 'next/router';
import { CreateSAMLConnection as CreateSAML, CreateOIDCConnection as CreateOIDC } from '@boxyhq/react-ui/sso';
import styles from 'styles/sdk-override.module.css';
import { errorToast, successToast } from '@components/Toaster';
import { useTranslation } from 'next-i18next';
import { BOXYHQ_UI_CSS } from '@components/styles';
interface CreateSSOConnectionProps {
setupLinkToken: string;
@ -30,19 +30,13 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
post: `/api/setup/${setupLinkToken}/sso-connection`,
};
const _CSS = {
input: `${styles['sdk-input']} input input-bordered`,
button: { ctoa: 'btn btn-primary' },
textarea: styles['sdk-input'],
};
return idpType === 'saml' ? (
<CreateSAML
variant='basic'
urls={urls}
successCallback={onSuccess}
errorCallback={onError}
classNames={_CSS}
classNames={BOXYHQ_UI_CSS}
displayHeader={false}
/>
) : (
@ -51,7 +45,7 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
urls={urls}
successCallback={onSuccess}
errorCallback={onError}
classNames={_CSS}
classNames={BOXYHQ_UI_CSS}
displayHeader={false}
/>
);

19
components/styles.ts Normal file
View File

@ -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',
};

View File

@ -24,13 +24,13 @@ test.describe('Admin Portal SSO - SAML', () => {
// Find the new connection button and click on it
await page.getByTestId('create-connection').click();
// Fill the name for the connection
const nameInput = page.getByTestId('name');
const nameInput = page.getByLabel('Connection name (Optional)');
await nameInput.fill(TEST_SAML_SSO_CONNECTION_NAME);
// Enter the metadata url for mocksaml in the form
const metadataUrlInput = page.getByTestId('metadataUrl');
const metadataUrlInput = page.getByLabel('Metadata URL');
await metadataUrlInput.fill(MOCKSAML_METADATA_URL);
// submit the form
await page.getByTestId('submit-form-create-sso').click();
await page.getByRole('button', { name: /save/i }).click();
// check if the added connection appears in the connection list
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).toBeVisible();
});
@ -78,8 +78,8 @@ test.describe('Admin Portal SSO - SAML', () => {
const editButton = page.getByText(TEST_SAML_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
await editButton.click();
// click the delete and confirm deletion
await page.getByTestId('delete-connection').click();
await page.getByTestId('confirm-delete').click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
// check that the SSO connection is deleted from the connection list
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).not.toBeVisible();
});
@ -94,40 +94,38 @@ test.describe('Admin Portal SSO - OIDC', () => {
// Find the new connection button and click on it
await page.getByTestId('create-connection').click();
// Toggle connection type to OIDC
await page.getByTestId('sso-type-oidc').click();
await page.getByLabel('OIDC').check();
// Fill the name for the connection
const nameInput = page.getByTestId('name');
const nameInput = page.getByLabel('Connection name (Optional)');
await nameInput.fill(TEST_OIDC_SSO_CONNECTION_NAME);
if (mode === 'discoveryUrl') {
// Enter the OIDC discovery url for mocklab in the form
const discoveryUrlInput = page.getByTestId('oidcDiscoveryUrl');
const discoveryUrlInput = page.getByLabel('Well-known URL of OpenID Provider');
await discoveryUrlInput.fill(MOCKLAB_DISCOVERY_ENDPOINT);
} else {
// Activate the oidc discovery fallback fields
await page.getByTestId('oidcDiscoveryUrl-fallback-switch').click();
// Enter the OIDC issuer value for mocklab in the form
const issuerInput = page.getByTestId('issuer');
const issuerInput = page.getByLabel('issuer');
await issuerInput.fill(MOCKLAB_ISSUER);
// Enter the OIDC authorization_endpoint value for mocklab in the form
const authzEndpointInput = page.getByTestId('authorization_endpoint');
const authzEndpointInput = page.getByLabel('Authorization Endpoint');
await authzEndpointInput.fill(MOCKLAB_AUTHORIZATION_ENDPOINT);
// Enter the OIDC token_endpoint value for mocklab in the form
const tokenEndpointInput = page.getByTestId('token_endpoint');
const tokenEndpointInput = page.getByLabel('Token endpoint');
await tokenEndpointInput.fill(MOCKLAB_TOKEN_ENDPOINT);
// Enter the OIDC userinfo_endpoint value for mocklab in the form
const userInfoEndpointInput = page.getByTestId('userinfo_endpoint');
const userInfoEndpointInput = page.getByLabel('UserInfo endpoint');
await userInfoEndpointInput.fill(MOCKLAB_USERINFO_ENDPOINT);
// Enter the OIDC jwks_uri value for mocklab in the form
const jwksUriInput = page.getByTestId('jwks_uri');
const jwksUriInput = page.getByLabel('JWKS URI');
await jwksUriInput.fill(MOCKLAB_JWKS_URI);
}
// Enter the OIDC client credentials for mocklab in the form
const clientIdInput = page.getByTestId('oidcClientId');
const clientIdInput = page.getByLabel('Client ID');
await clientIdInput.fill(MOCKLAB_CLIENT_ID);
const clientSecretInput = page.getByTestId('oidcClientSecret');
const clientSecretInput = page.getByLabel('Client Secret');
await clientSecretInput.fill(MOCKLAB_CLIENT_SECRET);
// submit the form
await page.getByTestId('submit-form-create-sso').click();
await page.getByRole('button', { name: /save/i }).click();
// check if the added connection appears in the connection list
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).toBeVisible();
});
@ -156,8 +154,8 @@ test.describe('Admin Portal SSO - OIDC', () => {
const editButton = page.getByText(TEST_OIDC_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
await editButton.click();
// click the delete and confirm deletion
await page.getByTestId('delete-connection').click();
await page.getByTestId('confirm-delete').click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
// check that the SSO connection is deleted from the connection list
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).not.toBeVisible();
});

View File

@ -153,6 +153,7 @@ export const DSyncForm = ({
className='input input-bordered w-full text-sm'
name='expiryDays'
required
min={1}
onChange={formik.handleChange}
value={formik.values.expiryDays}
/>

View File

@ -168,6 +168,7 @@ export const SSOForm = ({
className='input input-bordered w-full text-sm'
name='expiryDays'
required
min={1}
onChange={formik.handleChange}
value={formik.values.expiryDays}
/>

View File

@ -100,3 +100,10 @@ export const parsePaginateApiParams = (params: NextApiRequest['query']): Paginat
pageToken,
};
};
export type AdminPortalSSODefaults = {
tenant: string;
product: string;
redirectUrl: string;
defaultRedirectUrl: string;
};

View File

@ -2,26 +2,19 @@
"documentation": "Documentation",
"actions": "Actions",
"active": "Active",
"all_your_apps_using_this_connection_will_stop_working": "All your apps using this connection will stop working.",
"back": "Back",
"cancel": "Cancel",
"copy": "Copy",
"copied": "Copied",
"client_error": "Client error",
"close_sidebar": "Close sidebar",
"confirmation_modal_description": "This action cannot be undone. This will permanently delete the Connection.",
"create_directory": "Create Directory",
"create_new": "Create New",
"create_sso_connection": "Create SSO Connection",
"create_dsync_connection": "Create DSync Connection",
"delete": "Delete",
"delete_the_connection": "Delete the Connection?",
"delete_this_connection": "Delete this Connection",
"directory_name": "Directory name",
"directory_provider": "Directory provider",
"directory_sync": "Directory Sync",
"edit_sso_connection": "Edit SSO Connection",
"email": "Email",
"enable_webhook_events_logging": "Enable Webhook events logging",
"boxyhq_tagline": "Security Building Blocks for Developers.",
"enterprise_sso": "Enterprise SSO",
"idp_entity_id": "IdP Entity ID",
@ -33,24 +26,17 @@
"connections": "Connections",
"new_connection": "New Connection",
"no_projects_found": "No projects found.",
"oidc": "OIDC",
"open_menu": "Open menu",
"open_sidebar": "Open sidebar",
"product": "Product",
"save_changes": "Save Changes",
"saved": "Saved",
"server_error": "Server error",
"sign_out": "Sign out",
"saml": "SAML",
"sso_error": "SSO error",
"select_sso_type": "Select SSO type",
"select_an_app": "Select an App to continue",
"send_magic_link": "Send Magic Link",
"setup_links": "Setup Links",
"tenant": "Tenant",
"edit_directory": "Edit Directory",
"webhook_secret": "Webhook secret",
"webhook_url": "Webhook URL",
"download": "Download",
"saml_federation_new_success": "Identity Federation app created successfully.",
"entity_id": "Entity ID / Audience URI / Audience Restriction",
@ -131,9 +117,6 @@
"delete_this_directory": "Delete this directory connection?",
"delete_this_directory_desc": "This action cannot be undone. This will permanently delete the directory connection, users, and groups.",
"directory_connection_deleted_successfully": "Directory connection deleted successfully",
"directory_domain": "Directory Domain",
"show_secret": "Show secret",
"hide_secret": "Hide secret",
"retraced_project_created": "Project created successfully",
"project_name": "Project name",
"create_project": "Create Project",
@ -159,6 +142,7 @@
"choose_an_app_to_continue": "Choose an app to continue. If you don't see your app, please contact your administrator.",
"no_saml_response_try_again": "No SAMLResponse found. Please try again.",
"sso_connection_created_successfully": "SSO Connection created successfully",
"sso_connection_deleted_successfully": "SSO Connection deleted successfully",
"saml_federation_entity_id_generated": "SP Entity ID generated",
"setup-link-created": "A new setup link created.",
"setup-link-regenerated": "The setup link regenerated.",

View File

@ -216,7 +216,7 @@ const saml = {
name,
label,
description,
forceAuthn = false,
forceAuthn,
metadataUrl,
...clientInfo
} = body;
@ -302,7 +302,7 @@ const saml = {
metadataUrl: newMetadata ? newMetadataUrl : _savedConnection.metadataUrl,
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _savedConnection.defaultRedirectUrl,
redirectUrl: redirectUrlList ? redirectUrlList : _savedConnection.redirectUrl,
forceAuthn,
forceAuthn: typeof forceAuthn === 'boolean' ? forceAuthn : _savedConnection.forceAuthn,
};
if ('sortOrder' in body) {

View File

@ -233,7 +233,8 @@ export class SetupLinkController {
}
const token = crypto.randomBytes(24).toString('hex');
const expiryInDays = expiryDays || this.opts.setupLinkExpiryDays || 3;
const expiryInDays =
typeof expiryDays === 'number' && expiryDays > 0 ? expiryDays : this.opts.setupLinkExpiryDays || 3;
const setupID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, service));
const setupLink: SetupLink = {

8
package-lock.json generated
View File

@ -12,7 +12,7 @@
"dependencies": {
"@boxyhq/internal-ui": "file:internal-ui",
"@boxyhq/metrics": "0.2.6",
"@boxyhq/react-ui": "3.3.39",
"@boxyhq/react-ui": "3.3.41",
"@boxyhq/saml-jackson": "file:npm",
"@heroicons/react": "2.1.3",
"@retracedhq/logs-viewer": "2.7.1",
@ -2030,9 +2030,9 @@
}
},
"node_modules/@boxyhq/react-ui": {
"version": "3.3.39",
"resolved": "https://registry.npmjs.org/@boxyhq/react-ui/-/react-ui-3.3.39.tgz",
"integrity": "sha512-1kSQIc5AjWbh8rUwIlG+BhRZy8kXJg6fgv/2SxWbkf874XILUK+TobwtVXnUncRC3pQthh7sVYfR6XW7FF/J0w==",
"version": "3.3.41",
"resolved": "https://registry.npmjs.org/@boxyhq/react-ui/-/react-ui-3.3.41.tgz",
"integrity": "sha512-j0c4VVNoqx73i3gkQQx16KOhfK7PRdUO6wvgxGwhaRC7cqf+NoSSqZuyazYu1PyDCTeiQv6vv/07yIuAQuP+ZA==",
"dependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",

View File

@ -63,7 +63,7 @@
"dependencies": {
"@boxyhq/internal-ui": "file:internal-ui",
"@boxyhq/metrics": "0.2.6",
"@boxyhq/react-ui": "3.3.39",
"@boxyhq/react-ui": "3.3.41",
"@boxyhq/saml-jackson": "file:npm",
"@heroicons/react": "2.1.3",
"@retracedhq/logs-viewer": "2.7.1",

View File

@ -5,9 +5,14 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { jacksonOptions } from '@lib/env';
const DirectoryCreatePage: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = (props) => {
const { defaultWebhookEndpoint } = props;
const { defaultWebhookEndpoint, defaultWebhookSecret } = props;
return <CreateDirectory defaultWebhookEndpoint={defaultWebhookEndpoint} />;
return (
<CreateDirectory
defaultWebhookEndpoint={defaultWebhookEndpoint}
defaultWebhookSecret={defaultWebhookSecret}
/>
);
};
export const getServerSideProps = async ({ locale }: GetServerSidePropsContext) => {
@ -15,6 +20,7 @@ export const getServerSideProps = async ({ locale }: GetServerSidePropsContext)
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
defaultWebhookEndpoint: jacksonOptions.webhook?.endpoint,
defaultWebhookSecret: jacksonOptions.webhook?.secret,
},
};
};

View File

@ -7,7 +7,6 @@ import EditConnection from '@components/connection/EditConnection';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Loading from '@components/Loading';
import { errorToast } from '@components/Toaster';
import type { ApiError, ApiSuccess } from 'types';
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
const EditSSOConnection: NextPage = () => {
@ -15,7 +14,7 @@ const EditSSOConnection: NextPage = () => {
const { id } = router.query as { id: string };
const { data, error, isLoading } = useSWR<ApiSuccess<SAMLSSORecord | OIDCSSORecord>, ApiError>(
const { data, error, isLoading } = useSWR<SAMLSSORecord | OIDCSSORecord>(
id ? `/api/admin/connections/${id}` : null,
fetcher,
{
@ -32,11 +31,11 @@ const EditSSOConnection: NextPage = () => {
return null;
}
if (!data?.data) {
if (!data) {
return null;
}
return <EditConnection connection={data?.data} isSettingsView />;
return <EditConnection connection={data[0]} isSettingsView />;
};
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {

View File

@ -1,7 +1,7 @@
import type { GetServerSidePropsContext, NextPage } from 'next';
import CreateConnection from '@components/connection/CreateConnection';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import type { AdminPortalSSODefaults } from '@components/connection/utils';
import type { AdminPortalSSODefaults } from '@lib/utils';
import { adminPortalSSODefaults } from '@lib/env';
type Props = {

View File

@ -5,7 +5,6 @@ import { useRouter } from 'next/router';
import { fetcher } from '@lib/ui/utils';
import EditConnection from '@components/connection/EditConnection';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import type { ApiError, ApiSuccess } from 'types';
import Loading from '@components/Loading';
import { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
import { errorToast } from '@components/Toaster';
@ -15,12 +14,13 @@ const ConnectionEditPage: NextPage = () => {
const { id } = router.query as { id: string };
const { data, error, isLoading, isValidating } = useSWR<
ApiSuccess<SAMLSSORecord | OIDCSSORecord>,
ApiError
>(id ? `/api/admin/connections/${id}` : null, fetcher, {
revalidateOnFocus: false,
});
const { data, error, isLoading, isValidating } = useSWR<SAMLSSORecord | OIDCSSORecord>(
id ? `/api/admin/connections/${id}` : null,
fetcher,
{
revalidateOnFocus: false,
}
);
if (isLoading || isValidating) {
return <Loading />;
@ -31,11 +31,11 @@ const ConnectionEditPage: NextPage = () => {
return null;
}
if (!data?.data) {
if (!data) {
return null;
}
return <EditConnection connection={data?.data} />;
return <EditConnection connection={data[0]} />;
};
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {

View File

@ -24,7 +24,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
throw new ApiError('Connection not found', 404);
}
res.json({ data: connections[0] });
res.json(connections);
};
export default handler;

View File

@ -39,15 +39,16 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const connection = connections[0];
res.json({
data: {
res.json([
{
clientID: connection.clientID,
clientSecret: connection.clientSecret,
deactivated: connection.deactivated,
...('forceAuthn' in connection ? { forceAuthn: connection.forceAuthn } : undefined),
...('idpMetadata' in connection ? { idpMetadata: {}, metadataUrl: connection.metadataUrl } : undefined),
...('oidcProvider' in connection ? { oidcProvider: connection.oidcProvider } : undefined),
},
});
]);
};
export default handler;

View File

@ -45,6 +45,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse, setupLink: S
clientID: connection.clientID,
name: connection.name,
deactivated: connection.deactivated,
...('forceAuthn' in connection ? { forceAuthn: connection.forceAuthn } : undefined),
...('idpMetadata' in connection
? {
idpMetadata: {
@ -106,9 +107,11 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
clientID,
metadataUrl,
encodedRawMetadata,
forceAuthn,
oidcClientId,
oidcClientSecret,
oidcDiscoveryUrl,
oidcMetadata,
} = req.body;
const connections = await connectionAPIController.getConnections({
@ -128,12 +131,13 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
clientID,
clientSecret,
...('deactivated' in req.body ? { deactivated } : undefined),
...(isSAML ? { metadataUrl, encodedRawMetadata } : undefined),
...(isSAML ? { metadataUrl, encodedRawMetadata, forceAuthn } : undefined),
...(isOIDC
? {
oidcClientId,
oidcClientSecret,
oidcDiscoveryUrl,
oidcMetadata,
}
: undefined),
};

View File

@ -3,23 +3,19 @@ import React from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import CreateDirectory from '@components/dsync/CreateDirectory';
import { jacksonOptions } from '@lib/env';
const DirectoryCreatePage: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = (props) => {
const { defaultWebhookEndpoint } = props;
const DirectoryCreatePage: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = () => {
const router = useRouter();
const { token } = router.query as { token: string };
return <CreateDirectory setupLinkToken={token} defaultWebhookEndpoint={defaultWebhookEndpoint} />;
return <CreateDirectory setupLinkToken={token} />;
};
export const getServerSideProps = async ({ locale }: GetServerSidePropsContext) => {
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
defaultWebhookEndpoint: jacksonOptions.webhook?.endpoint,
},
};
};

View File

@ -28,10 +28,11 @@ const ConnectionEditPage: NextPage = () => {
errorToast(error.message);
return null;
}
if (!data) {
return null;
}
const connection = data.data;
return <EditConnection connection={connection} setupLinkToken={token} />;
return <EditConnection connection={data[0]} setupLinkToken={token} />;
};
export async function getServerSideProps({ locale }) {

View File

@ -52,10 +52,18 @@ a {
@apply text-white;
}
.btn-primary:focus-visible {
@apply outline-primary;
}
.btn-error {
@apply text-white;
}
.btn-error:focus-visible {
@apply outline-error;
}
.modal-box {
@apply rounded;
}

View File

@ -1,5 +1,6 @@
/* Override SDK styles */
.sdk-input:focus {
.sdk-input:focus,
.sdk-select:focus-visible + span {
/* Below styles copied from the tailwindcss/forms plugin */
outline: 2px solid hsla(var(--bc) / 0.2);
outline-offset: 2px;