Setup links tweaks (#788)

* Update

* Add verification to the setup link to ensure it is valid and not expired before allowing the user to continue.

* cleanup

* Tweaks to setup links

* Remove the unnecessary conditions from DirectoryTab

* Add the missing translation in CreateSetupLink

* Invoke the mutate at the beginning

* Remove unused type

* Remove another unused type

* Make the description optional in Modal and add ModalProps

* Adjust the input border radius

* Display setup link after the setup link is regenerated

* Display setup link info

* Remove the existing setup link if regenerate is true

* show expired date in red

* standardised View icon

Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Kiran K 2022-12-30 19:13:50 +05:30 committed by GitHub
parent de3fdff71c
commit 17161de3d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 430 additions and 305 deletions

View File

@ -1,25 +1,28 @@
import React from 'react';
import { useState, useEffect } from 'react';
const Modal = (props: {
type ModalProps = {
visible: boolean;
title: string;
description: string;
description?: string;
children?: React.ReactNode;
}) => {
const { visible, title, description, children } = props;
};
const [open, setOpen] = React.useState(visible ? visible : false);
const Modal = ({ visible, title, description, children }: ModalProps) => {
const [open, setOpen] = useState(visible ? visible : false);
React.useEffect(() => {
useEffect(() => {
setOpen(visible);
}, [visible]);
return (
<div className={`modal ${open ? 'modal-open' : ''}`}>
<div className='modal-box'>
<h3 className='text-lg font-bold'>{title}</h3>
<p className='py-4'>{description}</p>
<div>{children}</div>
<div className='flex flex-col gap-2'>
<h3 className='text-lg font-bold'>{title}</h3>
{description && <p className='text-sm'>{description}</p>}
<div>{children}</div>
</div>
</div>
</div>
);

View File

@ -21,10 +21,8 @@ export const Toaster = () => {
if (!toast) return null;
return (
<Alert key={`toast-${index}`} status={toast.status}>
<div className='w-full flex-row justify-between gap-2'>
<h3>{toast.text}</h3>
</div>
<Alert key={`toast-${index}`} status={toast.status} className='rounded py-3'>
<h3>{toast.text}</h3>
<Button
color='ghost'
onClick={() =>

View File

@ -41,7 +41,7 @@ const ConnectionList = ({
{ revalidateOnFocus: false }
);
if (!data) {
if (!data && !error) {
return <Loading />;
}
@ -50,7 +50,7 @@ const ConnectionList = ({
return null;
}
const connections = data.data || [];
const connections = data?.data || [];
if (connections && setupLinkToken && connections.length === 0) {
router.replace(`/setup/${setupLinkToken}/sso-connection/new`);

View File

@ -173,29 +173,3 @@ export function renderFieldList(args: {
};
return FieldList;
}
export const deleteLink = async (setupID: string) => {
await fetch(`/api/admin/setup-links?setupID=${setupID}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
};
export const regenerateLink = async (setupLink: any, service: string) => {
const { tenant, product } = setupLink;
const res = await fetch('/api/admin/setup-links', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tenant,
product,
type: service,
regenerate: true,
}),
});
};

View File

@ -34,7 +34,7 @@ const DirectoryInfo = ({ directoryId, setupLinkToken }: { directoryId: string; s
<LinkBack href={backUrl} />
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='directory' token={setupLinkToken} />
<DirectoryTab directory={directory} activeTab='directory' setupLinkToken={setupLinkToken} />
<div className='my-3 rounded border'>
<dl className='divide-y'>
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>

View File

@ -1,5 +1,5 @@
import EmptyState from '@components/EmptyState';
import CircleStackIcon from '@heroicons/react/24/outline/CircleStackIcon';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import LinkIcon from '@heroicons/react/24/outline/LinkIcon';
import PencilIcon from '@heroicons/react/24/outline/PencilIcon';
import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
@ -113,7 +113,7 @@ const DirectoryList = ({ setupLinkToken }: { setupLinkToken?: string }) => {
<span className='inline-flex items-baseline'>
<IconButton
tooltip={t('view')}
Icon={CircleStackIcon}
Icon={EyeIcon}
className='mr-3 hover:text-green-400'
onClick={() => {
router.push(

View File

@ -2,46 +2,42 @@ import Link from 'next/link';
import type { Directory } from '@boxyhq/saml-jackson';
import classNames from 'classnames';
const DirectoryTab = (props: { directory: Directory; activeTab: string; token?: string }) => {
const { directory, activeTab, token } = props;
const menus = token
const DirectoryTab = ({
directory,
activeTab,
setupLinkToken,
}: {
directory: Directory;
activeTab: string;
setupLinkToken?: string;
}) => {
const menus = setupLinkToken
? [
{
name: 'Directory',
href: token
? `/setup/${token}/directory-sync/${directory.id}`
: `/admin/directory-sync/${directory.id}`,
href: `/setup/${setupLinkToken}/directory-sync/${directory.id}`,
active: activeTab === 'directory',
},
]
: [
{
name: 'Directory',
href: token
? `/setup/${token}/directory-sync/${directory.id}`
: `/admin/directory-sync/${directory.id}`,
href: `/admin/directory-sync/${directory.id}`,
active: activeTab === 'directory',
},
{
name: 'Users',
href: token
? `/setup/${token}/directory-sync/${directory.id}/users`
: `/admin/directory-sync/${directory.id}/users`,
href: `/admin/directory-sync/${directory.id}/users`,
active: activeTab === 'users',
},
{
name: 'Groups',
href: token
? `/setup/${token}/directory-sync/${directory.id}/groups`
: `/admin/directory-sync/${directory.id}/groups`,
href: `/admin/directory-sync/${directory.id}/groups`,
active: activeTab === 'groups',
},
{
name: 'Webhook Events',
href: token
? `/setup/${token}/directory-sync/${directory.id}/events`
: `/admin/directory-sync/${directory.id}/events`,
href: `/admin/directory-sync/${directory.id}/events`,
active: activeTab === 'events',
},
];

View File

@ -4,14 +4,25 @@ import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/router';
import Logo from '../../public/logo.png';
import InvalidSetupLinkAlert from '@components/setup-link/InvalidSetupLinkAlert';
import Loading from '@components/Loading';
import useSetupLink from '@lib/ui/hooks/useSetupLink';
export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
export const SetupLinkLayout = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { token } = router.query;
const { token } = router.query as { token: string };
const { setupLink, error, isLoading } = useSetupLink(token);
if (isLoading) {
return <Loading />;
}
return (
<>
<Head>
<title>Enterprise SSO - BoxyHQ</title>
<title>Setup Link - BoxyHQ</title>
<link rel='icon' href='/favicon.ico' />
</Head>
<div className='flex flex-1 flex-col'>
@ -27,7 +38,10 @@ export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
</div>
<main>
<div className='py-6'>
<div className='mx-auto max-w-7xl px-4 sm:px-6 md:px-8'>{children}</div>
<div className='mx-auto max-w-7xl px-4 sm:px-6 md:px-8'>
{error && <InvalidSetupLinkAlert message={error.message} />}
{setupLink && children}
</div>
</div>
</main>
</div>

View File

@ -1,2 +1,2 @@
export { AccountLayout } from './AccountLayout';
export { SetupLayout } from './SetupLayout';
export { SetupLinkLayout } from './SetupLinkLayout';

View File

@ -139,10 +139,9 @@ const CreateSetupLink = ({ service }: { service: SetupLinkService }) => {
</ButtonPrimary>
</div>
</form>
<ConfirmationModal
title='Delete the setup link'
description='This action cannot be undone. This will permanently delete the link.'
title={t('regenerate_setup_link')}
description={t('regenerate_setup_link_description')}
visible={delModalVisible}
onConfirm={regenerateSetupLink}
onCancel={toggleDelConfirm}

View File

@ -0,0 +1,13 @@
const InvalidSetupLinkAlert = ({ message }: { message: string }) => {
return (
<div className='flex flex-col gap-3 rounded border border-error p-4'>
<h3 className='text-base font-medium'>{message}</h3>
<p className='leading-6'>
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.
</p>
</div>
);
};
export default InvalidSetupLinkAlert;

View File

@ -1,185 +0,0 @@
import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
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 { successToast } from '@components/Toaster';
import { copyToClipboard, fetcher } from '@lib/ui/utils';
import useSWR from 'swr';
import { deleteLink, regenerateLink } from '@components/connection/utils';
import { LinkPrimary } from '@components/LinkPrimary';
import { IconButton } from '@components/IconButton';
import { Pagination, pageLimit } from '@components/Pagination';
import usePaginate from '@lib/ui/hooks/usePaginate';
import Loading from '@components/Loading';
const LinkList = ({ service }) => {
const { paginate, setPaginate } = usePaginate();
const [queryParam, setQueryParam] = useState('');
useEffect(() => {
setQueryParam(`?service=${service}`);
}, [service]);
// const [paginate, setPaginate] = useState({ pageOffset: 0, pageLimit: 20, page: 0 });
const { data, mutate } = useSWR<any>(queryParam ? [`/api/admin/setup-links`, queryParam] : [], fetcher, {
revalidateOnFocus: false,
});
const links = data?.data;
const { t } = useTranslation('common');
const [showDelConfirmModal, setShowDelConfirmModal] = useState(false);
const [showRegenConfirmModal, setShowRegenConfirmModal] = useState(false);
const toggleDelConfirmModal = () => setShowDelConfirmModal(!showDelConfirmModal);
const toggleRegenConfirmModal = () => setShowRegenConfirmModal(!showRegenConfirmModal);
const [actionId, setActionId] = useState(0);
const invokeRegenerate = async () => {
await regenerateLink(links[actionId], service);
toggleRegenConfirmModal();
await mutate();
successToast(t('link_regenerated'));
};
const invokeDelete = async () => {
await deleteLink(links[actionId].setupID);
toggleDelConfirmModal();
await mutate();
successToast(t('deleted'));
};
if (!links) {
return <Loading />;
}
return (
<div>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>
{t('setup_links') + ' (' + (service === 'sso' ? t('enterprise_sso') : t('directory_sync')) + ')'}
</h2>
<div className='mb-5 flex items-center justify-between'>
<h3>{service === 'sso' ? t('setup_link_sso_description') : t('setup_link_dsync_description')}</h3>
<div>
<LinkPrimary
Icon={PlusIcon}
href={`/admin/${
service === 'sso' ? 'sso-connection' : service === 'dsync' ? 'directory-sync' : ''
}/setup-link/new`}
data-test-id='create-setup-link'>
{t('new_setup_link')}
</LinkPrimary>
</div>
</div>
{links.length === 0 ? (
<EmptyState
title={t('no_setup_links_found')}
href={`/admin/${
service === 'sso' ? 'sso-connection' : service === 'dsync' ? 'directory-sync' : ''
}/setup-link/new`}
/>
) : (
<>
<div className='rounder border'>
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr className='hover:bg-gray-50'>
<th scope='col' className='px-6 py-3'>
{t('tenant')}
</th>
<th scope='col' className='px-6 py-3'>
{t('product')}
</th>
<th scope='col' className='px-6 py-3'>
{t('validity')}
</th>
<th scope='col' className='px-6 py-3'>
{t('actions')}
</th>
</tr>
</thead>
<tbody>
{links.map((link, idx) => {
return (
<tr
key={link.setupID}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800'>
<td className='whitespace-nowrap px-6 py-3 text-sm font-medium text-gray-900 dark:text-white'>
{link.tenant}
</td>
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
{link.product}
</td>
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
<p className={new Date(link.validTill) < new Date() ? `text-red-400` : ``}>
{new Date(link.validTill).toString()}
</p>
</td>
<td className='px-6 py-3'>
<span className='inline-flex items-baseline'>
<IconButton
tooltip={t('copy')}
Icon={ClipboardDocumentIcon}
className='mr-3 hover:text-green-400'
onClick={() => {
copyToClipboard(link.url);
successToast(t('copied'));
}}
/>
<IconButton
tooltip={t('regenerate')}
Icon={ArrowPathIcon}
className='mr-3 hover:text-green-400'
onClick={() => {
setActionId(idx);
toggleRegenConfirmModal();
}}
/>
<IconButton
tooltip={t('delete')}
Icon={TrashIcon}
className='mr-3 hover:text-red-900'
onClick={() => {
setActionId(idx);
toggleDelConfirmModal();
}}
/>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<Pagination
itemsCount={links.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
<ConfirmationModal
title='Regenerate this setup link?'
description='This action cannot be undone. This will permanently delete the old setup link.'
visible={showRegenConfirmModal}
onConfirm={invokeRegenerate}
actionButtonText={t('regenerate')}
onCancel={toggleRegenConfirmModal}></ConfirmationModal>
<ConfirmationModal
title='Delete this setup link?'
description='This action cannot be undone. This will permanently delete the setup link.'
visible={showDelConfirmModal}
onConfirm={invokeDelete}
onCancel={toggleDelConfirmModal}></ConfirmationModal>
</div>
);
};
export default LinkList;

View File

@ -0,0 +1,41 @@
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 for the tenant ${setupLink.tenant}`}>
<div className='mt-2 flex flex-col gap-3'>
<div>
<InputWithCopyButton
text={setupLink.url}
label='Share this link with your customer to setup their service'
/>
</div>
<p className='text-sm'>
This link is valid till{' '}
<p className={new Date(setupLink.validTill) < new Date() ? 'text-red-400' : ''}>
{new Date(setupLink.validTill).toString()}
</p>
</p>
</div>
<div className='modal-action'>
<ButtonOutline onClick={onClose}>{t('close')}</ButtonOutline>
</div>
</Modal>
);
};

View File

@ -0,0 +1,252 @@
import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
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 { IconButton } from '@components/IconButton';
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';
const SetupLinkList = ({ service }: { service: SetupLinkService }) => {
const { paginate, setPaginate } = 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]);
const { data, error, mutate } = useSWR<ApiSuccess<SetupLink[]>, ApiError>(
`/api/admin/setup-links?service=${service}&offset=${paginate.offset}&limit=${pageLimit}`,
fetcher,
{
revalidateOnFocus: false,
}
);
if (!data && !error) {
return <Loading />;
}
const setupLinks = data?.data || [];
// 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');
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 Icon={PlusIcon} href={createSetupLinkUrl} data-test-id='create-setup-link'>
{t('new_setup_link')}
</LinkPrimary>
</div>
</div>
{setupLinks.length === 0 ? (
<EmptyState title={t('no_setup_links_found')} href={createSetupLinkUrl} />
) : (
<>
<div className='rounder border'>
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr className='hover:bg-gray-50'>
<th scope='col' className='px-6 py-3'>
{t('tenant')}
</th>
<th scope='col' className='px-6 py-3'>
{t('product')}
</th>
<th scope='col' className='px-6 py-3'>
{t('validity')}
</th>
<th scope='col' className='px-6 py-3'>
{t('actions')}
</th>
</tr>
</thead>
<tbody>
{setupLinks.map((setupLink) => {
return (
<tr
key={setupLink.setupID}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800'>
<td className='whitespace-nowrap px-6 py-3 text-sm font-medium text-gray-900 dark:text-white'>
{setupLink.tenant}
</td>
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
{setupLink.product}
</td>
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
<p className={new Date(setupLink.validTill) < new Date() ? 'text-red-400' : ''}>
{new Date(setupLink.validTill).toString()}
</p>
</td>
<td className='px-6 py-3'>
<span className='flex gap-3'>
<IconButton
tooltip={t('copy')}
Icon={ClipboardDocumentIcon}
className='hover:text-green-400'
onClick={() => {
copyToClipboard(setupLink.url);
successToast(t('copied'));
}}
/>
<IconButton
tooltip={t('view')}
Icon={EyeIcon}
className='hover:text-green-400'
onClick={() => {
showSetupLinkInfo(setupLink);
}}
/>
<IconButton
tooltip={t('regenerate')}
Icon={ArrowPathIcon}
className='hover:text-green-400'
onClick={() => {
setSelectedSetupLink(setupLink);
setShowRegenConfirmModal(true);
setShowSetupLinkModal(false);
}}
/>
<IconButton
tooltip={t('delete')}
Icon={TrashIcon}
className='hover:text-red-900'
onClick={() => {
setSelectedSetupLink(setupLink);
setShowDelConfirmModal(true);
}}
/>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<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,18 @@
import useSWR from 'swr';
import type { SetupLink } from '@boxyhq/saml-jackson';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
const useSetupLink = (setupLinkToken: string) => {
const url = setupLinkToken ? `/api/setup/${setupLinkToken}` : null;
const { data, error } = useSWR<ApiSuccess<SetupLink>, ApiError>(url, fetcher);
return {
setupLink: data?.data,
isLoading: !data && !error,
error,
};
};
export default useSetupLink;

View File

@ -120,5 +120,10 @@
"no_groups_found": "No groups found",
"setup_link_url": "Setup Link URL",
"no_directories_found": "No directories found",
"no_webhook_events_found": "No webhook events found"
"no_webhook_events_found": "No webhook events found",
"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"
}

View File

@ -57,7 +57,11 @@ module.exports = {
},
{
source: '/admin/directory-sync/setup-link',
destination: '/admin/sso-connection/setup-link',
destination: '/admin/setup-link',
},
{
source: '/admin/sso-connection/setup-link',
destination: '/admin/setup-link',
},
{
source: '/admin/sso-connection/setup-link/new',

View File

@ -29,6 +29,11 @@ export class SetupLinkController {
return existing[0];
}
// Remove the existing setup link if regenerate is true
if (regenerate) {
await this.setupLinkStore.delete(existing[0].setupID);
}
const setupLink = {
setupID,
tenant,
@ -69,8 +74,8 @@ export class SetupLinkController {
value: token,
});
if (!setupLink) {
throw new JacksonError('Setup link not found', 404);
if (!setupLink || setupLink.length === 0) {
throw new JacksonError('Setup link is not found', 404);
}
if (this.isExpired(setupLink[0])) {

View File

@ -686,10 +686,6 @@ export type SetupLinkCreatePayload = {
regenerate?: boolean;
};
export type SetupLinkRegeneratePayload = {
reference: string;
};
export type SetupLink = {
setupID: string;
tenant: string;
@ -699,9 +695,4 @@ export type SetupLink = {
validTill: number;
};
export type ApiResponse<T> = {
data: T | null;
error: ApiError | null;
};
export type SetupLinkService = 'sso' | 'dsync';

View File

@ -9,7 +9,7 @@ import { ReactElement, ReactNode } from 'react';
import micromatch from 'micromatch';
import nextI18NextConfig from '../next-i18next.config.js';
import { AccountLayout, SetupLayout } from '@components/layouts';
import { AccountLayout, SetupLinkLayout } from '@components/layouts';
import '../styles/globals.css';
@ -47,10 +47,10 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
if (pathname.startsWith('/setup/')) {
return (
<SetupLayout>
<SetupLinkLayout>
<Component {...props} />
<Toaster />
</SetupLayout>
</SetupLinkLayout>
);
}

View File

@ -1,9 +1,10 @@
import type { GetServerSidePropsContext, NextPage } from 'next';
import LinkList from '@components/setup-link/LinkList';
import SetupLinkList from '@components/setup-link/SetupLinkList';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import type { SetupLinkService } from '@boxyhq/saml-jackson';
const SetupLinks: NextPage = () => {
const SetupLinksIndexPage: NextPage = () => {
const router = useRouter();
const service = router.asPath.includes('sso-connection')
? 'sso'
@ -15,7 +16,7 @@ const SetupLinks: NextPage = () => {
return null;
}
return <LinkList service={service} />;
return <SetupLinkList service={service as SetupLinkService} />;
};
export async function getStaticProps({ locale }: GetServerSidePropsContext) {
@ -26,4 +27,4 @@ export async function getStaticProps({ locale }: GetServerSidePropsContext) {
};
}
export default SetupLinks;
export default SetupLinksIndexPage;

View File

@ -55,13 +55,19 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse, setupLink:
const handleGET = async (req: NextApiRequest, res: NextApiResponse, setupLink: SetupLink) => {
const { directorySyncController } = await jackson();
const { data, error } = await directorySyncController.directories.getByTenantAndProduct(
setupLink.tenant,
setupLink.product
);
const { offset, limit } = req.query as { offset: string; limit: string };
const pageOffset = parseInt(offset);
const pageLimit = parseInt(limit);
const { data, error } = await directorySyncController.directories.list({ pageOffset, pageLimit });
if (data) {
return res.status(200).json({ data: [data] });
const filteredData = data.filter(
(directory) => directory.tenant === setupLink.tenant && directory.product === setupLink.product
);
return res.status(200).json({ data: filteredData });
}
if (error) {

View File

@ -1,48 +1,32 @@
import type { NextPage } from 'next';
import useSWR from 'swr';
import { fetcher } from '@lib/ui/utils';
import { useRouter } from 'next/router';
import type { ApiError, ApiSuccess } from 'types';
import type { SetupLink } from '@boxyhq/saml-jackson';
import Loading from '@components/Loading';
import { errorToast } from '@components/Toaster';
import useSetupLink from '@lib/ui/hooks/useSetupLink';
const SetupLinksIndexPage: NextPage = () => {
const SetupLinkIndexPage: NextPage = () => {
const router = useRouter();
const { token } = router.query as { token: string };
const { data, error } = useSWR<ApiSuccess<SetupLink>, ApiError>(
token ? `/api/setup/${token}` : null,
fetcher,
{
revalidateOnFocus: false,
}
);
const { setupLink, isLoading } = useSetupLink(token);
if (!data) {
if (isLoading) {
return <Loading />;
}
if (error) {
errorToast(error.message);
return null;
}
// We can safely assume that the setupLink is valid here
// because the SetupLink layout is doing the validation before rendering this page.
const setupLink = data.data;
switch (setupLink.service) {
switch (setupLink?.service) {
case 'sso':
router.replace(`/setup/${token}/sso-connection`);
break;
case 'dsync':
router.replace(`/setup/${token}/directory-sync`);
break;
default:
router.replace('/');
}
return null;
};
export default SetupLinksIndexPage;
export default SetupLinkIndexPage;

View File

@ -23,6 +23,12 @@ a {
box-sizing: border-box;
}
@layer base {
input {
@apply rounded !important;
}
}
@layer components {
#__next {
@apply h-full;