mirror of https://github.com/boxyhq/jackson.git
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:
parent
de3fdff71c
commit
17161de3d4
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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={() =>
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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>
|
|
@ -1,2 +1,2 @@
|
|||
export { AccountLayout } from './AccountLayout';
|
||||
export { SetupLayout } from './SetupLayout';
|
||||
export { SetupLinkLayout } from './SetupLinkLayout';
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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])) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -23,6 +23,12 @@ a {
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
input {
|
||||
@apply rounded !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
#__next {
|
||||
@apply h-full;
|
||||
|
|
Loading…
Reference in New Issue