mirror of https://github.com/boxyhq/jackson.git
Internal UI : Setup Link & SSO Tracer (#2354)
* add WellKnownURLs * Fix translation keys * Update dependencies and add IdP Configuration * Update common.json with new translations * wip * Update @boxyhq/internal-ui version to 0.0.5 * add internal ui folder * Fix imports and build * Refactor internal-ui package structure * wip shared UI * Fix the build * Add new components and hooks for directory sync * lint fix * updated swr * users * Refactor shared components and fix API endpoints*** ***Update directory user page and add new federated SAML app * Fix lint * wip * Add new files and update existing files * Refactor DirectoryGroups and DirectoryInfo components * Update localization strings for directory UI * Update Google Auth URL description in common.json * Refactor directory tab and add delete functionality to webhook logs * Delete unused files and update dependencies * Fix column declaration * Add internal-ui/dist to .gitignore * Update page limit and add new dependencies * wip * Refactor directory search in user API endpoint * wip * Refactor directory retrieval logic in user and group API handlers * Add API endpoints for retrieving webhook events * Add query parameters to API URLs in DirectoryGroups * Add Google authorization status badge and handle pagination in FederatedSAMLApps * Add router prop to AppsList component and update page header titles * UI changes * Add new files and export functions * Remove unused router prop * Add PencilIcon to FederatedSAMLApps * Refactor FederatedSAMLApps and NewFederatedSAMLApp components * lint fix * add jose npm to dev dep * added missing strings * locale strings fix * locale strings cleanup * update package-lock * Add prepublish step * Build and publish npm and internal ui * Refactor install step * Run npm install (for local) inside internal ui automatically using prepare * Remove eslint setup for internal-ui * wip * Add `--legacy-peer-deps` to prevent installing peer dependencies * wip * Fix the types import path * wip * wip * Fix the types * Format * Update package-lock * Cleanup * Try adding jose library version 5.2.2 * Add new dependencies for @next/swc package * Fix translation keys and import types * Update SSOTracers component and common.json localization * COPY internal-ui before npm install * COPY internal-ui in builder stage * fixed sort order for jose * wip * wip setuplink * Add delete link * Add exclusion for node_modules in files.exclude * Add error handling and additional functionality to SetupLinks component * Refactor SetupLinks component and add missing translations * Add missing translations and update setup link messages * Remove comment * update localization strings * Remove unused key * Update SSOTracerInfo component title * Refactor ConfirmationModal component button styling * Update package.json and ConfirmationModal.tsx * Update dep * Refactor setup links API and UI to use query parameters for pagination * Refactor deleteLink API endpoint and SetupLinks component * Update package.json paths * Update dep * Refactor setup link forms and add new fields * Update dep * Update import paths and add new setup links tests * wip * Refactor CreateDirectory and DirectoryInfo components * Add new fields to setup link and directory sync APIs * Cleanup * Update package-lock * Fix link regeneration * updated package-lock * Fix and add e2e tests * Update API documentation with new parameters for setup link creation and update * Revert * Update postcss.config.js and SSOForm.tsx --------- Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com> Co-authored-by: Aswin V <vaswin91@gmail.com>
This commit is contained in:
parent
6c6cc6dbb7
commit
a6ef0ddddb
|
@ -98,18 +98,14 @@ const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirec
|
|||
<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'>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
{!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>
|
||||
|
@ -138,6 +134,7 @@ const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirec
|
|||
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>
|
||||
)}
|
||||
|
@ -167,32 +164,33 @@ const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirec
|
|||
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='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}
|
||||
value={directory.webhook_url}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className='flex justify-end'>
|
||||
<ButtonPrimary loading={loading}>{t('create_directory')}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,13 +7,23 @@ import { errorToast, successToast } from '@components/Toaster';
|
|||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import { ButtonDanger } from '@components/ButtonDanger';
|
||||
|
||||
export const DeleteDirectory = ({ directoryId }: { directoryId: Directory['id'] }) => {
|
||||
export const DeleteDirectory = ({
|
||||
directoryId,
|
||||
setupLinkToken,
|
||||
}: {
|
||||
directoryId: Directory['id'];
|
||||
setupLinkToken?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const [delModalVisible, setDelModalVisible] = useState(false);
|
||||
|
||||
const deleteDirectory = async () => {
|
||||
const rawResponse = await fetch(`/api/admin/directory-sync/${directoryId}`, {
|
||||
const deleteUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
|
||||
: `/api/admin/directory-sync/${directoryId}`;
|
||||
|
||||
const rawResponse = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
|
@ -25,8 +35,12 @@ export const DeleteDirectory = ({ directoryId }: { directoryId: Directory['id']
|
|||
}
|
||||
|
||||
if ('data' in response) {
|
||||
const redirectUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}/directory-sync`
|
||||
: '/admin/directory-sync';
|
||||
|
||||
successToast(t('directory_connection_deleted_successfully'));
|
||||
router.replace('/admin/directory-sync');
|
||||
router.replace(redirectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
}
|
||||
|
||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
const putUrl = setupLinkToken
|
||||
const patchUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
|
||||
: `/api/admin/directory-sync/${directoryId}`;
|
||||
const redirectUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
|
@ -65,7 +65,7 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch(putUrl, {
|
||||
const rawResponse = await fetch(patchUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -117,83 +117,85 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('update_directory')}</h2>
|
||||
<ToggleConnectionStatus connection={directory} setupLinkToken={setupLinkToken} />
|
||||
</div>
|
||||
<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' && (
|
||||
{!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_domain')}</span>
|
||||
<span className='label-text'>{t('directory_name')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='google_domain'
|
||||
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.google_domain}
|
||||
value={directoryUpdated.webhook.endpoint}
|
||||
/>
|
||||
</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')}
|
||||
<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>
|
||||
<div>
|
||||
<ButtonPrimary type='submit' loading={loading}>
|
||||
{t('save_changes')}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<DeleteDirectory directoryId={directoryId} />
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<DeleteDirectory directoryId={directoryId} setupLinkToken={setupLinkToken} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,280 +0,0 @@
|
|||
import { FormEvent, useState } from 'react';
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { ButtonPrimary } from '../ButtonPrimary';
|
||||
import { LinkBack } from '../LinkBack';
|
||||
import { InputWithCopyButton } from '../ClipboardButton';
|
||||
import type { SetupLinkService, SetupLink } from '@boxyhq/saml-jackson';
|
||||
import type { ApiResponse } from 'types';
|
||||
|
||||
interface Props {
|
||||
service: SetupLinkService;
|
||||
expiryDays: number;
|
||||
}
|
||||
|
||||
const CreateSetupLink = ({ service, expiryDays }: Props) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading1, setLoading1] = useState(false);
|
||||
const [delModalVisible, setDelModalVisible] = useState(false);
|
||||
|
||||
const [setupLink, setSetupLink] = useState<SetupLink | null>(null);
|
||||
const [formObj, setFormObj] = useState({
|
||||
tenant: '',
|
||||
product: '',
|
||||
service,
|
||||
name: '',
|
||||
description: '',
|
||||
defaultRedirectUrl: '',
|
||||
redirectUrl: '',
|
||||
expiryDays,
|
||||
});
|
||||
|
||||
// Create a new setup link
|
||||
const createSetupLink = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch('/api/admin/setup-links', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formObj),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const response: ApiResponse<SetupLink> = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawResponse.ok) {
|
||||
setSetupLink(response.data);
|
||||
successToast(t('link_generated'));
|
||||
}
|
||||
};
|
||||
|
||||
// Regenerate setup link
|
||||
const regenerateSetupLink = async () => {
|
||||
setLoading1(true);
|
||||
setDelModalVisible(!delModalVisible);
|
||||
|
||||
const res = await fetch('/api/admin/setup-links', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formObj,
|
||||
regenerate: true,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setLoading1(false);
|
||||
const json = await res.json();
|
||||
setSetupLink(json.data);
|
||||
successToast(t('link_regenerated'));
|
||||
} else {
|
||||
setLoading1(false);
|
||||
errorToast(t('server_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (event: FormEvent) => {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
setFormObj((cur) => ({ ...cur, [target.name]: target.value }));
|
||||
};
|
||||
|
||||
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
|
||||
|
||||
const buttonDisabled = !formObj.tenant || !formObj.product || !formObj.service;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkBack onClick={() => router.back()} />
|
||||
<div className='mt-5 min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<h2 className='mb-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('create_setup_link', {
|
||||
service: service === 'sso' ? t('enterprise_sso') : t('directory_sync'),
|
||||
})}
|
||||
</h2>
|
||||
<form onSubmit={createSetupLink} method='POST'>
|
||||
<div>
|
||||
<div className='mb-6'>
|
||||
<label
|
||||
htmlFor='tenant'
|
||||
className={`mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300`}>
|
||||
{t('tenant')}
|
||||
</label>
|
||||
<input
|
||||
id='tenant'
|
||||
name='tenant'
|
||||
type='text'
|
||||
placeholder='acme.com'
|
||||
value={formObj['tenant']}
|
||||
onChange={handleChange}
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<label
|
||||
htmlFor='product'
|
||||
className={`mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300`}>
|
||||
{t('product')}
|
||||
</label>
|
||||
<input
|
||||
id='product'
|
||||
name='product'
|
||||
type='text'
|
||||
placeholder='demo'
|
||||
value={formObj['product']}
|
||||
onChange={handleChange}
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{service === 'sso' && (
|
||||
<>
|
||||
<div className='mb-6'>
|
||||
<label
|
||||
htmlFor='name'
|
||||
className={`mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300`}>
|
||||
{t('name')}
|
||||
</label>
|
||||
<input
|
||||
id='name'
|
||||
name='name'
|
||||
type='text'
|
||||
placeholder='MyApp'
|
||||
value={formObj['name']}
|
||||
onChange={handleChange}
|
||||
className='input-bordered input w-full'
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<label
|
||||
htmlFor='description'
|
||||
className={`mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300`}>
|
||||
{t('description')}
|
||||
</label>
|
||||
<input
|
||||
id='description'
|
||||
name='description'
|
||||
type='text'
|
||||
placeholder='A short description not more than 100 characters'
|
||||
value={formObj['description']}
|
||||
onChange={handleChange}
|
||||
className='input-bordered input w-full'
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<label
|
||||
htmlFor='defaultRedirectUrl'
|
||||
className={`mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300`}>
|
||||
{t('default_redirect_url')}
|
||||
</label>
|
||||
<input
|
||||
id='defaultRedirectUrl'
|
||||
name='defaultRedirectUrl'
|
||||
type='url'
|
||||
placeholder='http://localhost:3366/login/saml'
|
||||
value={formObj['defaultRedirectUrl']}
|
||||
onChange={handleChange}
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<label
|
||||
htmlFor='redirectUrl'
|
||||
className={`mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300`}>
|
||||
{t('allowed_redirect_url')}
|
||||
</label>
|
||||
<textarea
|
||||
id={'redirectUrl'}
|
||||
name='redirectUrl'
|
||||
placeholder={t('allowed_redirect_url')}
|
||||
value={formObj['redirectUrl']}
|
||||
required
|
||||
onChange={handleChange}
|
||||
className={`whitespace-pre} textarea-bordered textarea h-24 w-full`}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className=''>
|
||||
<label
|
||||
htmlFor='expiryDays'
|
||||
className='mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||
{t('expiry_in_days')}
|
||||
</label>
|
||||
<input
|
||||
id='expiryDays'
|
||||
name='expiryDays'
|
||||
type='number'
|
||||
placeholder='3'
|
||||
value={formObj['expiryDays']}
|
||||
onChange={handleChange}
|
||||
className='input-bordered input w-1/4'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex py-5'>
|
||||
<ButtonPrimary loading={loading} disabled={buttonDisabled}>
|
||||
{t('generate')}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</form>
|
||||
<ConfirmationModal
|
||||
title={t('regenerate_setup_link')}
|
||||
description={t('regenerate_setup_link_description')}
|
||||
visible={delModalVisible}
|
||||
onConfirm={regenerateSetupLink}
|
||||
onCancel={toggleDelConfirm}
|
||||
actionButtonText={t('regenerate')}
|
||||
/>
|
||||
</div>
|
||||
{setupLink && (
|
||||
<div className='mt-5 min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<h2 className='mb-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{setupLink
|
||||
? t('setup_link_info')
|
||||
: t('create_setup_link', {
|
||||
service: service === 'sso' ? t('enterprise_sso') : t('directory_sync'),
|
||||
})}
|
||||
</h2>
|
||||
<div className='form-control'>
|
||||
<InputWithCopyButton text={setupLink.url} label={t('setup_link_url')} />
|
||||
</div>
|
||||
<div className='mt-5 flex'>
|
||||
<ButtonPrimary
|
||||
loading={loading1}
|
||||
disabled={buttonDisabled}
|
||||
onClick={
|
||||
setupLink
|
||||
? () => {
|
||||
setDelModalVisible(true);
|
||||
}
|
||||
: createSetupLink
|
||||
}>
|
||||
{setupLink ? t('regenerate') : t('generate')}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateSetupLink;
|
|
@ -1,40 +0,0 @@
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import { SetupLink } from '@boxyhq/saml-jackson';
|
||||
import Modal from '@components/Modal';
|
||||
import { ButtonOutline } from '@components/ButtonOutline';
|
||||
import { InputWithCopyButton } from '@components/ClipboardButton';
|
||||
|
||||
type SetupLinkInfoProps = {
|
||||
setupLink: SetupLink | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const SetupLinkInfo = ({ setupLink, visible, onClose }: SetupLinkInfoProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!setupLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={`Setup link info: tenant '${setupLink.tenant}', product '${setupLink.product}'`}>
|
||||
<div className='mt-2 flex flex-col gap-3'>
|
||||
<div>
|
||||
<InputWithCopyButton text={setupLink.url} label={t('share_setup_link')} />
|
||||
</div>
|
||||
<p className='text-sm'>
|
||||
{t('setup_link_valid_till')}{' '}
|
||||
<span className={new Date(setupLink.validTill) < new Date() ? 'text-red-400' : ''}>
|
||||
{new Date(setupLink.validTill).toString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='modal-action'>
|
||||
<ButtonOutline onClick={onClose}>{t('close')}</ButtonOutline>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -1,255 +0,0 @@
|
|||
import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
|
||||
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
|
||||
import ArrowPathIcon from '@heroicons/react/24/outline/ArrowPathIcon';
|
||||
import TrashIcon from '@heroicons/react/24/outline/TrashIcon';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { copyToClipboard, fetcher } from '@lib/ui/utils';
|
||||
import useSWR from 'swr';
|
||||
import { LinkPrimary } from '@components/LinkPrimary';
|
||||
import { Pagination, pageLimit } from '@components/Pagination';
|
||||
import usePaginate from '@lib/ui/hooks/usePaginate';
|
||||
import Loading from '@components/Loading';
|
||||
import type { SetupLinkService, SetupLink } from '@boxyhq/saml-jackson';
|
||||
import type { ApiError, ApiResponse, ApiSuccess } from 'types';
|
||||
import { SetupLinkInfo } from './SetupLinkInfo';
|
||||
import { Table } from '@components/table/Table';
|
||||
|
||||
const SetupLinkList = ({ service }: { service: SetupLinkService }) => {
|
||||
const { paginate, setPaginate, pageTokenMap, setPageTokenMap } = usePaginate();
|
||||
const { t } = useTranslation('common');
|
||||
const [showDelConfirmModal, setShowDelConfirmModal] = useState(false);
|
||||
const [showRegenConfirmModal, setShowRegenConfirmModal] = useState(false);
|
||||
const [showSetupLinkModal, setShowSetupLinkModal] = useState(false);
|
||||
const [selectedSetupLink, setSelectedSetupLink] = useState<SetupLink | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset selected setup link when service changes
|
||||
setSelectedSetupLink(null);
|
||||
setShowSetupLinkModal(false);
|
||||
}, [service]);
|
||||
|
||||
let getSetupLinksUrl = `/api/admin/setup-links?service=${service}&offset=${paginate.offset}&limit=${pageLimit}`;
|
||||
// Use the (next)pageToken mapped to the previous page offset to get the current page
|
||||
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
|
||||
getSetupLinksUrl += `&pageToken=${pageTokenMap[paginate.offset - pageLimit]}`;
|
||||
}
|
||||
|
||||
const { data, error, mutate, isLoading } = useSWR<ApiSuccess<SetupLink[]>, ApiError>(
|
||||
getSetupLinksUrl,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
const nextPageToken = data?.pageToken;
|
||||
// store the nextPageToken against the pageOffset
|
||||
useEffect(() => {
|
||||
if (nextPageToken) {
|
||||
setPageTokenMap((tokenMap) => ({ ...tokenMap, [paginate.offset]: nextPageToken }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nextPageToken, paginate.offset]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
errorToast(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Regenerate a setup link
|
||||
const regenerateSetupLink = async () => {
|
||||
if (!selectedSetupLink) return;
|
||||
|
||||
const { tenant, product, service } = selectedSetupLink;
|
||||
|
||||
const rawResponse = await fetch('/api/admin/setup-links', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ tenant, product, service, regenerate: true }),
|
||||
});
|
||||
|
||||
const response: ApiResponse<SetupLink> = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('data' in response) {
|
||||
setShowRegenConfirmModal(false);
|
||||
await mutate();
|
||||
showSetupLinkInfo(response.data);
|
||||
successToast(t('link_regenerated'));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a setup link
|
||||
const deleteSetupLink = async () => {
|
||||
if (!selectedSetupLink) return;
|
||||
|
||||
await fetch(`/api/admin/setup-links?setupID=${selectedSetupLink.setupID}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
setSelectedSetupLink(null);
|
||||
setShowDelConfirmModal(false);
|
||||
await mutate();
|
||||
successToast(t('deleted'));
|
||||
};
|
||||
|
||||
// Display setup link info
|
||||
const showSetupLinkInfo = (setupLink: SetupLink) => {
|
||||
setSelectedSetupLink(setupLink);
|
||||
setShowSetupLinkModal(true);
|
||||
};
|
||||
|
||||
const createSetupLinkUrl =
|
||||
service === 'sso' ? '/admin/sso-connection/setup-link/new' : '/admin/directory-sync/setup-link/new';
|
||||
const title = service === 'sso' ? t('enterprise_sso') : t('directory_sync');
|
||||
const description = service === 'sso' ? t('setup_link_sso_description') : t('setup_link_dsync_description');
|
||||
|
||||
const setupLinks = data?.data || [];
|
||||
const noSetupLinks = setupLinks.length === 0 && paginate.offset === 0;
|
||||
const noMoreResults = setupLinks.length === 0 && paginate.offset > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('setup_links') + ' (' + title + ')'}
|
||||
</h2>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<h3>{description}</h3>
|
||||
<div>
|
||||
<LinkPrimary href={createSetupLinkUrl} data-testid='create-setup-link'>
|
||||
{t('new_setup_link')}
|
||||
</LinkPrimary>
|
||||
</div>
|
||||
</div>
|
||||
{noSetupLinks ? (
|
||||
<EmptyState title={t('no_setup_links_found')} href={createSetupLinkUrl} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
noMoreResults={noMoreResults}
|
||||
cols={[t('tenant'), t('product'), t('validity'), t('actions')]}
|
||||
body={setupLinks.map((setupLink) => {
|
||||
return {
|
||||
id: setupLink.setupID,
|
||||
cells: [
|
||||
{
|
||||
wrap: true,
|
||||
text: setupLink.tenant,
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
text: setupLink.product,
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
element: (
|
||||
<p className={new Date(setupLink.validTill) < new Date() ? 'text-red-400' : ''}>
|
||||
{new Date(setupLink.validTill).toLocaleString()}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
text: t('copy'),
|
||||
onClick: () => {
|
||||
copyToClipboard(setupLink.url);
|
||||
successToast(t('copied'));
|
||||
},
|
||||
icon: <ClipboardDocumentIcon className='h-5 w-5' />,
|
||||
},
|
||||
{
|
||||
text: t('view'),
|
||||
onClick: () => {
|
||||
showSetupLinkInfo(setupLink);
|
||||
},
|
||||
icon: <EyeIcon className='h-5 w-5' />,
|
||||
},
|
||||
{
|
||||
text: t('regenerate'),
|
||||
onClick: () => {
|
||||
setSelectedSetupLink(setupLink);
|
||||
setShowRegenConfirmModal(true);
|
||||
setShowSetupLinkModal(false);
|
||||
},
|
||||
icon: <ArrowPathIcon className='h-5 w-5' />,
|
||||
},
|
||||
{
|
||||
destructive: true,
|
||||
text: t('delete'),
|
||||
onClick: () => {
|
||||
setSelectedSetupLink(setupLink);
|
||||
setShowDelConfirmModal(true);
|
||||
},
|
||||
icon: <TrashIcon className='h-5 w-5' />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
})}></Table>
|
||||
|
||||
<Pagination
|
||||
itemsCount={setupLinks.length}
|
||||
offset={paginate.offset}
|
||||
onPrevClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset - pageLimit,
|
||||
});
|
||||
}}
|
||||
onNextClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset + pageLimit,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ConfirmationModal
|
||||
title={t('regenerate_setup_link')}
|
||||
description={t('regenerate_setup_link_description')}
|
||||
visible={showRegenConfirmModal}
|
||||
onConfirm={regenerateSetupLink}
|
||||
onCancel={() => {
|
||||
setShowRegenConfirmModal(false);
|
||||
}}
|
||||
actionButtonText={t('regenerate')}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
title={t('delete_setup_link')}
|
||||
description={t('delete_setup_link_description')}
|
||||
visible={showDelConfirmModal}
|
||||
onConfirm={deleteSetupLink}
|
||||
onCancel={() => {
|
||||
setShowDelConfirmModal(false);
|
||||
}}
|
||||
/>
|
||||
<SetupLinkInfo
|
||||
setupLink={selectedSetupLink}
|
||||
visible={showSetupLinkModal}
|
||||
onClose={() => {
|
||||
setShowSetupLinkModal(false);
|
||||
setSelectedSetupLink(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupLinkList;
|
|
@ -0,0 +1,184 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const tenant = 'tenant-1';
|
||||
const product = 'product-1';
|
||||
|
||||
test.use({
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Api-Key secret`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/dsync/setuplinks
|
||||
test('create the setup link', async ({ request }) => {
|
||||
const response = await request.post('/api/v1/dsync/setuplinks', {
|
||||
data: {
|
||||
tenant,
|
||||
product,
|
||||
webhook_url: 'http://localhost:3000/webhook',
|
||||
webhook_secret: 'webhook-secret',
|
||||
},
|
||||
});
|
||||
|
||||
const setupLink = await response.json();
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.status()).toBe(201);
|
||||
expect(setupLink.data.tenant).toMatch(tenant);
|
||||
expect(setupLink.data.product).toMatch(product);
|
||||
expect(setupLink.data.service).toMatch('dsync');
|
||||
});
|
||||
|
||||
// GET /api/v1/dsync/setuplinks?id={id}
|
||||
test('get the setup link by id', async ({ request }) => {
|
||||
let response = await request.get('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
tenant,
|
||||
product,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: setupLink } = await response.json();
|
||||
|
||||
response = await request.get('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
id: setupLink.setupID,
|
||||
},
|
||||
});
|
||||
|
||||
const fetchedSetupLink = await response.json();
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(fetchedSetupLink.data.tenant).toMatch(tenant);
|
||||
expect(fetchedSetupLink.data.product).toMatch(product);
|
||||
expect(fetchedSetupLink.data.service).toMatch('dsync');
|
||||
});
|
||||
|
||||
// GET /api/v1/dsync/setuplinks?tenant={tenant}&product={product}
|
||||
test('get the setup link by tenant & product', async ({ request }) => {
|
||||
const response = await request.get('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
tenant,
|
||||
product,
|
||||
},
|
||||
});
|
||||
|
||||
const setupLink = await response.json();
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(setupLink.data.tenant).toMatch(tenant);
|
||||
expect(setupLink.data.product).toMatch(product);
|
||||
expect(setupLink.data.service).toMatch('dsync');
|
||||
});
|
||||
|
||||
// DELETE /api/v1/dsync/setuplinks?tenant={tenant}&product={product}
|
||||
test('delete the setup link by tenant & product', async ({ request }) => {
|
||||
let response = await request.delete('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
tenant,
|
||||
product,
|
||||
},
|
||||
});
|
||||
|
||||
await response.json();
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
response = await request.get('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
tenant,
|
||||
product,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(false);
|
||||
expect(response.status()).toBe(404);
|
||||
});
|
||||
|
||||
// DELETE /api/v1/dsync/setuplinks?id={id}
|
||||
test('delete the setup link by id', async ({ request }) => {
|
||||
let response = await request.post('/api/v1/dsync/setuplinks', {
|
||||
data: {
|
||||
tenant,
|
||||
product,
|
||||
webhook_url: 'http://localhost:3000/webhook',
|
||||
webhook_secret: 'webhook-secret',
|
||||
},
|
||||
});
|
||||
|
||||
const { data: setupLink } = await response.json();
|
||||
|
||||
response = await request.delete('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
id: setupLink.setupID,
|
||||
},
|
||||
});
|
||||
|
||||
await response.json();
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
response = await request.get('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
id: setupLink.setupID,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(false);
|
||||
expect(response.status()).toBe(404);
|
||||
});
|
||||
|
||||
// GET /api/v1/dsync/setuplinks/product
|
||||
test('get the setup links by product', async ({ request }) => {
|
||||
// Create 2 setup links
|
||||
await request.post('/api/v1/dsync/setuplinks', {
|
||||
data: {
|
||||
tenant: 'tenant-2',
|
||||
product,
|
||||
webhook_url: 'http://localhost:3000/webhook',
|
||||
webhook_secret: 'webhook-secret',
|
||||
},
|
||||
});
|
||||
|
||||
await request.post('/api/v1/dsync/setuplinks', {
|
||||
data: {
|
||||
tenant: 'tenant-3',
|
||||
product,
|
||||
webhook_url: 'http://localhost:3000/webhook',
|
||||
webhook_secret: 'webhook-secret',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request.get('/api/v1/dsync/setuplinks/product', {
|
||||
params: {
|
||||
product,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: setupLinks } = await response.json();
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(setupLinks.length).toBe(2);
|
||||
expect(setupLinks[0].product).toBe(product);
|
||||
expect(setupLinks[1].product).toBe(product);
|
||||
|
||||
await request.delete('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
tenant: 'tenant-2',
|
||||
product,
|
||||
},
|
||||
});
|
||||
|
||||
await request.delete('/api/v1/dsync/setuplinks', {
|
||||
params: {
|
||||
tenant: 'tenant-3',
|
||||
product,
|
||||
},
|
||||
});
|
||||
});
|
|
@ -16,6 +16,8 @@ test('create the setup link', async ({ request }) => {
|
|||
data: {
|
||||
tenant,
|
||||
product,
|
||||
redirectUrl: ['http://localhost:3000'],
|
||||
defaultRedirectUrl: 'http://localhost:3000/default',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -103,6 +105,8 @@ test('delete the setup link by id', async ({ request }) => {
|
|||
data: {
|
||||
tenant,
|
||||
product,
|
||||
redirectUrl: ['http://localhost:3000'],
|
||||
defaultRedirectUrl: 'http://localhost:3000/default',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -136,6 +140,8 @@ test('get the setup links by product', async ({ request }) => {
|
|||
data: {
|
||||
tenant: 'tenant-2',
|
||||
product,
|
||||
redirectUrl: ['http://localhost:3000'],
|
||||
defaultRedirectUrl: 'http://localhost:3000/default',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -143,6 +149,8 @@ test('get the setup links by product', async ({ request }) => {
|
|||
data: {
|
||||
tenant: 'tenant-3',
|
||||
product,
|
||||
redirectUrl: ['http://localhost:3000'],
|
||||
defaultRedirectUrl: 'http://localhost:3000/default',
|
||||
},
|
||||
});
|
||||
|
|
@ -95,16 +95,24 @@ export const DirectoryInfo = ({
|
|||
</>
|
||||
)}
|
||||
{directory.type === 'google' && (
|
||||
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-authorized-status')}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||
{authorizedGoogle ? (
|
||||
<Badge color='success'>{t('bui-dsync-authorized')}</Badge>
|
||||
) : (
|
||||
<Badge color='warning'>{t('bui-dsync-not-authorized')}</Badge>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<>
|
||||
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-authorized-status')}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||
{authorizedGoogle ? (
|
||||
<Badge color='success'>{t('bui-dsync-authorized')}</Badge>
|
||||
) : (
|
||||
<Badge color='warning'>{t('bui-dsync-not-authorized')}</Badge>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-google-domain')}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||
{directory.google_domain || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
Pagination,
|
||||
PageHeader,
|
||||
pageLimit,
|
||||
DeleteConfirmationModal,
|
||||
ConfirmationModal,
|
||||
} from '../shared';
|
||||
import { ButtonDanger } from '../shared/ButtonDanger';
|
||||
import { useRouter } from '../hooks';
|
||||
|
@ -156,7 +156,7 @@ export const DirectoryWebhookLogs = ({
|
|||
{t('bui-dsync-remove-events')}
|
||||
</ButtonDanger>
|
||||
</div>
|
||||
<DeleteConfirmationModal
|
||||
<ConfirmationModal
|
||||
title={t('bui-dsync-delete-events-title')}
|
||||
description={t('bui-dsync-delete-events-desc')}
|
||||
visible={delModalVisible}
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { SAMLFederationApp } from '../types';
|
|||
import { EditBranding } from './EditBranding';
|
||||
import { Edit } from './Edit';
|
||||
import { EditAttributesMapping } from './EditAttributesMapping';
|
||||
import { DeleteCard, Loading, DeleteConfirmationModal } from '../shared';
|
||||
import { DeleteCard, Loading, ConfirmationModal } from '../shared';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { defaultHeaders, fetcher } from '../utils';
|
||||
|
@ -90,7 +90,7 @@ export const EditFederatedSAMLApp = ({
|
|||
buttonLabel={t('bui-shared-delete')}
|
||||
onClick={() => setDelModalVisible(true)}
|
||||
/>
|
||||
<DeleteConfirmationModal
|
||||
<ConfirmationModal
|
||||
title={t('bui-fs-delete-app-title')}
|
||||
description={t('bui-fs-delete-app-desc')}
|
||||
visible={delModalVisible}
|
||||
|
|
|
@ -3,3 +3,5 @@ export * from './federated-saml';
|
|||
export * from './shared';
|
||||
export * from './dsync';
|
||||
export * from './provider';
|
||||
export * from './sso-tracer';
|
||||
export * from './setup-link';
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
import { useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { Button } from 'react-daisyui';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Card } from '../shared';
|
||||
import type { SetupLink } from '../types';
|
||||
import { defaultHeaders } from '../utils';
|
||||
import { SetupLinkInfo } from './SetupLinkInfo';
|
||||
|
||||
interface CreateSetupLinkInput {
|
||||
name: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
expiryDays: number;
|
||||
service: 'dsync';
|
||||
regenerate: boolean;
|
||||
}
|
||||
|
||||
export const DSyncForm = ({
|
||||
urls,
|
||||
expiryDays,
|
||||
onCreate,
|
||||
onError,
|
||||
excludeFields,
|
||||
}: {
|
||||
urls: { createLink: string };
|
||||
expiryDays: number;
|
||||
onCreate: (data: SetupLink) => void;
|
||||
onError: (error: Error) => void;
|
||||
excludeFields?: 'product'[];
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [setupLink, setSetupLink] = useState<SetupLink | null>(null);
|
||||
|
||||
const formik = useFormik<CreateSetupLinkInput>({
|
||||
initialValues: {
|
||||
name: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
webhook_url: '',
|
||||
webhook_secret: '',
|
||||
expiryDays,
|
||||
service: 'dsync',
|
||||
regenerate: false,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const rawResponse = await fetch(urls.createLink, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
headers: defaultHeaders,
|
||||
});
|
||||
|
||||
const response = await rawResponse.json();
|
||||
|
||||
if (rawResponse.ok) {
|
||||
onCreate(response.data);
|
||||
formik.resetForm();
|
||||
setSetupLink(response.data);
|
||||
} else {
|
||||
onError(response.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{setupLink && <SetupLinkInfo setupLink={setupLink} onClose={() => setSetupLink(null)} />}
|
||||
<form onSubmit={formik.handleSubmit} method='POST'>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Description>{t('bui-sl-dsync-desc')}</Card.Description>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-dsync-name')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={t('bui-sl-dsync-name-placeholder')!}
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='name'
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name}
|
||||
/>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-tenant')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='acme'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='tenant'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.tenant}
|
||||
/>
|
||||
</label>
|
||||
{!excludeFields?.includes('product') && (
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-product')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='MyApp'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='product'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.product}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-webhook-url')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
placeholder='https://yourapp.com/webhook'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='webhook_url'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.webhook_url}
|
||||
/>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-webhook-secret')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='your-secret'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='webhook_secret'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.webhook_secret}
|
||||
/>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-expiry-days')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
placeholder='7'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='expiryDays'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.expiryDays}
|
||||
/>
|
||||
</label>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
<Button
|
||||
type='submit'
|
||||
className='btn btn-primary btn-md'
|
||||
loading={formik.isSubmitting}
|
||||
disabled={!formik.dirty || !formik.isValid}>
|
||||
{t('bui-sl-create')}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { SSOForm } from './SSOForm';
|
||||
import { PageHeader } from '../shared';
|
||||
import { DSyncForm } from './DSyncForm';
|
||||
import type { SetupLinkService, SetupLink } from '../types';
|
||||
|
||||
export const NewSetupLink = ({
|
||||
urls,
|
||||
service,
|
||||
expiryDays,
|
||||
onCreate,
|
||||
onError,
|
||||
excludeFields,
|
||||
}: {
|
||||
urls: { createLink: string };
|
||||
service: SetupLinkService;
|
||||
expiryDays: number;
|
||||
onCreate: (data: SetupLink) => void;
|
||||
onError: (error: Error) => void;
|
||||
excludeFields?: 'product'[];
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<>
|
||||
{service === 'sso' ? (
|
||||
<>
|
||||
<PageHeader title={t('bui-sl-create-link')} />
|
||||
<SSOForm
|
||||
urls={urls}
|
||||
expiryDays={expiryDays}
|
||||
onCreate={onCreate}
|
||||
onError={onError}
|
||||
excludeFields={excludeFields}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PageHeader title={t('bui-sl-create-link')} />
|
||||
<DSyncForm
|
||||
urls={urls}
|
||||
expiryDays={expiryDays}
|
||||
onCreate={onCreate}
|
||||
onError={onError}
|
||||
excludeFields={excludeFields}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,189 @@
|
|||
import { useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { Button } from 'react-daisyui';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Card } from '../shared';
|
||||
import type { SetupLink } from '../types';
|
||||
import { defaultHeaders } from '../utils';
|
||||
import { SetupLinkInfo } from './SetupLinkInfo';
|
||||
|
||||
interface CreateSetupLinkInput {
|
||||
name: string;
|
||||
description: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
expiryDays: number;
|
||||
service: 'sso';
|
||||
regenerate: boolean;
|
||||
redirectUrl: string;
|
||||
defaultRedirectUrl: string;
|
||||
}
|
||||
|
||||
export const SSOForm = ({
|
||||
urls,
|
||||
expiryDays,
|
||||
onCreate,
|
||||
onError,
|
||||
excludeFields,
|
||||
}: {
|
||||
urls: { createLink: string };
|
||||
expiryDays: number;
|
||||
onCreate: (data: SetupLink) => void;
|
||||
onError: (error: Error) => void;
|
||||
excludeFields?: 'product'[];
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [setupLink, setSetupLink] = useState<SetupLink | null>(null);
|
||||
|
||||
const formik = useFormik<CreateSetupLinkInput>({
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
expiryDays,
|
||||
service: 'sso',
|
||||
regenerate: false,
|
||||
redirectUrl: '',
|
||||
defaultRedirectUrl: '',
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const redirectUrlList = values.redirectUrl.split(/\r\n|\r|\n/);
|
||||
|
||||
const rawResponse = await fetch(urls.createLink, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...values, redirectUrl: JSON.stringify(redirectUrlList) }),
|
||||
headers: defaultHeaders,
|
||||
});
|
||||
|
||||
const response = await rawResponse.json();
|
||||
|
||||
if (rawResponse.ok) {
|
||||
onCreate(response.data);
|
||||
formik.resetForm();
|
||||
setSetupLink(response.data);
|
||||
} else {
|
||||
onError(response.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{setupLink && <SetupLinkInfo setupLink={setupLink} onClose={() => setSetupLink(null)} />}
|
||||
<form onSubmit={formik.handleSubmit} method='POST'>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Description>{t('bui-sl-sso-desc')}</Card.Description>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-sso-name')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={t('bui-sl-sso-name-placeholder')!}
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='name'
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name}
|
||||
/>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-sso-description')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='description'
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.description}
|
||||
/>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-tenant')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='acme'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='tenant'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.tenant}
|
||||
/>
|
||||
</label>
|
||||
{!excludeFields?.includes('product') && (
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-product')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='MyApp'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='product'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.product}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-allowed-redirect-urls')}</span>
|
||||
</div>
|
||||
<textarea
|
||||
name='redirectUrl'
|
||||
placeholder='http://localhost:3366'
|
||||
className='textarea-bordered textarea whitespace-pre rounded'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.redirectUrl}
|
||||
/>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-default-redirect-url')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
placeholder='http://localhost:3366/login/saml'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='defaultRedirectUrl'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.defaultRedirectUrl}
|
||||
/>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-expiry-days')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
placeholder='7'
|
||||
className='input input-bordered w-full text-sm'
|
||||
name='expiryDays'
|
||||
required
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.expiryDays}
|
||||
/>
|
||||
</label>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
<Button
|
||||
type='submit'
|
||||
className='btn btn-primary btn-md'
|
||||
loading={formik.isSubmitting}
|
||||
disabled={!formik.dirty || !formik.isValid}>
|
||||
{t('bui-sl-create')}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import { Button } from 'react-daisyui';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Card } from '../shared';
|
||||
import type { SetupLink } from '../types';
|
||||
import { InputWithCopyButton } from '../shared/InputWithCopyButton';
|
||||
|
||||
export const SetupLinkInfo = ({ setupLink, onClose }: { setupLink: SetupLink; onClose: () => void }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='border-primary'>
|
||||
<Card.Body>
|
||||
<div>
|
||||
<InputWithCopyButton text={setupLink.url} label={t('bui-sl-share-info')} />
|
||||
</div>
|
||||
<div>
|
||||
<Button size='sm' color='primary' onClick={onClose}>
|
||||
{t('bui-sl-btn-close')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import { Button } from 'react-daisyui';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Modal } from '../shared';
|
||||
import type { SetupLink } from '../types';
|
||||
import { InputWithCopyButton } from '../shared/InputWithCopyButton';
|
||||
|
||||
export const SetupLinkInfoModal = ({
|
||||
setupLink,
|
||||
visible,
|
||||
onClose,
|
||||
}: {
|
||||
setupLink: SetupLink;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const expiresAt = new Date(setupLink.validTill).toUTCString();
|
||||
const isExpired = new Date(setupLink.validTill) < new Date();
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={`Setup Link for ${setupLink.tenant}`}>
|
||||
<div className='pt-3'>
|
||||
<InputWithCopyButton label={t('bui-sl-share-link-info')} text={setupLink.url} />
|
||||
</div>
|
||||
{!isExpired ? (
|
||||
<p className='text-sm text-gray-500 mt-3'>{t('bui-sl-link-expire-on', { expiresAt })}</p>
|
||||
) : (
|
||||
<p>{t('bui-sl-link-expired')}</p>
|
||||
)}
|
||||
<div className='modal-action'>
|
||||
<Button color='secondary' variant='outline' type='button' size='md' onClick={() => onClose()}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,294 @@
|
|||
import useSWR from 'swr';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
|
||||
import TrashIcon from '@heroicons/react/24/outline/TrashIcon';
|
||||
import ArrowPathIcon from '@heroicons/react/24/outline/ArrowPathIcon';
|
||||
import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
|
||||
|
||||
import { addQueryParamsToPath, copyToClipboard, fetcher } from '../utils';
|
||||
import { TableBodyType } from '../shared/Table';
|
||||
import { pageLimit } from '../shared/Pagination';
|
||||
import { usePaginate, useRouter } from '../hooks';
|
||||
import type { SAMLFederationApp, SetupLink, SetupLinkService } from '../types';
|
||||
import {
|
||||
Loading,
|
||||
Table,
|
||||
EmptyState,
|
||||
Error,
|
||||
Pagination,
|
||||
PageHeader,
|
||||
ButtonPrimary,
|
||||
Badge,
|
||||
ConfirmationModal,
|
||||
} from '../shared';
|
||||
import { SetupLinkInfoModal } from './SetupLinkInfoModal';
|
||||
|
||||
type ExcludeFields = keyof Pick<SAMLFederationApp, 'product'>;
|
||||
|
||||
export const SetupLinks = ({
|
||||
urls,
|
||||
excludeFields,
|
||||
actions,
|
||||
service,
|
||||
onCopy,
|
||||
onRegenerate,
|
||||
onError,
|
||||
onDelete,
|
||||
}: {
|
||||
urls: { getLinks: string; deleteLink: string; regenerateLink: string };
|
||||
excludeFields?: ExcludeFields[];
|
||||
actions: { newLink: string };
|
||||
service: SetupLinkService;
|
||||
onCopy: (setupLink: SetupLink) => void;
|
||||
onRegenerate: (setupLink: SetupLink) => void;
|
||||
onError: (error: Error) => void;
|
||||
onDelete: (setupLink: SetupLink) => void;
|
||||
}) => {
|
||||
const { router } = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
const [showDelModal, setDelModal] = useState(false);
|
||||
const [showSetupLink, setShowSetupLink] = useState(false);
|
||||
const [showRegenModal, setShowRegenModal] = useState(false);
|
||||
const [setupLink, setSetupLink] = useState<SetupLink | null>(null);
|
||||
const { paginate, setPaginate, pageTokenMap } = usePaginate(router!);
|
||||
|
||||
const params = {
|
||||
pageOffset: paginate.offset,
|
||||
pageLimit: pageLimit,
|
||||
service,
|
||||
};
|
||||
|
||||
// For DynamoDB
|
||||
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
|
||||
params['pageToken'] = pageTokenMap[paginate.offset - pageLimit];
|
||||
}
|
||||
|
||||
const getLinksUrl = addQueryParamsToPath(urls.getLinks, params);
|
||||
const { data, isLoading, error, mutate } = useSWR<{ data: SetupLink[] }>(getLinksUrl, fetcher);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Error message={error.message} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const links = data?.data || [];
|
||||
const noLinks = links.length === 0 && paginate.offset === 0;
|
||||
const noMoreResults = links.length === 0 && paginate.offset > 0;
|
||||
|
||||
let cols = [
|
||||
t('bui-sl-tenant'),
|
||||
t('bui-sl-product'),
|
||||
t('bui-sl-validity'),
|
||||
t('bui-sl-status'),
|
||||
t('bui-sl-actions'),
|
||||
];
|
||||
|
||||
// Exclude fields
|
||||
cols = cols.filter((col) => !excludeFields?.includes(col.toLowerCase() as ExcludeFields));
|
||||
|
||||
const body: TableBodyType[] = links.map((setupLink) => {
|
||||
const cells: TableBodyType['cells'] = [
|
||||
{
|
||||
wrap: true,
|
||||
text: setupLink.tenant,
|
||||
},
|
||||
];
|
||||
|
||||
if (!excludeFields?.includes('product')) {
|
||||
cells.push({
|
||||
wrap: true,
|
||||
text: setupLink.product,
|
||||
});
|
||||
}
|
||||
|
||||
cells.push(
|
||||
{
|
||||
wrap: false,
|
||||
text: new Date(setupLink.validTill).toLocaleString(),
|
||||
},
|
||||
{
|
||||
wrap: false,
|
||||
element: new Date(setupLink.validTill).toLocaleString() ? (
|
||||
<Badge color='primary'>{t('bui-sl-active')}</Badge>
|
||||
) : (
|
||||
<Badge color='warning'>{t('bui-sl-expired')}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
text: t('bui-sl-copy'),
|
||||
onClick: () => {
|
||||
copyToClipboard(setupLink.url);
|
||||
onCopy(setupLink);
|
||||
},
|
||||
icon: <ClipboardDocumentIcon className='h-5 w-5' />,
|
||||
},
|
||||
{
|
||||
text: t('bui-sl-view'),
|
||||
onClick: () => {
|
||||
setSetupLink(setupLink);
|
||||
setShowSetupLink(true);
|
||||
},
|
||||
icon: <EyeIcon className='h-5 w-5' />,
|
||||
},
|
||||
{
|
||||
text: t('bui-sl-regenerate'),
|
||||
onClick: () => {
|
||||
setSetupLink(setupLink);
|
||||
setShowRegenModal(true);
|
||||
},
|
||||
icon: <ArrowPathIcon className='h-5 w-5' />,
|
||||
},
|
||||
{
|
||||
destructive: true,
|
||||
text: t('bui-sl-delete'),
|
||||
onClick: () => {
|
||||
setSetupLink(setupLink);
|
||||
setDelModal(true);
|
||||
},
|
||||
icon: <TrashIcon className='h-5 w-5' />,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: setupLink.setupID,
|
||||
cells,
|
||||
};
|
||||
});
|
||||
|
||||
// Delete setup link
|
||||
const deleteSetupLink = async () => {
|
||||
if (!setupLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawResponse = await fetch(
|
||||
`${urls.deleteLink}?id=${setupLink.setupID}&service=${setupLink.service}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const response = await rawResponse.json();
|
||||
|
||||
if (rawResponse.ok) {
|
||||
setDelModal(false);
|
||||
setSetupLink(null);
|
||||
onDelete(setupLink);
|
||||
await mutate();
|
||||
} else {
|
||||
onError(response.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Regenerate a setup link
|
||||
const regenerateSetupLink = async () => {
|
||||
if (!setupLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawResponse = await fetch(urls.regenerateLink, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...setupLink,
|
||||
regenerate: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await rawResponse.json();
|
||||
|
||||
if (rawResponse.ok) {
|
||||
onRegenerate(response.data);
|
||||
setShowRegenModal(false);
|
||||
await mutate();
|
||||
setSetupLink(response.data);
|
||||
setShowSetupLink(true);
|
||||
} else {
|
||||
onError(response.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<PageHeader
|
||||
title={service === 'dsync' ? t('bui-sl-dsync-title') : t('bui-sl-sso-title')}
|
||||
actions={
|
||||
<>
|
||||
<ButtonPrimary onClick={() => router?.push(actions.newLink)} className='btn-md'>
|
||||
{t('bui-sl-new-link')}
|
||||
</ButtonPrimary>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{noLinks ? (
|
||||
<EmptyState title={t('bui-sl-no-links')} description={t('bui-sl-no-links-desc')} />
|
||||
) : (
|
||||
<>
|
||||
<Table noMoreResults={noMoreResults} cols={cols} body={body} />
|
||||
<Pagination
|
||||
itemsCount={links.length}
|
||||
offset={paginate.offset}
|
||||
onPrevClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset - pageLimit,
|
||||
});
|
||||
}}
|
||||
onNextClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset + pageLimit,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
title={t('bui-sl-delete-link-title')}
|
||||
description={t('bui-sl-delete-link-desc')}
|
||||
visible={showDelModal}
|
||||
onConfirm={() => deleteSetupLink()}
|
||||
onCancel={() => {
|
||||
setDelModal(false);
|
||||
setSetupLink(null);
|
||||
}}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
title={t('bui-sl-regen-link-title')}
|
||||
description={t('bui-sl-regen-link-desc')}
|
||||
visible={showRegenModal}
|
||||
onConfirm={() => regenerateSetupLink()}
|
||||
onCancel={() => {
|
||||
setShowRegenModal(false);
|
||||
setSetupLink(null);
|
||||
}}
|
||||
actionButtonText={t('bui-sl-regenerate')!}
|
||||
/>
|
||||
{setupLink && (
|
||||
<SetupLinkInfoModal
|
||||
setupLink={setupLink}
|
||||
visible={showSetupLink}
|
||||
onClose={() => {
|
||||
setShowSetupLink(false);
|
||||
setSetupLink(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export { NewSetupLink } from './NewSetupLink';
|
||||
export { SetupLinkInfo } from './SetupLinkInfo';
|
||||
export { SetupLinks } from './SetupLinks';
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
const Card = ({ children }: { children: React.ReactNode }) => {
|
||||
const Card = ({ children, className }: { children: React.ReactNode; className?: string }) => {
|
||||
return (
|
||||
<div className='card w-full border border-rounded dark:bg-black dark:border-gray-600'>{children}</div>
|
||||
<div className={`card w-full border border-rounded dark:bg-black dark:border-gray-600 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import { Modal } from './Modal';
|
||||
import { ButtonOutline } from './ButtonOutline';
|
||||
import { ButtonDanger } from './ButtonDanger';
|
||||
import { ButtonBase } from './ButtonBase';
|
||||
|
||||
export const ConfirmationModal = ({
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
actionButtonText,
|
||||
dataTestId = 'confirm-delete',
|
||||
overrideDeleteButton = false,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
actionButtonText?: string;
|
||||
overrideDeleteButton?: boolean;
|
||||
dataTestId?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const buttonText = actionButtonText || t('delete');
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={title} description={description}>
|
||||
<div className='modal-action'>
|
||||
<ButtonOutline onClick={onCancel} className='btn-md'>
|
||||
{t('cancel')}
|
||||
</ButtonOutline>
|
||||
{overrideDeleteButton ? (
|
||||
<ButtonBase color='secondary' onClick={onConfirm} data-testid={dataTestId} className='btn-md'>
|
||||
{buttonText}
|
||||
</ButtonBase>
|
||||
) : (
|
||||
<ButtonDanger onClick={onConfirm} data-testid={dataTestId} className='btn-md'>
|
||||
{buttonText}
|
||||
</ButtonDanger>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -8,7 +8,7 @@ export { Badge } from './Badge';
|
|||
export { Pagination } from './Pagination';
|
||||
export { ButtonOutline } from './ButtonOutline';
|
||||
export { Modal } from './Modal';
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { ConfirmationModal } from './ConfirmationModal';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export { LinkOutline } from './LinkOutline';
|
||||
export { LinkPrimary } from './LinkPrimary';
|
||||
|
|
|
@ -0,0 +1,208 @@
|
|||
import useSWR from 'swr';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
|
||||
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
import { fetcher } from '../utils';
|
||||
import type { SSOTrace } from '../types';
|
||||
import { Loading, Error, PageHeader, Badge } from '../shared';
|
||||
import { CopyToClipboardButton } from '../shared/InputWithCopyButton';
|
||||
|
||||
const ListItem = ({ term, value }: { term: string; value: string | JSX.Element }) => (
|
||||
<div className='grid grid-cols-3 py-3'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{term}</dt>
|
||||
<dd className='text-sm text-gray-900 overflow-auto col-span-2'>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SSOTracerInfo = ({ urls }: { urls: { getTracer: string } }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const { data, isLoading, error } = useSWR<{ data: SSOTrace & { traceId: string } }>(
|
||||
urls.getTracer,
|
||||
fetcher
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Error message={error.message} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trace = data.data;
|
||||
const assertionType = trace.context.samlResponse ? 'Response' : trace.context.samlRequest ? 'Request' : '-';
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<PageHeader title={t('bui-tracer-title')} />
|
||||
<dl className='divide-y'>
|
||||
<ListItem term={t('bui-tracer-id')} value={trace.traceId} />
|
||||
|
||||
<ListItem term={t('bui-tracer-assertion-type')} value={assertionType} />
|
||||
|
||||
<ListItem
|
||||
term={t('bui-tracer-sp-protocol')}
|
||||
value={
|
||||
<Badge
|
||||
color='primary'
|
||||
size='md'
|
||||
className='font-mono uppercase text-white'
|
||||
aria-label={t('bui-tracer-sp-protocol')!}>
|
||||
{trace.context.requestedOIDCFlow
|
||||
? 'OIDC'
|
||||
: trace.context.isSAMLFederated
|
||||
? t('bui-tracer-saml-federation')
|
||||
: trace.context.isIdPFlow
|
||||
? t('bui-tracer-idp-login')
|
||||
: t('bui-tracer-oauth2')}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
{typeof trace.timestamp === 'number' && (
|
||||
<ListItem term={t('bui-tracer-timestamp')} value={new Date(trace.timestamp).toLocaleString()} />
|
||||
)}
|
||||
|
||||
<ListItem term={t('bui-tracer-error')} value={trace.error} />
|
||||
|
||||
{trace.context.tenant && <ListItem term={t('bui-tracer-tenant')} value={trace.context.tenant} />}
|
||||
|
||||
{trace.context.product && <ListItem term={t('bui-tracer-product')} value={trace.context.product} />}
|
||||
|
||||
{trace.context.relayState && (
|
||||
<ListItem term={t('bui-tracer-relay-state')} value={trace.context.relayState} />
|
||||
)}
|
||||
|
||||
{trace.context.redirectUri && (
|
||||
<ListItem
|
||||
term={
|
||||
trace.context.isIDPFlow ? t('bui-tracer-default-redirect-url') : t('bui-tracer-redirect-uri')
|
||||
}
|
||||
value={trace.context.redirectUri}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trace.context.clientID && (
|
||||
<ListItem term={t('bui-tracer-sso-connection-client-id')} value={trace.context.clientID} />
|
||||
)}
|
||||
|
||||
{trace.context.issuer && <ListItem term={t('bui-tracer-issuer')} value={trace.context.issuer} />}
|
||||
|
||||
{trace.context.acsUrl && <ListItem term={t('bui-tracer-acs-url')} value={trace.context.acsUrl} />}
|
||||
|
||||
{trace.context.entityId && (
|
||||
<ListItem term={t('bui-tracer-entity-id')} value={trace.context.entityId} />
|
||||
)}
|
||||
|
||||
{trace.context.providerName && (
|
||||
<ListItem term={t('bui-tracer-provider')} value={trace.context.providerName} />
|
||||
)}
|
||||
|
||||
{assertionType === 'Response' && trace.context.samlResponse && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-saml-response')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton text={trace.context.samlResponse}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='xml' style={materialOceanic}>
|
||||
{trace.context.samlResponse}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{assertionType === 'Request' && trace.context.samlRequest && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-saml-request')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton text={trace.context.samlRequest}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='xml' style={materialOceanic}>
|
||||
{trace.context.samlRequest}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof trace.context.profile === 'object' && trace.context.profile && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-profile')}
|
||||
value={
|
||||
<SyntaxHighlighter language='json' style={materialOceanic}>
|
||||
{JSON.stringify(trace.context.profile)}
|
||||
</SyntaxHighlighter>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trace.context.error_description && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-error-description-from-oidc-idp')}
|
||||
value={trace.context.error_description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trace.context.error_uri && (
|
||||
<ListItem term={t('bui-tracer-error-uri')} value={trace.context.error_uri} />
|
||||
)}
|
||||
|
||||
{trace.context.oidcTokenSet?.id_token && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-id-token-from-oidc-idp')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton text={trace.context.oidcTokenSet.id_token}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='shell' style={materialOceanic}>
|
||||
{trace.context.oidcTokenSet.id_token}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trace.context.oidcTokenSet?.access_token && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-access-token-from-oidc-idp')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton text={trace.context.oidcTokenSet.access_token}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='shell' style={materialOceanic}>
|
||||
{trace.context.oidcTokenSet.access_token}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trace.context.stack && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-stack-trace')}
|
||||
value={
|
||||
<SyntaxHighlighter language='shell' style={materialOceanic}>
|
||||
{trace.context.stack}
|
||||
</SyntaxHighlighter>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trace.context.session_state_from_op_error && (
|
||||
<ListItem
|
||||
term={t('bui-tracer-session-state-from-oidc-idp')}
|
||||
value={trace.context.session_state_from_op_error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trace.context.scope_from_op_error && (
|
||||
<ListItem term={t('bui-tracer-scope-from-op-error')} value={trace.context.scope_from_op_error} />
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,124 @@
|
|||
import useSWR from 'swr';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import type { Trace } from '../types';
|
||||
import { usePaginate, useRouter } from '../hooks';
|
||||
import type { ApiError, ApiSuccess } from '../types';
|
||||
import { addQueryParamsToPath, fetcher } from '../utils';
|
||||
import { Loading, Table, EmptyState, Error, Pagination, PageHeader, pageLimit } from '../shared';
|
||||
|
||||
export const SSOTracers = ({
|
||||
urls,
|
||||
onView,
|
||||
}: {
|
||||
urls: { getTracers: string };
|
||||
onView: (user: Trace) => void;
|
||||
}) => {
|
||||
const { router } = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
const { paginate, setPaginate, pageTokenMap, setPageTokenMap } = usePaginate(router!);
|
||||
|
||||
const params = {
|
||||
offset: paginate.offset,
|
||||
limit: pageLimit,
|
||||
};
|
||||
|
||||
// For DynamoDB
|
||||
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
|
||||
params['pageToken'] = pageTokenMap[paginate.offset - pageLimit];
|
||||
}
|
||||
|
||||
const getUrl = addQueryParamsToPath(urls.getTracers, params);
|
||||
const { data, isLoading, error } = useSWR<ApiSuccess<Trace[]>, ApiError>(getUrl, fetcher);
|
||||
|
||||
const nextPageToken = data?.pageToken;
|
||||
|
||||
useEffect(() => {
|
||||
if (nextPageToken) {
|
||||
setPageTokenMap((tokenMap) => ({ ...tokenMap, [paginate.offset]: nextPageToken }));
|
||||
}
|
||||
}, [nextPageToken, paginate.offset]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Error message={error.message} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const traces = data?.data || [];
|
||||
const noTraces = traces.length === 0 && paginate.offset === 0;
|
||||
const noMoreResults = traces.length === 0 && paginate.offset > 0;
|
||||
|
||||
const cols = [
|
||||
t('bui-tracer-id'),
|
||||
t('bui-tracer-description'),
|
||||
t('bui-tracer-assertion-type'),
|
||||
t('bui-tracer-timestamp'),
|
||||
];
|
||||
|
||||
const body = traces.map((trace) => {
|
||||
return {
|
||||
id: trace.traceId,
|
||||
cells: [
|
||||
{
|
||||
wrap: true,
|
||||
element: (
|
||||
<button className='link-primary link flex' onClick={() => onView(trace)}>
|
||||
{trace.traceId}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
text: trace.error,
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
text: trace.context?.samlResponse
|
||||
? t('bui-tracer-response')
|
||||
: trace?.context.samlRequest
|
||||
? t('bui-tracer-request')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
text: new Date(trace.timestamp).toLocaleString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<PageHeader title={t('bui-tracer-title')} />
|
||||
{noTraces ? (
|
||||
<EmptyState title={t('bui-tracer-no-traces')} />
|
||||
) : (
|
||||
<>
|
||||
<Table noMoreResults={noMoreResults} cols={cols} body={body} />
|
||||
<Pagination
|
||||
itemsCount={traces.length}
|
||||
offset={paginate.offset}
|
||||
onPrevClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset - pageLimit,
|
||||
});
|
||||
}}
|
||||
onNextClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset + pageLimit,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export { SSOTracers } from './SSOTracers';
|
||||
export { SSOTracerInfo } from './SSOTracerInfo';
|
|
@ -1,3 +1,10 @@
|
|||
export type ApiSuccess<T> = { data: T; pageToken?: string };
|
||||
|
||||
export interface ApiError extends Error {
|
||||
info?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
enum DirectorySyncProviders {
|
||||
'azure-scim-v2' = 'Azure SCIM v2.0',
|
||||
'onelogin-scim-v2' = 'OneLogin SCIM v2.0',
|
||||
|
@ -95,3 +102,52 @@ export type SAMLFederationApp = {
|
|||
tenants?: string[]; // To support multiple tenants for a single app
|
||||
mappings: AttributeMapping[] | null;
|
||||
};
|
||||
|
||||
export interface Trace {
|
||||
traceId: string;
|
||||
timestamp: number;
|
||||
error: string;
|
||||
context: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSOTrace extends Omit<Trace, 'traceId' | 'timestamp'> {
|
||||
timestamp?: number /** Can be passed in from outside else will be set to Date.now() */;
|
||||
context: Trace['context'] & {
|
||||
tenant: string;
|
||||
product: string;
|
||||
clientID: string;
|
||||
redirectUri?: string;
|
||||
requestedOIDCFlow?: boolean; // Type of OAuth client request
|
||||
isSAMLFederated?: boolean; // true if hit the SAML Federation flow
|
||||
isIDPFlow?: boolean; // true if IdP Login flow
|
||||
relayState?: string; // RelayState in SP flow
|
||||
providerName?: string; // SAML Federation SP
|
||||
acsUrl?: string; // ACS Url of SP in SAML Federation flow
|
||||
entityId?: string; // Entity ID of SP in SAML Federation flow
|
||||
samlRequest?: string; // Generated SAML Request
|
||||
samlResponse?: string; // Raw SAML response from IdP
|
||||
issuer?: string; // Parsed issuer from samlResponse
|
||||
profile?: any; // Profile extracted from samlResponse
|
||||
// OPError attributes from OIDC provider authorization response: https://github.com/panva/node-openid-client/blob/main/docs/README.md#class-operror
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
session_state_from_op_error?: string;
|
||||
scope_from_op_error?: string;
|
||||
stack?: string;
|
||||
oidcTokenSet?: { id_token?: string; access_token?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export type SetupLinkService = 'sso' | 'dsync';
|
||||
|
||||
export type SetupLink = {
|
||||
setupID: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
validTill: number;
|
||||
url: string;
|
||||
service: SetupLinkService;
|
||||
};
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"validity": "Validity",
|
||||
"documentation": "Documentation",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
|
@ -15,7 +14,6 @@
|
|||
"create_new": "Create New",
|
||||
"create_sso_connection": "Create SSO Connection",
|
||||
"delete": "Delete",
|
||||
"deleted": "Deleted",
|
||||
"delete_the_connection": "Delete the Connection?",
|
||||
"delete_this_connection": "Delete this Connection",
|
||||
"directory_name": "Directory name",
|
||||
|
@ -26,13 +24,9 @@
|
|||
"enable_webhook_events_logging": "Enable Webhook events logging",
|
||||
"boxyhq_tagline": "Security Building Blocks for Developers.",
|
||||
"enterprise_sso": "Enterprise SSO",
|
||||
"error": "Error",
|
||||
"idp_entity_id": "IdP Entity ID",
|
||||
"idp_login": "IdP Login",
|
||||
"login_with_sso": "Login with SSO",
|
||||
"login_success_toast": "A sign in link has been sent to your email address.",
|
||||
"link_generated": "Link Generated",
|
||||
"link_regenerated": "Link Regenerated",
|
||||
"name": "Name",
|
||||
"new_directory": "New Directory",
|
||||
"new_setup_link": "New Setup Link",
|
||||
|
@ -40,15 +34,12 @@
|
|||
"connections": "Connections",
|
||||
"new_connection": "New Connection",
|
||||
"no_projects_found": "No projects found.",
|
||||
"no_setup_links_found": "No setup links found.",
|
||||
"oidc": "OIDC",
|
||||
"open_menu": "Open menu",
|
||||
"open_sidebar": "Open sidebar",
|
||||
"prev": "Prev",
|
||||
"previous": "Previous",
|
||||
"product": "Product",
|
||||
"generate": "Generate",
|
||||
"regenerate": "Regenerate",
|
||||
"save_changes": "Save Changes",
|
||||
"saved": "Saved",
|
||||
"server_error": "Server error",
|
||||
|
@ -65,7 +56,6 @@
|
|||
"webhook_url": "Webhook URL",
|
||||
"download": "Download",
|
||||
"saml_federation_new_success": "SAML Federation app created successfully.",
|
||||
"acs_url": "ACS URL",
|
||||
"entity_id": "Entity ID / Audience URI / Audience Restriction",
|
||||
"saml_federation_update_success": "SAML Federation app updated successfully.",
|
||||
"saml_federation_delete_success": "SAML federation app deleted successfully",
|
||||
|
@ -78,35 +68,10 @@
|
|||
"directory_created_successfully": "Directory created successfully",
|
||||
"directory_updated_successfully": "Directory updated successfully",
|
||||
"dashboard": "Dashboard",
|
||||
"create_setup_link": "Create Setup Link ({{service}})",
|
||||
"setup_link_info": "Setup Link Info",
|
||||
"setup_link_sso_description": "Create a unique Setup Link to share with your customer so they can set up SSO Connection for your app.",
|
||||
"setup_link_dsync_description": "Create a unique Setup Link to share with your customer so they can set up Directory Sync for your app.",
|
||||
"saml_federation": "SAML Federation",
|
||||
"sso_tracer": "SSO Tracer",
|
||||
"no_sso_traces_found": "No SSO Traces recorded yet.",
|
||||
"trace_details": "Trace details",
|
||||
"trace_id": "Trace ID",
|
||||
"timestamp": "Timestamp",
|
||||
"assertion_type": "Assertion Type",
|
||||
"sp_protocol": "SP Protocol",
|
||||
"issuer": "Issuer",
|
||||
"profile": "Profile",
|
||||
"relay_state": "Relay State",
|
||||
"saml_request": "SAML Request",
|
||||
"saml_response": "SAML Response",
|
||||
"provider": "Provider",
|
||||
"trace_entity_id": "Entity ID",
|
||||
"sso_connection_client_id": "SSO Connection Client ID",
|
||||
"error_description_from_oidc_idp": "Error Description (from OIDC Provider)",
|
||||
"view": "View",
|
||||
"settings": "Settings",
|
||||
"admin_portal_sso": "SSO for Admin Portal",
|
||||
"setup_link_url": "Setup Link URL",
|
||||
"regenerate_setup_link": "Regenerate this setup link?",
|
||||
"regenerate_setup_link_description": "This action cannot be undone. This will permanently delete the old setup link.",
|
||||
"delete_setup_link": "Delete this setup link?",
|
||||
"delete_setup_link_description": "This action cannot be undone. This will permanently delete the setup link.",
|
||||
"close": "Close",
|
||||
"configuration": "Configuration",
|
||||
"view_events": "View Events",
|
||||
|
@ -118,10 +83,6 @@
|
|||
"curl_request": "cURL Request",
|
||||
"no_more_results": "No more results found",
|
||||
"unable_to_fetch_projects": "Unable to fetch the projects!",
|
||||
"redirect_uri": "Client Redirect URI",
|
||||
"default_redirect_url": "Default redirect URL",
|
||||
"allowed_redirect_url": "Allowed redirect URLs (newline separated)",
|
||||
"description": "Description",
|
||||
"sp_acs_url": "ACS (Assertion Consumer Service) URL / Single Sign-On URL / Destination URL",
|
||||
"sp_oidc_redirect_url": "Authorised redirect URI / Sign-in redirect URI",
|
||||
"sp_entity_id": "SP Entity ID / Identifier / Audience URI / Audience Restriction",
|
||||
|
@ -179,8 +140,6 @@
|
|||
"retraced_project_created": "Project created successfully",
|
||||
"project_name": "Project name",
|
||||
"create_project": "Create Project",
|
||||
"share_setup_link": "Share this link with your customer to setup their service",
|
||||
"setup_link_valid_till": "This link is valid till",
|
||||
"invalid_setup_link_alert": "Please contact your administrator to get a new setup link. If you are the administrator, visit the Admin Portal to create a new setup link for the service.",
|
||||
"boxyhq_admin_portal": "BoxyHQ Admin Portal",
|
||||
"environment": "Environment",
|
||||
|
@ -202,15 +161,12 @@
|
|||
"choose_an_identity_provider_to_continue": "Choose an Identity Provider to continue. If you don't see your Identity Provider, 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.",
|
||||
"expiry_in_days": "Expiry in days",
|
||||
"stack_trace": "Stack Trace",
|
||||
"error_uri": "error_uri in error response (from OIDC Provider)",
|
||||
"id_token_from_oidc_idp": "ID Token (from OIDC Provider)",
|
||||
"access_token_from_oidc_idp": "Access Token (from OIDC Provider)",
|
||||
"session_state_from_oidc_idp": "Session State (from OIDC Provider)",
|
||||
"scope_from_op_error": "Scope (from OIDC Provider)",
|
||||
"sso_connection_created_successfully": "SSO Connection created 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.",
|
||||
"setup-link-copied": "The setup link copied to the clipboard.",
|
||||
"setup-link-deleted": "The setup link deleted.",
|
||||
"bui-shared-name": "Name",
|
||||
"bui-shared-tenant": "Tenant",
|
||||
"bui-shared-product": "Product",
|
||||
|
@ -312,5 +268,78 @@
|
|||
"bui-dsync-authorized": "Authorized",
|
||||
"bui-dsync-not-authorized": "Not Authorized",
|
||||
"bui-dsync-authorization-google": "Authorize Google Workspace",
|
||||
"bui-dsync-authorization-google-desc": "You should authorize Google Workspace to sync your directory. Click the button below start the authorization process. Make sure you have the necessary permissions to authorize Google Workspace."
|
||||
"bui-dsync-authorization-google-desc": "You should authorize Google Workspace to sync your directory. Click the button below start the authorization process. Make sure you have the necessary permissions to authorize Google Workspace.",
|
||||
"bui-tracer-title": "SSO Tracer",
|
||||
"bui-tracer-id": "Trace ID",
|
||||
"bui-tracer-description": "Description",
|
||||
"bui-tracer-assertion-type": "Assertion Type",
|
||||
"bui-tracer-timestamp": "Timestamp",
|
||||
"bui-tracer-response": "Response",
|
||||
"bui-tracer-request": "Request",
|
||||
"bui-tracer-no-traces": "No SSO Traces recorded yet.",
|
||||
"bui-tracer-sp-protocol": "SP Protocol",
|
||||
"bui-tracer-saml-federation": "SAML Federation",
|
||||
"bui-tracer-idp-login": "IdP Login",
|
||||
"bui-tracer-oauth2": "OAuth 2.0",
|
||||
"bui-tracer-error": "Error",
|
||||
"bui-tracer-tenant": "Tenant",
|
||||
"bui-tracer-product": "Product",
|
||||
"bui-tracer-relay-state": "Relay State",
|
||||
"bui-tracer-default-redirect-url": "Default Redirect URL",
|
||||
"bui-tracer-redirect-uri": "Redirect URI",
|
||||
"bui-tracer-sso-connection-client-id": "SSO Connection Client ID",
|
||||
"bui-tracer-issuer": "Issuer",
|
||||
"bui-tracer-acs-url": "ACS URL",
|
||||
"bui-tracer-entity-id": "Entity ID",
|
||||
"bui-tracer-provider": "Provider",
|
||||
"bui-tracer-saml-response": "SAML Response",
|
||||
"bui-tracer-saml-request": "SAML Request",
|
||||
"bui-tracer-profile": "Profile",
|
||||
"bui-tracer-error-description-from-oidc-idp": "Error Description (from OIDC Provider)",
|
||||
"bui-tracer-error-uri": "Error URI",
|
||||
"bui-tracer-id-token-from-oidc-idp": "ID Token (from OIDC Provider)",
|
||||
"bui-tracer-access-token-from-oidc-idp": "Access Token (from OIDC Provider)",
|
||||
"bui-tracer-stack-trace": "Stack Trace",
|
||||
"bui-tracer-session-state-from-oidc-idp": "Session State (from OIDC Provider)",
|
||||
"bui-tracer-scope-from-op-error": "Scope (from OIDC Provider)",
|
||||
"bui-sl-sso-name": "Name (Optional)",
|
||||
"bui-sl-sso-description": "Description (Optional)",
|
||||
"bui-sl-create-link": "Create Setup Link",
|
||||
"bui-sl-dsync-name": "Name (Optional)",
|
||||
"bui-sl-tenant": "Tenant",
|
||||
"bui-sl-product": "Product",
|
||||
"bui-sl-create": "Create Setup Link",
|
||||
"bui-sl-expiry-days": "Expiry in days",
|
||||
"bui-sl-default-redirect-url": "Default redirect URL",
|
||||
"bui-sl-allowed-redirect-urls": "Allowed redirect URLs (newline separated)",
|
||||
"bui-sl-sso-desc": "Create a unique Setup Link to share with your customers so they can set Enterprise SSO connection with your app.",
|
||||
"bui-sl-dsync-desc": "Create a unique Setup Link to share with your customers so they can set Directory Sync connection with your app.",
|
||||
"bui-sl-dsync-name-placeholder": "Acme Directory",
|
||||
"bui-sl-sso-name-placeholder": "Acme SSO",
|
||||
"bui-sl-share-info": "Share this link with your customers to allow them to set up the integration",
|
||||
"bui-sl-btn-close": "Close",
|
||||
"bui-sl-dsync-title": "Setup Links (Directory Sync)",
|
||||
"bui-sl-sso-title": "Setup Links (Enterprise SSO)",
|
||||
"bui-sl-no-links": "No Setup Links found.",
|
||||
"bui-sl-no-links-desc": "You have not created any Setup Links yet.",
|
||||
"bui-sl-status": "Status",
|
||||
"bui-sl-active": "Active",
|
||||
"bui-sl-expired": "Expired",
|
||||
"bui-sl-actions": "Actions",
|
||||
"bui-sl-validity": "Valid till",
|
||||
"bui-sl-new-link": "New Setup Link",
|
||||
"bui-sl-view": "View",
|
||||
"bui-sl-copy": "Copy",
|
||||
"bui-sl-regenerate": "Regenerate",
|
||||
"bui-sl-delete": "Delete",
|
||||
"bui-sl-delete-link-title": "Delete Setup Link",
|
||||
"bui-sl-delete-link-desc": "This action cannot be undone. This will permanently delete the Setup Link.",
|
||||
"bui-sl-regen-link-title": "Regenerate Setup Link",
|
||||
"bui-sl-regen-link-desc": "This action cannot be undone. This will permanently delete the existing Setup Link and generate a new one.",
|
||||
"bui-sl-link-expired": "This link has expired",
|
||||
"bui-sl-link-expire-on": "This link will expire on {{expiresAt}}.",
|
||||
"bui-sl-share-link-info": "Share this link with your customer to setup their service",
|
||||
"bui-sl-webhook-url": "Webhook URL",
|
||||
"bui-sl-webhook-secret": "Webhook Secret",
|
||||
"bui-dsync-google-domain": "Google Domain"
|
||||
}
|
||||
|
|
|
@ -111,6 +111,38 @@ export class SetupLinkController {
|
|||
* in: formData
|
||||
* type: string
|
||||
* required: true
|
||||
* webhookUrlParamPost:
|
||||
* name: webhook_url
|
||||
* description: The URL to send the directory sync events to
|
||||
* in: formData
|
||||
* type: string
|
||||
* required: true
|
||||
* webhookSecretParamPost:
|
||||
* name: webhook_secret
|
||||
* description: The secret to sign the directory sync events
|
||||
* in: formData
|
||||
* type: string
|
||||
* required: true
|
||||
* nameParamPost:
|
||||
* name: name
|
||||
* description: Name of connection
|
||||
* in: formData
|
||||
* type: string
|
||||
* required: false
|
||||
* expiryDaysParamPost:
|
||||
* name: expiryDays
|
||||
* description: Days in number for the setup link to expire
|
||||
* default: 3
|
||||
* in: formData
|
||||
* type: number
|
||||
* required: false
|
||||
* regenerateParamPost:
|
||||
* name: regenerate
|
||||
* description: If passed as true, it will remove the existing setup link and create a new one.
|
||||
* in: formData
|
||||
* default: false
|
||||
* type: boolean
|
||||
* required: false
|
||||
* /api/v1/sso/setuplinks:
|
||||
* post:
|
||||
* summary: Create a Setup Link
|
||||
|
@ -122,10 +154,13 @@ export class SetupLinkController {
|
|||
* - application/x-www-form-urlencoded
|
||||
* - application/json
|
||||
* parameters:
|
||||
* - $ref: '#/parameters/nameParamPost'
|
||||
* - $ref: '#/parameters/tenantParamPost'
|
||||
* - $ref: '#/parameters/productParamPost'
|
||||
* - $ref: '#/parameters/defaultRedirectUrlParamPost'
|
||||
* - $ref: '#/parameters/redirectUrlParamPost'
|
||||
* - $ref: '#/parameters/expiryDaysParamPost'
|
||||
* - $ref: '#/parameters/regenerateParamPost'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
|
@ -142,8 +177,13 @@ export class SetupLinkController {
|
|||
* - application/x-www-form-urlencoded
|
||||
* - application/json
|
||||
* parameters:
|
||||
* - $ref: '#/parameters/nameParamPost'
|
||||
* - $ref: '#/parameters/tenantParamPost'
|
||||
* - $ref: '#/parameters/productParamPost'
|
||||
* - $ref: '#/parameters/webhookUrlParamPost'
|
||||
* - $ref: '#/parameters/webhookSecretParamPost'
|
||||
* - $ref: '#/parameters/expiryDaysParamPost'
|
||||
* - $ref: '#/parameters/regenerateParamPost'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
|
@ -151,29 +191,30 @@ export class SetupLinkController {
|
|||
* $ref: '#/definitions/SetupLink'
|
||||
*/
|
||||
async create(body: SetupLinkCreatePayload): Promise<SetupLink> {
|
||||
const {
|
||||
tenant,
|
||||
product,
|
||||
service,
|
||||
name,
|
||||
description,
|
||||
defaultRedirectUrl,
|
||||
regenerate,
|
||||
redirectUrl,
|
||||
expiryDays,
|
||||
} = body;
|
||||
const { name, tenant, product, service, expiryDays, regenerate } = body;
|
||||
|
||||
validateTenantAndProduct(tenant, product);
|
||||
|
||||
if (defaultRedirectUrl || redirectUrl) {
|
||||
const redirectUrlList = extractRedirectUrls(redirectUrl || '');
|
||||
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
|
||||
}
|
||||
|
||||
throwIfInvalidService(service);
|
||||
|
||||
const setupID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, service));
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
if (!tenant || !product) {
|
||||
throw new JacksonError('Must provide tenant and product', 400);
|
||||
}
|
||||
|
||||
if (service === 'sso') {
|
||||
const { defaultRedirectUrl, redirectUrl } = body;
|
||||
|
||||
if (!defaultRedirectUrl || !redirectUrl) {
|
||||
throw new JacksonError('Must provide defaultRedirectUrl and redirectUrl', 400);
|
||||
}
|
||||
|
||||
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList: extractRedirectUrls(redirectUrl || '') });
|
||||
} else if (service === 'dsync') {
|
||||
const { webhook_url, webhook_secret } = body;
|
||||
|
||||
if (!webhook_url || !webhook_secret) {
|
||||
throw new JacksonError('Must provide webhook_url and webhook_secret', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const existing: SetupLink[] = (
|
||||
await this.setupLinkStore.getByIndex({
|
||||
|
@ -191,21 +232,31 @@ export class SetupLinkController {
|
|||
await this.setupLinkStore.delete(existing[0].setupID);
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const expiryInDays = expiryDays || this.opts.setupLinkExpiryDays || 3;
|
||||
const setupID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, service));
|
||||
|
||||
const setupLink = {
|
||||
const setupLink: SetupLink = {
|
||||
setupID,
|
||||
tenant,
|
||||
product,
|
||||
service,
|
||||
name,
|
||||
description,
|
||||
redirectUrl,
|
||||
defaultRedirectUrl,
|
||||
validTill: calculateExpiryTimestamp(expiryInDays),
|
||||
url: `${this.opts.externalUrl}/setup/${token}`,
|
||||
};
|
||||
|
||||
if (service === 'sso') {
|
||||
const { defaultRedirectUrl, redirectUrl, description } = body;
|
||||
setupLink.defaultRedirectUrl = defaultRedirectUrl;
|
||||
setupLink.redirectUrl = redirectUrl;
|
||||
setupLink.description = description || '';
|
||||
} else if (service === 'dsync') {
|
||||
const { webhook_url, webhook_secret } = body;
|
||||
setupLink.webhook_url = webhook_url;
|
||||
setupLink.webhook_secret = webhook_secret;
|
||||
}
|
||||
|
||||
await this.setupLinkStore.put(
|
||||
setupID,
|
||||
setupLink,
|
||||
|
|
|
@ -548,18 +548,6 @@ export interface ApiError {
|
|||
code: number;
|
||||
}
|
||||
|
||||
export type SetupLinkCreatePayload = {
|
||||
tenant: string;
|
||||
product: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
defaultRedirectUrl?: string;
|
||||
redirectUrl?: string;
|
||||
service: SetupLinkService;
|
||||
regenerate?: boolean;
|
||||
expiryDays?: number;
|
||||
};
|
||||
|
||||
export type SetupLink = {
|
||||
setupID: string;
|
||||
tenant: string;
|
||||
|
@ -571,8 +559,22 @@ export type SetupLink = {
|
|||
url: string;
|
||||
service: SetupLinkService;
|
||||
validTill: number;
|
||||
webhook_url?: string;
|
||||
webhook_secret?: string;
|
||||
};
|
||||
|
||||
export type SetupLinkCreatePayload =
|
||||
| (Pick<SetupLink, 'name' | 'tenant' | 'product' | 'webhook_url' | 'webhook_secret'> & {
|
||||
service: 'dsync';
|
||||
regenerate?: boolean;
|
||||
expiryDays?: number;
|
||||
})
|
||||
| (Pick<SetupLink, 'name' | 'tenant' | 'product' | 'description' | 'defaultRedirectUrl' | 'redirectUrl'> & {
|
||||
service: 'sso';
|
||||
regenerate?: boolean;
|
||||
expiryDays?: number;
|
||||
});
|
||||
|
||||
export type SetupLinkService = 'sso' | 'dsync';
|
||||
|
||||
// Admin Portal settings
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import tap from 'tap';
|
||||
import { jacksonOptions } from './utils';
|
||||
import { ISetupLinkController } from 'npm/src';
|
||||
import { ISetupLinkController } from '../src';
|
||||
|
||||
let setupLinkController: ISetupLinkController;
|
||||
const product = 'jackson';
|
||||
|
@ -65,4 +65,35 @@ tap.test('Setup link controller', async (t) => {
|
|||
t.ok(setupLink);
|
||||
t.match(expireInDays(setupLink.validTill), 10);
|
||||
});
|
||||
|
||||
t.test('Create a new setup link for sso service', async (t) => {
|
||||
const setupLink = await setupLinkController.create({
|
||||
name: 'sso for acme',
|
||||
tenant: 'acme',
|
||||
product,
|
||||
service: 'sso',
|
||||
description: 'sso setup link for acme',
|
||||
defaultRedirectUrl: 'https://acme.com',
|
||||
redirectUrl: JSON.stringify(['https://acme.com', 'https://acme.com/login']),
|
||||
});
|
||||
|
||||
t.ok(setupLink);
|
||||
t.match(setupLink.redirectUrl, ['https://acme.com', 'https://acme.com/login']);
|
||||
t.match(setupLink.defaultRedirectUrl, 'https://acme.com');
|
||||
});
|
||||
|
||||
t.test('Create a new setup link for dsync service', async (t) => {
|
||||
const setupLink = await setupLinkController.create({
|
||||
name: 'dsync for acme',
|
||||
tenant: 'acme',
|
||||
product,
|
||||
service: 'dsync',
|
||||
webhook_url: 'https://acme.com/webhook',
|
||||
webhook_secret: 'webhook-secret',
|
||||
});
|
||||
|
||||
t.ok(setupLink);
|
||||
t.match(setupLink.webhook_url, 'https://acme.com/webhook');
|
||||
t.match(setupLink.webhook_secret, 'webhook-secret');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,22 +1,57 @@
|
|||
import type { GetServerSidePropsContext, NextPage } from 'next';
|
||||
import SetupLinkList from '@components/setup-link/SetupLinkList';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { useRouter } from 'next/router';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { SetupLinks } from '@boxyhq/internal-ui';
|
||||
import type { SetupLinkService } from '@boxyhq/saml-jackson';
|
||||
import type { GetServerSidePropsContext, NextPage } from 'next';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
|
||||
const serviceMap = {
|
||||
sso: 'sso-connection',
|
||||
dsync: 'directory-sync',
|
||||
} as const;
|
||||
|
||||
const SetupLinksIndexPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const service = router.asPath.includes('sso-connection')
|
||||
? 'sso'
|
||||
: router.asPath.includes('directory-sync')
|
||||
? 'dsync'
|
||||
: '';
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
if (service.length === 0) {
|
||||
return null;
|
||||
let service: SetupLinkService | null = null;
|
||||
|
||||
if (router.asPath.includes('sso-connection')) {
|
||||
service = 'sso';
|
||||
} else if (router.asPath.includes('directory-sync')) {
|
||||
service = 'dsync';
|
||||
}
|
||||
|
||||
return <SetupLinkList service={service as SetupLinkService} />;
|
||||
if (!service) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupLinks
|
||||
service={service}
|
||||
urls={{
|
||||
getLinks: '/api/admin/setup-links',
|
||||
deleteLink: '/api/admin/setup-links',
|
||||
regenerateLink: '/api/admin/setup-links',
|
||||
}}
|
||||
actions={{ newLink: `/admin/${serviceMap[service]}/setup-link/new` }}
|
||||
onCopy={() => {
|
||||
successToast(t('setup-link-copied'));
|
||||
}}
|
||||
onRegenerate={() => {
|
||||
successToast(t('setup-link-regenerated'));
|
||||
}}
|
||||
onDelete={() => {
|
||||
successToast(t('setup-link-deleted'));
|
||||
}}
|
||||
onError={(error) => {
|
||||
errorToast(error.message);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export async function getStaticProps({ locale }: GetServerSidePropsContext) {
|
||||
|
|
|
@ -1,30 +1,49 @@
|
|||
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import CreateSetupLink from '@components/setup-link/CreateSetupLink';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import type { SetupLinkService } from '@boxyhq/saml-jackson';
|
||||
import { LinkBack, NewSetupLink } from '@boxyhq/internal-ui';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
|
||||
|
||||
import { setupLinkExpiryDays } from '@lib/env';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
|
||||
const serviceMaps = {
|
||||
'sso-connection': 'sso',
|
||||
'directory-sync': 'dsync',
|
||||
};
|
||||
const serviceMap = {
|
||||
sso: 'sso-connection',
|
||||
dsync: 'directory-sync',
|
||||
} as const;
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const SetupLinkCreatePage = ({ expiryDays }: Props) => {
|
||||
const SetupLinkCreatePage = ({ expiryDays }: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Extract the service name from the path
|
||||
const serviceName = router.asPath.split('/')[2];
|
||||
let service: SetupLinkService | null = null;
|
||||
|
||||
const service = serviceMaps[serviceName] as SetupLinkService;
|
||||
|
||||
if (!service) {
|
||||
return null;
|
||||
if (router.asPath.includes('sso-connection')) {
|
||||
service = 'sso';
|
||||
} else if (router.asPath.includes('directory-sync')) {
|
||||
service = 'dsync';
|
||||
}
|
||||
|
||||
return <CreateSetupLink service={service} expiryDays={expiryDays} />;
|
||||
if (!service) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<LinkBack href={`/admin/${serviceMap[service]}/setup-link`} />
|
||||
<NewSetupLink
|
||||
urls={{ createLink: '/api/admin/setup-links' }}
|
||||
service={service}
|
||||
expiryDays={expiryDays}
|
||||
onCreate={() => {
|
||||
successToast(t('setup-link-created'));
|
||||
}}
|
||||
onError={(error) => errorToast(error.message)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
||||
|
|
|
@ -1,220 +1,18 @@
|
|||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { NextPage } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { SSOTrace } from '@boxyhq/saml-jackson';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import useSWR from 'swr';
|
||||
import { ApiSuccess, ApiError } from 'types';
|
||||
import { fetcher } from '@lib/ui/utils';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import Loading from '@components/Loading';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { Badge } from 'react-daisyui';
|
||||
import { CopyToClipboardButton } from '@components/ClipboardButton';
|
||||
|
||||
const DescriptionListItem = ({ term, value }: { term: string; value: string | JSX.Element }) => (
|
||||
<div className='px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{term}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0 overflow-auto'>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
import { SSOTracerInfo, LinkBack } from '@boxyhq/internal-ui';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
|
||||
const SSOTraceInspector: NextPage = () => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { traceId } = router.query as { traceId: string };
|
||||
|
||||
const { data, error, isLoading } = useSWR<ApiSuccess<SSOTrace>, ApiError>(
|
||||
`/api/admin/sso-tracer/${traceId}`,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
errorToast(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const trace = data.data;
|
||||
const assertionType = trace.context.samlResponse ? 'Response' : trace.context.samlRequest ? 'Request' : '-';
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkBack onClick={() => router.back()} />
|
||||
<div className='mt-5 overflow-hidden bg-white shadow sm:rounded-lg'>
|
||||
<div className='px-4 py-5 sm:px-6'>
|
||||
<h3 className='text-base font-semibold leading-6 text-gray-900'>{t('trace_details')}</h3>
|
||||
<p className='mt-1 flex max-w-2xl gap-6 text-sm text-gray-500'>
|
||||
<span className='whitespace-nowrap'>
|
||||
<span className='font-medium text-gray-500'>{t('trace_id')}</span>
|
||||
<span className='ml-2 font-bold text-gray-700'> {traceId}</span>
|
||||
</span>
|
||||
<span className='whitespace-nowrap'>
|
||||
<span className='font-medium text-gray-500'>{t('assertion_type')}:</span>
|
||||
<span className='ml-2 font-bold text-gray-700'>{assertionType}</span>
|
||||
</span>
|
||||
<span className='whitespace-nowrap'>
|
||||
<span className='font-medium text-gray-500'>{t('sp_protocol')}:</span>
|
||||
<Badge
|
||||
color='primary'
|
||||
size='md'
|
||||
className='ml-2 font-mono uppercase text-white'
|
||||
aria-label='SP Protocol'>
|
||||
{trace.context.requestedOIDCFlow
|
||||
? 'OIDC'
|
||||
: trace.context.isSAMLFederated
|
||||
? t('saml_federation')
|
||||
: trace.context.isIdPFlow
|
||||
? t('idp_login')
|
||||
: 'OAuth 2.0'}
|
||||
</Badge>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='border-t border-gray-200'>
|
||||
<dl>
|
||||
{typeof trace.timestamp === 'number' && (
|
||||
<DescriptionListItem term={t('timestamp')} value={new Date(trace.timestamp).toLocaleString()} />
|
||||
)}
|
||||
<DescriptionListItem term={t('error')} value={trace.error} />
|
||||
{trace.context.tenant && <DescriptionListItem term={t('tenant')} value={trace.context.tenant} />}
|
||||
{trace.context.product && (
|
||||
<DescriptionListItem term={t('product')} value={trace.context.product} />
|
||||
)}
|
||||
{trace.context.relayState && (
|
||||
<DescriptionListItem term={t('relay_state')} value={trace.context.relayState} />
|
||||
)}
|
||||
{trace.context.redirectUri && (
|
||||
<DescriptionListItem
|
||||
term={trace.context.isIDPFlow ? t('default_redirect_url') : t('redirect_uri')}
|
||||
value={trace.context.redirectUri}
|
||||
/>
|
||||
)}
|
||||
{trace.context.clientID && (
|
||||
<DescriptionListItem term={t('sso_connection_client_id')} value={trace.context.clientID} />
|
||||
)}
|
||||
{trace.context.issuer && <DescriptionListItem term={t('issuer')} value={trace.context.issuer} />}
|
||||
{trace.context.acsUrl && <DescriptionListItem term={t('acs_url')} value={trace.context.acsUrl} />}
|
||||
{trace.context.entityId && (
|
||||
<DescriptionListItem term={t('trace_entity_id')} value={trace.context.entityId} />
|
||||
)}
|
||||
{trace.context.providerName && (
|
||||
<DescriptionListItem term={t('provider')} value={trace.context.providerName} />
|
||||
)}
|
||||
{assertionType === 'Response' && trace.context.samlResponse && (
|
||||
<DescriptionListItem
|
||||
term={t('saml_response')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton text={trace.context.samlResponse}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='xml' style={materialOceanic}>
|
||||
{trace.context.samlResponse}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{assertionType === 'Request' && trace.context.samlRequest && (
|
||||
<DescriptionListItem
|
||||
term={t('saml_request')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton text={trace.context.samlRequest}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='xml' style={materialOceanic}>
|
||||
{trace.context.samlRequest}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof trace.context.profile === 'object' && trace.context.profile && (
|
||||
<DescriptionListItem
|
||||
term={t('profile')}
|
||||
value={
|
||||
<SyntaxHighlighter language='json' style={materialOceanic}>
|
||||
{JSON.stringify(trace.context.profile)}
|
||||
</SyntaxHighlighter>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{trace.context.error_description && (
|
||||
<DescriptionListItem
|
||||
term={t('error_description_from_oidc_idp')}
|
||||
value={trace.context.error_description}
|
||||
/>
|
||||
)}
|
||||
{trace.context.error_uri && (
|
||||
<DescriptionListItem term={t('error_uri')} value={trace.context.error_uri} />
|
||||
)}
|
||||
{trace.context.oidcTokenSet?.id_token && (
|
||||
<>
|
||||
<DescriptionListItem
|
||||
term={t('id_token_from_oidc_idp')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton
|
||||
text={trace.context.oidcTokenSet.id_token}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='shell' style={materialOceanic}>
|
||||
{trace.context.oidcTokenSet.id_token}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{trace.context.oidcTokenSet?.access_token && (
|
||||
<DescriptionListItem
|
||||
term={t('access_token_from_oidc_idp')}
|
||||
value={
|
||||
<>
|
||||
<CopyToClipboardButton
|
||||
text={trace.context.oidcTokenSet.access_token}></CopyToClipboardButton>
|
||||
<SyntaxHighlighter language='shell' style={materialOceanic}>
|
||||
{trace.context.oidcTokenSet.access_token}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{trace.context.stack && (
|
||||
<DescriptionListItem
|
||||
term={t('stack_trace')}
|
||||
value={
|
||||
<SyntaxHighlighter language='shell' style={materialOceanic}>
|
||||
{trace.context.stack}
|
||||
</SyntaxHighlighter>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{trace.context.session_state_from_op_error && (
|
||||
<DescriptionListItem
|
||||
term={t('session_state_from_oidc_idp')}
|
||||
value={trace.context.session_state_from_op_error}
|
||||
/>
|
||||
)}
|
||||
{trace.context.scope_from_op_error && (
|
||||
<DescriptionListItem
|
||||
term={t('scope_from_op_error')}
|
||||
value={trace.context.scope_from_op_error}
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className='space-y-4'>
|
||||
<LinkBack href='/admin/sso-tracer' />
|
||||
<SSOTracerInfo urls={{ getTracer: `/api/admin/sso-tracer/${traceId}` }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,118 +1,16 @@
|
|||
import { useEffect } from 'react';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import type { NextPage } from 'next';
|
||||
import useSWR from 'swr';
|
||||
import usePaginate from '@lib/ui/hooks/usePaginate';
|
||||
import { fetcher } from '@lib/ui/utils';
|
||||
import type { ApiSuccess, ApiError } from 'types';
|
||||
import type { Trace } from '@boxyhq/saml-jackson';
|
||||
import { pageLimit, Pagination } from '@components/Pagination';
|
||||
import Loading from '@components/Loading';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
import Link from 'next/link';
|
||||
import { Table } from '@components/table/Table';
|
||||
import { useRouter } from 'next/router';
|
||||
import { SSOTracers } from '@boxyhq/internal-ui';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
|
||||
const SSOTraceViewer: NextPage = () => {
|
||||
const { t } = useTranslation('common');
|
||||
const { paginate, setPaginate, pageTokenMap, setPageTokenMap } = usePaginate();
|
||||
|
||||
let getSSOTracesUrl = `/api/admin/sso-tracer?offset=${paginate.offset}&limit=${pageLimit}`;
|
||||
// Use the (next)pageToken mapped to the previous page offset to get the current page
|
||||
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
|
||||
getSSOTracesUrl += `&pageToken=${pageTokenMap[paginate.offset - pageLimit]}`;
|
||||
}
|
||||
|
||||
const { data, error, isLoading } = useSWR<ApiSuccess<Trace[]>, ApiError>(getSSOTracesUrl, fetcher);
|
||||
|
||||
const nextPageToken = data?.pageToken;
|
||||
// store the nextPageToken against the pageOffset
|
||||
useEffect(() => {
|
||||
if (nextPageToken) {
|
||||
setPageTokenMap((tokenMap) => ({ ...tokenMap, [paginate.offset]: nextPageToken }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nextPageToken, paginate.offset]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
errorToast(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
const traces = data?.data || [];
|
||||
const noTraces = traces.length === 0 && paginate.offset === 0;
|
||||
const noMoreResults = traces.length === 0 && paginate.offset > 0;
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{t('sso_tracer')}</h2>
|
||||
</div>
|
||||
{noTraces ? (
|
||||
<>
|
||||
<EmptyState title={t('no_sso_traces_found')} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
noMoreResults={noMoreResults}
|
||||
cols={[t('trace_id'), t('description'), t('assertion_type'), t('timestamp')]}
|
||||
body={traces.map((trace) => {
|
||||
return {
|
||||
id: trace.traceId,
|
||||
cells: [
|
||||
{
|
||||
wrap: true,
|
||||
element: (
|
||||
<Link
|
||||
href={`/admin/sso-tracer/${trace.traceId}/inspect`}
|
||||
className='link-primary link flex'>
|
||||
{trace.traceId}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
text: trace.error,
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
text: trace.context?.samlResponse
|
||||
? 'Response'
|
||||
: trace?.context.samlRequest
|
||||
? 'Request'
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
wrap: true,
|
||||
text: new Date(trace.timestamp).toLocaleString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
})}></Table>
|
||||
|
||||
<Pagination
|
||||
itemsCount={traces.length}
|
||||
offset={paginate.offset}
|
||||
onPrevClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset - pageLimit,
|
||||
});
|
||||
}}
|
||||
onNextClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset + pageLimit,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<SSOTracers
|
||||
urls={{ getTracers: '/api/admin/sso-tracer' }}
|
||||
onView={(trace) => router.push(`/admin/sso-tracer/${trace.traceId}/inspect`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -35,9 +35,9 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { setupLinkController } = await jackson();
|
||||
|
||||
const { setupID } = req.query as { setupID: string };
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
await setupLinkController.remove({ id: setupID });
|
||||
await setupLinkController.remove({ id });
|
||||
|
||||
return res.json({ data: {} });
|
||||
};
|
||||
|
@ -45,9 +45,9 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { setupLinkController } = await jackson();
|
||||
|
||||
const { token, service, offset, limit, pageToken } = req.query as {
|
||||
offset: string;
|
||||
limit: string;
|
||||
const { token, service, pageOffset, pageLimit, pageToken } = req.query as {
|
||||
pageOffset: string;
|
||||
pageLimit: string;
|
||||
pageToken?: string;
|
||||
token: string;
|
||||
service: string;
|
||||
|
@ -73,8 +73,8 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
if (service) {
|
||||
const setupLinksPaginated = await setupLinkController.filterBy({
|
||||
service: service as any,
|
||||
pageLimit: parseInt(limit),
|
||||
pageOffset: parseInt(offset),
|
||||
pageLimit: parseInt(pageLimit),
|
||||
pageOffset: parseInt(pageOffset),
|
||||
pageToken,
|
||||
});
|
||||
|
||||
|
|
|
@ -11,12 +11,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
await setupLinkController.getByToken(token);
|
||||
|
||||
switch (method) {
|
||||
case 'PUT':
|
||||
return await handlePUT(req, res);
|
||||
case 'PATCH':
|
||||
return await handlePATCH(req, res);
|
||||
case 'GET':
|
||||
return await handleGET(req, res);
|
||||
case 'DELETE':
|
||||
return await handleDELETE(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', 'PUT, GET');
|
||||
res.setHeader('Allow', 'PATCH, GET, DELETE');
|
||||
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
@ -27,24 +29,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
};
|
||||
|
||||
// Update a directory configuration
|
||||
const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { directoryId } = req.query as { directoryId: string };
|
||||
const { deactivated } = req.body;
|
||||
|
||||
const { name, webhook_url, webhook_secret, log_webhook_events } = req.body;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.update(directoryId as string, {
|
||||
name,
|
||||
log_webhook_events,
|
||||
webhook: {
|
||||
endpoint: webhook_url,
|
||||
secret: webhook_secret,
|
||||
},
|
||||
});
|
||||
const { data, error } = await directorySyncController.directories.update(directoryId, { deactivated });
|
||||
|
||||
if (data) {
|
||||
return res.status(201).json({ data });
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
@ -69,4 +63,19 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Delete a directory configuration
|
||||
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { directoryId } = req.query as { directoryId: string };
|
||||
|
||||
const { error } = await directorySyncController.directories.delete(directoryId);
|
||||
|
||||
if (error) {
|
||||
return res.status(error.code).json({ error });
|
||||
}
|
||||
|
||||
return res.json({ data: null });
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { DirectoryType, SetupLink } from '@boxyhq/saml-jackson';
|
||||
import type { SetupLink } from '@boxyhq/saml-jackson';
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
@ -31,16 +31,19 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const handlePOST = async (req: NextApiRequest, res: NextApiResponse, setupLink: SetupLink) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { name, type, webhook_url, webhook_secret } = req.body;
|
||||
const { type, google_domain } = req.body;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.create({
|
||||
name,
|
||||
const directory = {
|
||||
type,
|
||||
google_domain,
|
||||
name: setupLink.name,
|
||||
tenant: setupLink.tenant,
|
||||
product: setupLink.product,
|
||||
type: type as DirectoryType,
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
});
|
||||
webhook_url: setupLink.webhook_url,
|
||||
webhook_secret: setupLink.webhook_secret,
|
||||
};
|
||||
|
||||
const { data, error } = await directorySyncController.directories.create(directory);
|
||||
|
||||
if (data) {
|
||||
return res.status(201).json({ data });
|
||||
|
|
|
@ -35,6 +35,8 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
...setupLink,
|
||||
tenant: undefined,
|
||||
product: undefined,
|
||||
webhook_url: undefined,
|
||||
webhook_secret: undefined,
|
||||
...branding,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Enterprise SSO & Directory Sync",
|
||||
"version": "1.18.6",
|
||||
"version": "1.19.2",
|
||||
"description": "This is the API documentation for SAML Jackson service.",
|
||||
"termsOfService": "https://boxyhq.com/terms.html",
|
||||
"contact": {
|
||||
|
@ -449,6 +449,9 @@
|
|||
"application/json"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/parameters/nameParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/tenantParamPost"
|
||||
},
|
||||
|
@ -460,6 +463,12 @@
|
|||
},
|
||||
{
|
||||
"$ref": "#/parameters/redirectUrlParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/expiryDaysParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/regenerateParamPost"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -542,11 +551,26 @@
|
|||
"application/json"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/parameters/nameParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/tenantParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/productParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/webhookUrlParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/webhookSecretParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/expiryDaysParamPost"
|
||||
},
|
||||
{
|
||||
"$ref": "#/parameters/regenerateParamPost"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -1766,9 +1790,10 @@
|
|||
"parameters": {
|
||||
"nameParamPost": {
|
||||
"name": "name",
|
||||
"description": "Name/identifier for the connection",
|
||||
"description": "Name of connection",
|
||||
"type": "string",
|
||||
"in": "formData"
|
||||
"in": "formData",
|
||||
"required": false
|
||||
},
|
||||
"labelParamPost": {
|
||||
"name": "label",
|
||||
|
@ -2035,6 +2060,36 @@
|
|||
"type": "string",
|
||||
"description": "Strategy which can help to filter connections with tenant/product query"
|
||||
},
|
||||
"webhookUrlParamPost": {
|
||||
"name": "webhook_url",
|
||||
"description": "The URL to send the directory sync events to",
|
||||
"in": "formData",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"webhookSecretParamPost": {
|
||||
"name": "webhook_secret",
|
||||
"description": "The secret to sign the directory sync events",
|
||||
"in": "formData",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"expiryDaysParamPost": {
|
||||
"name": "expiryDays",
|
||||
"description": "Days in number for the setup link to expire",
|
||||
"default": 3,
|
||||
"in": "formData",
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
"regenerateParamPost": {
|
||||
"name": "regenerate",
|
||||
"description": "If passed as true, it will remove the existing setup link and create a new one.",
|
||||
"in": "formData",
|
||||
"default": false,
|
||||
"type": "boolean",
|
||||
"required": false
|
||||
},
|
||||
"setupLinkId": {
|
||||
"name": "id",
|
||||
"description": "Setup link ID",
|
||||
|
|
Loading…
Reference in New Issue