Hide the connection info from the setup link UI (#2383)

* For dsync

* For sso

* Refactor EditConnection component to hide certain fields in setup view

* Refactor EditConnection

* Refactor directory-sync and sso-connection API handlers

* Fix lint issue

* wip

* Fix updates

* Fix authorization check and error message

* Optimize

* Sync lock files

---------

Co-authored-by: Aswin V <vaswin91@gmail.com>
Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Kiran K 2024-03-07 01:33:50 +05:30 committed by GitHub
parent 7197ee59b2
commit 06c7d38b37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 212 additions and 127 deletions

View File

@ -174,6 +174,13 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
? '/admin/settings/sso-connection'
: '/admin/sso-connection';
const fieldsToHideInSetupView = ['forceAuthn', 'clientID', 'clientSecret', 'idpCertExpiry', 'idpMetadata'];
const readOnlyFields = filteredFieldsByConnection
.filter((field) => field.attributes.editable === false)
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
.filter(excludeFallback(formObj))
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true));
return (
<>
<LinkBack href={backUrl} />
@ -187,20 +194,22 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
<form onSubmit={save}>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 lg:border-none lg:p-0'>
<div className='flex flex-col gap-0 lg:flex-row lg:gap-4'>
<div className='w-full rounded border-gray-200 dark:border-gray-700 lg:w-3/5 lg:border lg:p-3'>
<div
className={`w-full rounded border-gray-200 dark:border-gray-700 lg:border lg:p-3 ${readOnlyFields.length > 0 ? 'lg:w-3/5' : ''}`}>
{filteredFieldsByConnection
.filter((field) => field.attributes.editable !== false)
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
.filter(excludeFallback(formObj))
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true))
.map(renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback }))}
</div>
<div className='w-full rounded border-gray-200 dark:border-gray-700 lg:w-2/5 lg:border lg:p-3'>
{filteredFieldsByConnection
.filter((field) => field.attributes.editable === false)
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
.filter(excludeFallback(formObj))
.map(renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback }))}
</div>
{readOnlyFields.length > 0 && (
<div className='w-full rounded border-gray-200 dark:border-gray-700 lg:w-2/5 lg:border lg:p-3'>
{readOnlyFields.map(
renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback })
)}
</div>
)}
</div>
<div className='flex w-full lg:mt-6'>
<ButtonPrimary type='submit'>{t('save_changes')}</ButtonPrimary>

View File

