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:
Kiran K 2024-03-01 22:30:38 +05:30 committed by GitHub
parent 6c6cc6dbb7
commit a6ef0ddddb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1953 additions and 1162 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export { NewSetupLink } from './NewSetupLink';
export { SetupLinkInfo } from './SetupLinkInfo';
export { SetupLinks } from './SetupLinks';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { SSOTracers } from './SSOTracers';
export { SSOTracerInfo } from './SSOTracerInfo';

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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) {

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,6 +35,8 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
...setupLink,
tenant: undefined,
product: undefined,
webhook_url: undefined,
webhook_secret: undefined,
...branding,
},
});

View File

@ -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",