@ -48,10 +48,13 @@ export const ToggleConnectionStatus: FC<Props> = (props) => {
}
);
const response: ApiResponse = await res.json();
if (!res.ok) {
const response: ApiResponse = await res.json();
if ('error' in response) {
errorToast(response.error.message);
}
if ('error' in response) {
errorToast(response.error.message);
return;
}

View File

@ -6,10 +6,7 @@ import { DirectoryTab } from '../dsync';
import type { Directory } from '../types';
import { Loading, Error, PageHeader, Badge, Alert, InputWithCopyButton, LinkPrimary } from '../shared';
type ExcludeFields = keyof Pick<Directory, 'tenant' | 'product' | 'webhook'>;
// TODO:
// Add the toast after copying the google auth url
type ExcludeFields = keyof Pick<Directory, 'id' | 'tenant' | 'product' | 'webhook'>;
export const DirectoryInfo = ({
urls,
@ -37,7 +34,9 @@ export const DirectoryInfo = ({
return null;
}
const authorizedGoogle = directory.google_access_token && directory.google_refresh_token;
const authorizedGoogle =
directory?.google_authorized || (directory?.google_access_token && directory?.google_refresh_token);
const hideInfo = excludeFields.length === 4 && directory.type != 'google';
return (
<>
@ -60,62 +59,66 @@ export const DirectoryInfo = ({
</Alert>
</div>
)}
<div className={`rounded border ${hideTabs ? 'mt-5' : ''}`}>
<dl className='divide-y'>
<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-directory-id')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.id}</dd>
</div>
{!excludeFields.includes('tenant') && (
<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-tenant')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.tenant}</dd>
</div>
)}
{!excludeFields.includes('product') && (
<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-product')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.product}</dd>
</div>
)}
{!excludeFields.includes('webhook') && (
<>
{!hideInfo && (
<div className={`rounded border ${hideTabs ? 'mt-5' : ''}`}>
<dl className='divide-y'>
{!excludeFields.includes('id') && (
<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-webhook-endpoint')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.endpoint || '-'}
</dd>
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-directory-id')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.id}</dd>
</div>
)}
{!excludeFields.includes('tenant') && (
<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-webhook-secret')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.secret || '-'}
</dd>
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-tenant')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.tenant}</dd>
</div>
</>
)}
{directory.type === 'google' && (
<>
)}
{!excludeFields.includes('product') && (
<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>
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-product')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.product}</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>
)}
{!excludeFields.includes('webhook') && (
<>
<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-webhook-endpoint')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.endpoint || '-'}
</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-webhook-secret')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.secret || '-'}
</dd>
</div>
</>
)}
{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-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>
)}
{directory.scim.endpoint && directory.scim.secret && (
<div className='mt-4 space-y-4 rounded border p-6'>
<div className='form-control'>

View File

@ -6,7 +6,7 @@ export const useDirectory = (getDirectoryUrl: string) => {
const { data, error, isLoading } = useSWR(getDirectoryUrl, fetcher);
return {
directory: data?.data as Directory,
directory: data?.data as Directory & { google_authorized?: boolean },
isLoadingDirectory: isLoading,
directoryError: error,
};

View File

@ -38,11 +38,11 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const { data, error } = await directorySyncController.directories.update(directoryId, { deactivated });
if (data) {
return res.status(200).json({ data });
res.json({ data: null });
}
if (error) {
return res.status(error.code).json({ error });
res.status(error.code).json({ error });
}
};
@ -55,11 +55,21 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { data, error } = await directorySyncController.directories.get(directoryId);
if (data) {
return res.json({ data });
res.json({
data: {
id: data.id,
type: data.type,
name: data.name,
deactivated: data.deactivated,
scim: data.scim,
google_domain: data.google_domain,
google_authorized: data.google_access_token && data.google_refresh_token, // Indicate if the Google authorization is complete
},
});
}
if (error) {
return res.status(error.code).json({ error });
res.status(error.code).json({ error });
}
};
@ -72,10 +82,10 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { error } = await directorySyncController.directories.delete(directoryId);
if (error) {
return res.status(error.code).json({ error });
res.status(error.code).json({ error });
}
return res.json({ data: null });
res.json({ data: null });
};
export default handler;

View File

@ -46,11 +46,15 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse, setupLink:
const { data, error } = await directorySyncController.directories.create(directory);
if (data) {
return res.status(201).json({ data });
res.status(201).json({
data: {
id: data.id,
},
});
}
if (error) {
return res.status(error.code).json({ error });
res.status(error.code).json({ error });
}
};
@ -58,35 +62,26 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse, setupLink:
const handleGET = async (req: NextApiRequest, res: NextApiResponse, setupLink: SetupLink) => {
const { directorySyncController } = await jackson();
const { offset, limit, pageToken } = req.query as { offset: string; limit: string; pageToken?: string };
const pageOffset = parseInt(offset);
const pageLimit = parseInt(limit);
const {
data,
error,
pageToken: nextPageToken,
} = await directorySyncController.directories.getAll({
pageOffset,
pageLimit,
pageToken,
});
if (nextPageToken) {
res.setHeader('jackson-pagetoken', nextPageToken);
}
const { data, error } = await directorySyncController.directories.getByTenantAndProduct(
setupLink.tenant,
setupLink.product
);
if (data) {
const filteredData = data.filter(
(directory) => directory.tenant === setupLink.tenant && directory.product === setupLink.product
);
const directories = data.map((directory) => {
return {
id: directory.id,
type: directory.type,
name: directory.name,
deactivated: directory.deactivated,
};
});
return res.status(200).json({ data: filteredData });
res.json({ data: directories });
}
if (error) {
return res.status(error.code).json({ error });
res.status(error.code).json({ error });
}
};

View File

@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import type { SetupLink } from '@boxyhq/saml-jackson';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { setupLinkController } = await jackson();
@ -9,11 +8,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { token } = req.query as { token: string };
try {
const setupLink = await setupLinkController.getByToken(token);
await setupLinkController.getByToken(token);
switch (method) {
case 'GET':
return await handleGET(req, res, setupLink);
return await handleGET(req, res);
default:
res.setHeader('Allow', 'GET');
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
@ -25,17 +24,30 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
};
const handleGET = async (req: NextApiRequest, res: NextApiResponse, setupLink: SetupLink) => {
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { connectionAPIController } = await jackson();
const { id } = req.query as { id: string };
const connections = await connectionAPIController.getConnections({
tenant: setupLink.tenant,
product: setupLink.product,
clientID: id,
});
return res.json({ data: connections.filter((l) => l.clientID === id)[0] });
if (!connections || connections.length === 0) {
res.status(404).json({ error: { message: 'Connection not found.' } });
}
const connection = connections[0];
res.json({
data: {
clientID: connection.clientID,
clientSecret: connection.clientSecret,
deactivated: connection.deactivated,
...('idpMetadata' in connection ? { idpMetadata: {}, metadataUrl: connection.metadataUrl } : undefined),
...('oidcProvider' in connection ? { oidcProvider: connection.oidcProvider } : undefined),
},
});
};
export default handler;

View File

@ -18,9 +18,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
case 'POST':
return await handlePOST(req, res, setupLink);
case 'PATCH':
return await handlePATCH(req, res, setupLink);
return await handlePATCH(req, res);
case 'DELETE':
return await handleDELETE(req, res, setupLink);
return await handleDELETE(req, res);
default:
res.setHeader('Allow', 'GET, POST, PATCH, DELETE');
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
@ -40,7 +40,31 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse, setupLink: S
product: setupLink.product,
});
return res.json(connections);
const _connections = connections.map((connection) => {
return {
clientID: connection.clientID,
name: connection.name,
deactivated: connection.deactivated,
...('idpMetadata' in connection
? {
idpMetadata: {
provider: connection.idpMetadata.provider,
friendlyProviderName: connection.idpMetadata.friendlyProviderName,
},
}
: undefined),
...('oidcProvider' in connection
? {
oidcProvider: {
provider: connection.oidcProvider.provider,
friendlyProviderName: connection.oidcProvider.friendlyProviderName,
},
}
: undefined),
};
});
res.json(_connections);
};
const handlePOST = async (req: NextApiRequest, res: NextApiResponse, setupLink: SetupLink) => {
@ -54,47 +78,75 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse, setupLink:
const { isSAML, isOIDC } = strategyChecker(req);
if (isSAML) {
return res.status(201).json({ data: await connectionAPIController.createSAMLConnection(body) });
await connectionAPIController.createSAMLConnection(body);
} else if (isOIDC) {
return res
.status(201)
.json({ data: await connectionAPIController.createOIDCConnection(oidcMetadataParse(body)) });
await connectionAPIController.createOIDCConnection(oidcMetadataParse(body));
} else {
throw { message: 'Missing SSO connection params', statusCode: 400 };
}
res.status(201).json({ data: null });
};
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse, setupLink: SetupLink) => {
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { connectionAPIController } = await jackson();
const body = {
...req.query,
tenant: setupLink.tenant,
product: setupLink.product,
};
const { clientID, clientSecret } = req.query as { clientID: string; clientSecret: string };
await connectionAPIController.deleteConnections(body);
await connectionAPIController.deleteConnections({ clientID, clientSecret });
return res.json({ data: null });
res.json({ data: null });
};
const handlePATCH = async (req: NextApiRequest, res: NextApiResponse, setupLink: SetupLink) => {
const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const { connectionAPIController } = await jackson();
const body = {
...req.body,
...setupLink,
};
const {
deactivated,
clientID,
metadataUrl,
encodedRawMetadata,
oidcClientId,
oidcClientSecret,
oidcDiscoveryUrl,
} = req.body;
const connections = await connectionAPIController.getConnections({
clientID,
});
if (!connections || connections.length === 0) {
throw { message: 'Connection not found', statusCode: 404 };
}
const { isSAML, isOIDC } = strategyChecker(req);
const { tenant, product, clientSecret } = connections[0];
const body = {
tenant,
product,
clientID,
clientSecret,
...('deactivated' in req.body ? { deactivated } : undefined),
...(isSAML ? { metadataUrl, encodedRawMetadata } : undefined),
...(isOIDC
? {
oidcClientId,
oidcClientSecret,
oidcDiscoveryUrl,
}
: undefined),
};
if (isSAML) {
res.json({ data: await connectionAPIController.updateSAMLConnection(body) });
await connectionAPIController.updateSAMLConnection(body as any);
} else if (isOIDC) {
res.json({ data: await connectionAPIController.updateOIDCConnection(oidcMetadataParse(body) as any) });
await connectionAPIController.updateOIDCConnection(oidcMetadataParse(body as any));
} else {
throw { message: 'Missing SSO connection params', statusCode: 400 };
}
res.status(204).end();
};
export default handler;

View File

@ -24,6 +24,7 @@ const DirectoryDetailsPage: NextPage = () => {
}}
hideTabs={true}
displayGoogleAuthButton={true}
excludeFields={['id', 'tenant', 'product', 'webhook']}
/>
</div>
</div>