Merge branch 'main' into feat/audit-logs-saas-app

This commit is contained in:
Kiran K 2024-01-03 16:55:06 +05:30
commit 48e731416f
59 changed files with 15791 additions and 15010 deletions

View File

@ -44,6 +44,8 @@ NEXTAUTH_ADMIN_CREDENTIALS=
RETRACED_HOST_URL=
RETRACED_EXTERNAL_URL=
RETRACED_ADMIN_ROOT_TOKEN=
RETRACED_API_KEY=
RETRACED_PROJECT_ID=
# Admin Portal for Terminus (Privacy Vault)
TERMINUS_PROXY_HOST_URL=
@ -97,7 +99,5 @@ DSYNC_GOOGLE_CLIENT_ID=
DSYNC_GOOGLE_CLIENT_SECRET=
DSYNC_GOOGLE_REDIRECT_URI=
# Retraced
RETRACED_HOST_URL=
RETRACED_API_KEY=
RETRACED_PROJECT_ID=
# Only applicable for BoxyHQ SaaS deployments
BOXYHQ_HOSTED=0

View File

@ -9,18 +9,20 @@ module.exports = {
},
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
plugins: ['@typescript-eslint', 'i18next'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'next/core-web-vitals',
'plugin:i18next/recommended',
],
overrides: [
{
files: ['*.ts', '*.tsx'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
'import/no-anonymous-default-export': 'off',
},
},
{

View File

@ -1,4 +1,4 @@
ARG NODEJS_IMAGE=node:20.8.1-alpine3.18
ARG NODEJS_IMAGE=node:20.10.0-alpine3.18
FROM --platform=$BUILDPLATFORM $NODEJS_IMAGE AS base
# Install dependencies only when needed

View File

@ -1,8 +1,12 @@
import { useTranslation } from 'next-i18next';
export const PoweredBy = () => {
const { t } = useTranslation('common');
return (
<p className='text-center text-xs text-gray-500 py-5'>
<a href='https://boxyhq.com/' target='_blank' rel='noopener noreferrer'>
Powered by BoxyHQ
{t('boxyhq_powered_by')}
</a>
</p>
);

View File

@ -165,7 +165,7 @@ export const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => {
<div className='flex flex-shrink-0 items-center px-4'>
<Link href='/' className='flex items-center'>
<Image src={Logo} alt='BoxyHQ' width={36} height={36} className='h-8 w-auto' />
<span className='ml-4 text-xl font-bold text-gray-900'>BoxyHQ Admin Portal</span>
<span className='ml-4 text-xl font-bold text-gray-900'>{t('boxyhq_admin_portal')}</span>
</Link>
</div>
<div className='mt-5 h-0 flex-1 overflow-y-auto'>
@ -182,7 +182,7 @@ export const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => {
<div className='flex flex-shrink-0 items-center px-4'>
<Link href='/' className='flex items-center'>
<Image src={Logo} alt='BoxyHQ' width={36} height={36} className='h-8 w-auto' />
<span className='ml-4 text-lg font-bold text-gray-900'>BoxyHQ Admin Portal</span>
<span className='ml-4 text-lg font-bold text-gray-900'>{t('boxyhq_admin_portal')}</span>
</Link>
</div>
<div className='mt-5 flex flex-1 flex-col'>

View File

@ -1,6 +1,7 @@
import ArrowTopRightOnSquareIcon from '@heroicons/react/20/solid/ArrowTopRightOnSquareIcon';
import { useTranslation } from 'next-i18next';
import { LinkOutline } from '@components/LinkOutline';
import { useState } from 'react';
const WellKnownURLs = () => {
const { t } = useTranslation('common');
@ -8,42 +9,57 @@ const WellKnownURLs = () => {
const viewText = t('view');
const downloadText = t('download');
const [view, setView] = useState<'idp-config' | 'auth' | 'saml-fed'>('idp-config');
const links = [
{
title: 'SP Metadata',
title: t('sp_metadata'),
description: t('sp_metadata_description'),
href: '/.well-known/sp-metadata',
buttonText: viewText,
type: 'idp-config',
},
{
title: 'SAML Configuration',
title: t('saml_configuration'),
description: t('sp_config_description'),
href: '/.well-known/saml-configuration',
buttonText: viewText,
type: 'idp-config',
},
{
title: 'SAML Public Certificate',
title: t('saml_public_cert'),
description: t('saml_public_cert_description'),
href: '/.well-known/saml.cer',
buttonText: downloadText,
type: 'idp-config',
},
{
title: 'OpenID Configuration',
title: t('oidc_configuration'),
description: t('oidc_config_description'),
href: '/.well-known/oidc-configuration',
buttonText: viewText,
type: 'idp-config',
},
{
title: t('oidc_discovery'),
description: t('oidc_discovery_description'),
href: '/.well-known/openid-configuration',
buttonText: viewText,
type: 'auth',
},
{
title: 'IdP Metadata',
title: t('idp_metadata'),
description: t('idp_metadata_description'),
href: '/.well-known/idp-metadata',
buttonText: viewText,
type: 'saml-fed',
},
{
title: 'IdP Configuration',
title: t('idp_configuration'),
description: t('idp_config_description'),
href: '/.well-known/idp-configuration',
buttonText: viewText,
type: 'saml-fed',
},
];
@ -54,21 +70,63 @@ const WellKnownURLs = () => {
{t('here_are_the_set_of_uris_you_would_need_access_to')}:
</h2>
</div>
<div className='space-y-3'>
{links.map((link) => (
<LinkCard
key={link.href}
title={link.title}
description={link.description}
href={link.href}
buttonText={link.buttonText}
/>
))}
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6'>
<Tab
isActive={view === 'idp-config'}
setIsActive={() => setView('idp-config')}
title={t('idp_configuration_title')}
description={t('idp_configuration_description')}
label={t('idp_configuration_label')}
/>
<Tab
isActive={view === 'auth'}
setIsActive={() => setView('auth')}
title={t('auth_integration_title')}
description={t('auth_integration_description')}
label={t('auth_integration_label')}
/>
<Tab
isActive={view === 'saml-fed'}
setIsActive={() => setView('saml-fed')}
title={t('saml_fed_configuration_title')}
description={t('saml_fed_configuration_description')}
label={t('saml_fed_configuration_label')}
/>
</div>
<div className='space-y-3 mt-8'>
{links
.filter((link) => link.type === view)
.map((link) => (
<LinkCard
key={link.href}
title={link.title}
description={link.description}
href={link.href}
buttonText={link.buttonText}
/>
))}
</div>
</>
);
};
const Tab = ({ isActive, setIsActive, title, description, label }) => {
return (
<button
type='button'
className={`w-full text-left rounded-lg focus:outline-none focus:ring focus:ring-teal-200 border hover:border-teal-800 p-6${
isActive ? ' bg-teal-50 opacity-100' : ' opacity-50'
}`}
onClick={setIsActive}
aria-label={label}>
<span className='flex flex-col items-end'>
<span className='font-semibold'>{title}</span>
<span>{description}</span>
</span>
</button>
);
};
const LinkCard = ({
title,
description,
@ -89,7 +147,7 @@ const LinkCard = ({
</div>
<div className='mx-4'>
<LinkOutline
className='w-32'
className='btn btn-secondary btn-sm w-32'
href={href}
target='_blank'
rel='noreferrer'

View File

@ -1,6 +1,7 @@
import Link from 'next/link';
import type { Directory } from '@boxyhq/saml-jackson';
import classNames from 'classnames';
import { useTranslation } from 'next-i18next';
const DirectoryTab = ({
directory,
@ -11,32 +12,34 @@ const DirectoryTab = ({
activeTab: string;
setupLinkToken?: string;
}) => {
const { t } = useTranslation('common');
const menus = setupLinkToken
? [
{
name: 'Directory',
name: t('directory'),
href: `/setup/${setupLinkToken}/directory-sync/${directory.id}`,
active: activeTab === 'directory',
},
]
: [
{
name: 'Directory',
name: t('directory'),
href: `/admin/directory-sync/${directory.id}`,
active: activeTab === 'directory',
},
{
name: 'Users',
name: t('users'),
href: `/admin/directory-sync/${directory.id}/users`,
active: activeTab === 'users',
},
{
name: 'Groups',
name: t('groups'),
href: `/admin/directory-sync/${directory.id}/groups`,
active: activeTab === 'groups',
},
{
name: 'Webhook Events',
name: t('webhook_events'),
href: `/admin/directory-sync/${directory.id}/events`,
active: activeTab === 'events',
},

View File

@ -20,7 +20,7 @@ export const AccountLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<Head>
<title>Admin Portal | BoxyHQ</title>
<title>{t('boxyhq_admin_portal')}</title>
<link rel='icon' href='/favicon.ico' />
</Head>
<Sidebar isOpen={isOpen} setIsOpen={setIsOpen} />

View File

@ -6,14 +6,12 @@ import { useRouter } from 'next/router';
import InvalidSetupLinkAlert from '@components/setup-link/InvalidSetupLinkAlert';
import Loading from '@components/Loading';
import useSetupLink from '@lib/ui/hooks/useSetupLink';
import usePortalBranding from '@lib/ui/hooks/usePortalBranding';
import { useTranslation } from 'next-i18next';
import { hexToHsl, darkenHslColor } from '@lib/color';
import { hexToOklch } from '@lib/color';
import { PoweredBy } from '@components/PoweredBy';
export const SetupLinkLayout = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { branding } = usePortalBranding();
const { t } = useTranslation('common');
const { token } = router.query as { token: string };
@ -24,32 +22,29 @@ export const SetupLinkLayout = ({ children }: { children: React.ReactNode }) =>
return <Loading />;
}
const primaryColor = branding?.primaryColor ? hexToHsl(branding?.primaryColor) : null;
const title =
setupLink?.service === 'sso'
? t('configure_sso')
: setupLink?.service === 'dsync'
? t('configure_dsync')
: null;
const titles = {
sso: t('configure_sso'),
dsync: t('configure_dsync'),
};
const title = setupLink?.service ? titles[setupLink?.service] : '';
return (
<>
<Head>
<title>{`${title} - ${branding?.companyName}`}</title>
{branding?.faviconUrl && <link rel='icon' href={branding.faviconUrl} />}
<title>{`${title} - ${setupLink?.companyName}`}</title>
{setupLink?.faviconUrl && <link rel='icon' href={setupLink.faviconUrl} />}
</Head>
{primaryColor && (
<style>{`:root { --p: ${primaryColor}; --pf: ${darkenHslColor(primaryColor, 30)}; }`}</style>
)}
{setupLink?.primaryColor && <style>{`:root { --p: ${hexToOklch(setupLink.primaryColor)}; }`}</style>}
<div className='mx-auto max-w-3xl'>
<div className='flex flex-1 flex-col'>
<div className='top-0 flex h-16 flex-shrink-0 border-b'>
<div className='flex flex-shrink-0 items-center gap-4'>
<Link href={`/setup/${token}`}>
{branding?.logoUrl && (
<Image src={branding.logoUrl} alt={branding.companyName} width={40} height={40} />
{setupLink?.logoUrl && (
<Image src={setupLink.logoUrl} alt={setupLink.companyName || ''} width={40} height={40} />
)}
</Link>
<span className='text-xl font-bold tracking-wide text-gray-900'>{title}</span>

View File

@ -3,9 +3,11 @@ import classNames from 'classnames';
import { useRouter } from 'next/router';
import { successToast, errorToast } from '@components/Toaster';
import { LinkBack } from '@components/LinkBack';
import { useTranslation } from 'next-i18next';
const AddProject = () => {
const router = useRouter();
const { t } = useTranslation('common');
const [loading, setLoading] = useState(false);
const [project, setProject] = useState({
@ -49,7 +51,7 @@ const AddProject = () => {
}
if (data && data.project) {
successToast('Project created successfully.');
successToast(t('retraced_project_created'));
router.replace(`/admin/retraced/projects/${data.project.id}`);
}
};
@ -58,13 +60,15 @@ const AddProject = () => {
<>
<LinkBack href='/admin/retraced/projects' />
<div className='mt-5'>
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>Create Project</h2>
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
{t('create_project')}
</h2>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
<form onSubmit={createProject}>
<div className='flex flex-col space-y-3'>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Project name</span>
<span className='label-text'>{t('project_name')}</span>
</label>
<input
type='text'
@ -76,7 +80,7 @@ const AddProject = () => {
</div>
<div>
<button className={classNames('btn-primary btn', loading ? 'loading' : '')}>
Create Project
{t('create_project')}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import RetracedEventsBrowser from '@retracedhq/logs-viewer';
import useSWR from 'swr';
import { useTranslation } from 'next-i18next';
import type { ApiError, ApiSuccess } from 'types';
import type { Project } from 'types/retraced';
@ -8,6 +9,8 @@ import Loading from '@components/Loading';
import { fetcher } from '@lib/ui/utils';
const LogsViewer = (props: { project: Project; environmentId: string; groupId: string; host: string }) => {
const { t } = useTranslation('common');
const { project, environmentId, groupId, host } = props;
const token = project.tokens.filter((token) => token.environment_id === environmentId)[0];
@ -36,7 +39,7 @@ const LogsViewer = (props: { project: Project; environmentId: string; groupId: s
<RetracedEventsBrowser
host={`${host}/viewer/v1`}
auditLogToken={viewerToken}
header='Audit Logs'
header={t('audit_logs')}
customClass={'text-primary dark:text-white'}
skipViewLogEvent={true}
/>

View File

@ -19,7 +19,7 @@ const ProjectDetails = (props: { project: Project; host?: string }) => {
<>
<div className='form-control mb-5 max-w-xs'>
<label className='label pl-0'>
<span className='label-text'>Environment</span>
<span className='label-text'>{t('environment')}</span>
</label>
<Select
value={selectedIndex}

View File

@ -1,9 +1,10 @@
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { ButtonPrimary } from '@components/ButtonPrimary';
const NextButton = () => {
const router = useRouter();
const { t } = useTranslation('common');
const onClick = () => {
const { idp, step, token } = router.query as { idp: string; step: string; token: string };
@ -20,7 +21,7 @@ const NextButton = () => {
return (
<div>
<ButtonPrimary onClick={onClick}>Next Step</ButtonPrimary>
<ButtonPrimary onClick={onClick}>{t('next_step')}</ButtonPrimary>
</div>
);
};

View File

@ -1,9 +1,10 @@
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { ButtonOutline } from '@components/ButtonOutline';
const PreviousButton = () => {
const router = useRouter();
const { t } = useTranslation('common');
const onClick = () => {
const { idp, step, token } = router.query as { idp: string; step: string; token: string };
@ -20,7 +21,7 @@ const PreviousButton = () => {
return (
<div>
<ButtonOutline onClick={onClick}>Previous Step</ButtonOutline>
<ButtonOutline onClick={onClick}>{t('previous_step')}</ButtonOutline>
</div>
);
};

View File

@ -196,7 +196,7 @@ const CreateSetupLink = ({ service }: { service: SetupLinkService }) => {
<textarea
id={'redirectUrl'}
name='redirectUrl'
placeholder={'Allowed redirect URLs (newline separated)'}
placeholder={t('allowed_redirect_url')}
value={formObj['redirectUrl']}
required
onChange={handleChange}

View File

@ -1,11 +1,12 @@
import { useTranslation } from 'next-i18next';
const InvalidSetupLinkAlert = ({ message }: { message: string }) => {
const { t } = useTranslation('common');
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>
<p className='leading-6'>{t('invalid_setup_link_alert')}</p>
</div>
);
};

View File

@ -23,13 +23,10 @@ export const SetupLinkInfo = ({ setupLink, visible, onClose }: SetupLinkInfoProp
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='Share this link with your customer to setup their service'
/>
<InputWithCopyButton text={setupLink.url} label={t('share_setup_link')} />
</div>
<p className='text-sm'>
This link is valid till{' '}
{t('setup_link_valid_till')}{' '}
<span className={new Date(setupLink.validTill) < new Date() ? 'text-red-400' : ''}>
{new Date(setupLink.validTill).toString()}
</span>

View File

@ -113,11 +113,11 @@ function BlocklyComponent(props) {
/>
</div>
<div className='mb-6 w-full px-3 md:mb-0 md:w-1/3'>
<ButtonPrimary onClick={uploadModel}>Publish Model</ButtonPrimary>
<ButtonPrimary onClick={uploadModel}>{t('publish_model')}</ButtonPrimary>
</div>
<div className='mb-6 w-full px-3 md:mb-0 md:w-1/3'>
<ButtonBase color='secondary' onClick={toggleRetrieveConfirm}>
Retrieve Model
{t('retrieve_model')}
</ButtonBase>
</div>
</div>

View File

@ -287,8 +287,8 @@ test.describe('SCIM /api/scim/v2.0/:directoryId/Groups', () => {
const [directory] = await getDirectory(request, { tenant, product });
// Create some users
const firstUser = await createUser(request, directory, users[0]);
const secondUser = await createUser(request, directory, users[1]);
const firstUser = await createUser(request, directory, users[1]);
const secondUser = await createUser(request, directory, users[2]);
const createdGroup = await createGroup(request, directory, {
...groups[0],

View File

@ -1,5 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPortalBranding } from '@lib/settings';
import { boxyhqHosted } from '@lib/env';
import { getPortalBranding, getProductBranding } from '../utils';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
@ -7,7 +9,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
switch (method) {
case 'GET':
return await handleGET(req, res);
await handleGET(req, res);
break;
default:
res.setHeader('Allow', 'GET');
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
@ -15,12 +18,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({ error: { message } });
res.status(statusCode).json({ error: { message } });
}
};
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
return res.json({ data: await getPortalBranding() });
const { productId } = req.query as { productId: string };
const productOrPortalBranding = boxyhqHosted
? await getProductBranding(productId)
: await getPortalBranding();
res.json({ data: productOrPortalBranding });
};
export default handler;

55
ee/branding/utils.ts Normal file
View File

@ -0,0 +1,55 @@
import jackson from '@lib/jackson';
import { boxyhqHosted } from '@lib/env';
// BoxyHQ branding
export const boxyhqBranding = {
logoUrl: '/logo.png',
faviconUrl: '/favicon.ico',
companyName: 'BoxyHQ',
primaryColor: '#25c2a0',
} as const;
export const getPortalBranding = async () => {
const { brandingController, checkLicense } = await jackson();
// If the licence is not valid, return the default branding
if (!(await checkLicense())) {
return boxyhqBranding;
}
const customBranding = await brandingController?.get();
return {
logoUrl: customBranding?.logoUrl || boxyhqBranding.logoUrl,
primaryColor: customBranding?.primaryColor || boxyhqBranding.primaryColor,
faviconUrl: customBranding?.faviconUrl || boxyhqBranding.faviconUrl,
companyName: customBranding?.companyName || boxyhqBranding.companyName,
};
};
/**
* Get the branding for a specific product.
* If the product does not have a custom branding, return the default branding
* @param productId
* @returns
*/
export const getProductBranding = async (productId: string) => {
const { checkLicense, productController } = await jackson();
if (!(await checkLicense())) {
return boxyhqBranding;
}
if (!boxyhqHosted || !productId) {
return boxyhqBranding;
}
const productBranding = await productController?.get(productId);
return {
logoUrl: productBranding.logoUrl || boxyhqBranding.logoUrl,
faviconUrl: productBranding.faviconUrl || boxyhqBranding.faviconUrl,
companyName: productBranding.companyName || boxyhqBranding.companyName,
primaryColor: productBranding.primaryColor || boxyhqBranding.primaryColor,
};
};

View File

@ -122,6 +122,12 @@ const UpdateApp = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
</label>
<input type='text' className='input-bordered input' defaultValue={app.product} disabled />
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('entity_id')}</span>
</label>
<input type='url' className='input-bordered input' defaultValue={app.entityId} disabled />
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('name')}</span>
@ -148,24 +154,8 @@ const UpdateApp = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
value={app.acsUrl}
/>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('entity_id')}</span>
</label>
<input
type='url'
id='entityId'
className='input-bordered input'
required
onChange={onChange}
value={app.entityId}
/>
</div>
<div className='pt-4'>
<p className='text-base leading-6 text-gray-500'>
You can customize the look and feel Identity Provider selection page by setting following
options:
</p>
<p className='text-base leading-6 text-gray-500'>{t('customize_branding')}:</p>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>

View File

@ -5,8 +5,8 @@ import jackson from '@lib/jackson';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
switch (req.method) {
case 'POST':
await handlePOST(req, res);
case 'PATCH':
await handlePATCH(req, res);
break;
default:
res.setHeader('Allow', 'POST');
@ -19,7 +19,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
};
// Update product configuration
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const { productController } = await jackson();
await productController.upsert(req.body);

View File

@ -14,6 +14,6 @@ patches:
images:
- name: boxyhq/jackson
newTag: 1.15.2
newTag: 1.15.3
- name: boxyhq/mock-saml
newTag: 1.1.7
newTag: 1.2.1

View File

@ -14,6 +14,6 @@ patches:
images:
- name: boxyhq/jackson
newTag: 1.15.2
newTag: 1.15.3
- name: boxyhq/mock-saml
newTag: 1.2.0
newTag: 1.2.1

View File

@ -1,53 +1,6 @@
// Hex code to HSL value
export const hexToHsl = (hexColor: string) => {
const r = parseInt(hexColor.slice(1, 3), 16) / 255;
const g = parseInt(hexColor.slice(3, 5), 16) / 255;
const b = parseInt(hexColor.slice(5, 7), 16) / 255;
import chroma from 'chroma-js';
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
let h = 0,
s: number,
l: number = (max + min) / 2;
// Calculate the HSL values
if (diff === 0) {
h = s = 0;
} else {
s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);
switch (max) {
case r:
h = (g - b) / diff + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / diff + 2;
break;
case b:
h = (r - g) / diff + 4;
break;
}
h /= 6;
}
h = Math.round(h * 360);
s = Math.round(s * 100);
l = Math.round(l * 100);
return `${h} ${s}% ${l}%`;
};
// Darken HSL color by a percentage
export const darkenHslColor = (hslColor: string, percent: number) => {
const [h, s, l] = hslColor.split(' ').map((val) => parseInt(val.replace('%', '')));
if (isNaN(h) || isNaN(s) || isNaN(l)) {
throw new Error(`Invalid HSL color: ${hslColor}`);
}
// Calculate the new lightness value
const newL = (l * (100 - percent)) / 100;
return `${h} ${s}% ${newL}%`;
export const hexToOklch = (hexColor: string) => {
const [l, c, h] = chroma(hexColor).oklch();
return [l, c, h].join(' ');
};

View File

@ -109,3 +109,8 @@ export { apiKeys };
export { jacksonOptions };
export const dsyncGoogleAuthURL = externalUrl + '/api/scim/oauth/authorize';
/**
* Indicates if the Jackson instance is hosted (i.e. not self-hosted)
*/
export const boxyhqHosted = process.env.BOXYHQ_HOSTED === '1';

View File

@ -1,27 +0,0 @@
import jackson from '@lib/jackson';
// BoxyHQ branding
export const boxyhqBranding = {
logoUrl: '/logo.png',
faviconUrl: '/favicon.ico',
companyName: 'BoxyHQ',
primaryColor: '#25c2a0',
} as const;
export const getPortalBranding = async () => {
const { brandingController, checkLicense } = await jackson();
// If the licence is not valid, return the default branding
if (!(await checkLicense())) {
return boxyhqBranding;
}
const customBranding = await brandingController?.get();
return {
logoUrl: customBranding?.logoUrl || boxyhqBranding.logoUrl,
primaryColor: customBranding?.primaryColor || boxyhqBranding.primaryColor,
faviconUrl: customBranding?.faviconUrl || boxyhqBranding.faviconUrl,
companyName: customBranding?.companyName || boxyhqBranding.companyName,
};
};

View File

@ -1,12 +1,15 @@
import useSWR from 'swr';
import type { SetupLink } from '@boxyhq/saml-jackson';
import type { AdminPortalBranding, 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, isLoading } = useSWR<ApiSuccess<SetupLink>, ApiError>(url, fetcher);
const { data, error, isLoading } = useSWR<ApiSuccess<SetupLink & AdminPortalBranding>, ApiError>(
url,
fetcher
);
return {
setupLink: data?.data,

View File

@ -134,7 +134,8 @@
"sp_metadata_description": "The metadata file that your customers who use federated management systems like OpenAthens and Shibboleth will need to configure your service.",
"sp_config_description": "The configuration setup guide that your customers will need to refer to when setting up SAML application with their Identity Provider.",
"saml_public_cert_description": "The SAML Public Certificate if you want to enable encryption with your Identity Provider.",
"oidc_config_description": "Our OpenID configuration URI which your customers will need if they are connecting via OAuth 2.0 or Open ID Connect.",
"oidc_config_description": "URIs that your customers will need to set up the OIDC app on the Identity Provider.",
"oidc_discovery_description": "Our OpenID well known URI which your customers will need if they are authenticating via OAuth 2.0 or Open ID Connect.",
"idp_metadata_description": "The metadata file that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"idp_config_description": "The configuration setup guide that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"no_users_found": "No users found",
@ -162,6 +163,7 @@
"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",
"response": "Response",
"assertion_signature": "Assertion Signature",
@ -169,6 +171,10 @@
"assertion_encryption": "Assertion Encryption",
"sp_saml_config_title": "Service Provider (SP) SAML Configuration",
"sp_saml_config_description": "Your Identity Provider (IdP) will ask for the following information while configuring the SAML application. Share this information with your IT administrator.",
"refer_to_provider_instructions": "Refer to our <guideLink>guides</guideLink> for provider specific instructions.",
"sp_download_our_public_cert": "If you want to encrypt the assertion, you can <downloadLink>download our public certificate.</downloadLink> Otherwise select the Unencrypted option.",
"sp_oidc_config_title": "Service Provider (SP) OIDC Configuration",
"sp_oidc_config_description": "Your Identity Provider (IdP) will ask for the following information while configuring the OIDC application. Share this information with your IT administrator.",
"password": "Password",
"sign_in": "Sign In",
"email_required": "Email is required",
@ -210,5 +216,52 @@
"directory_domain": "Directory Domain",
"dsync_google_auth_url": "The URL that you will need to authorize the application to access your Google Directory.",
"show_secret": "Show secret",
"hide_secret": "Hide secret"
"hide_secret": "Hide secret",
"directory": "Directory",
"users": "Users",
"groups": "Groups",
"webhook_events": "Webhook Events",
"sp_metadata": "SP Metadata",
"saml_configuration": "SAML Configuration",
"saml_public_cert": "SAML Public Certificate",
"oidc_configuration": "OpenID Configuration",
"oidc_discovery": "OpenID Connect Discovery",
"idp_metadata": "IdP Metadata",
"idp_configuration": "IdP Configuration",
"idp_configuration_title": "Identity Provider Configuration",
"idp_configuration_description": "Links for SAML/OIDC IdP setup",
"idp_configuration_label": "Identity Provider Configuration links",
"auth_integration_title": "Auth integration",
"auth_integration_description": "Links for OAuth 2.0/OpenID Connect auth",
"auth_integration_label": "Auth integration links",
"saml_fed_configuration_title": "SAML Federation",
"saml_fed_configuration_description": "Links for SAML Federation app setup",
"saml_fed_configuration_label": "SAML Federation links",
"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",
"group_or_tenant": "Group (Tenant)",
"id": "Id",
"created_at": "Created At",
"previous_step": "Previous Step",
"next_step": "Next Step",
"boxyhq_powered_by": "Powered by BoxyHQ",
"publish_model": "Publish Model",
"retrieve_model": "Retrieve Model",
"guides": "guides",
"learn_to_enable_auth_methods": "Please visit <docLink>BoxyHQ documentation</docLink> to learn how to enable the Magic Link or Email/Password authentication methods.",
"advanced_sp_saml_configuration": "Advanced Service Provider (SP) SAML Configuration",
"select_identity_provider": "Select Identity Provider",
"configure_identity_provider": "Configure {{provider}}",
"change_identity_provider": "Change Identity Provider",
"invalid_request_try_again": "Invalid request. Please try again.",
"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.",
"customize_branding": "You can customize the look and feel Identity Provider selection page by setting following options"
}

View File

@ -36,6 +36,10 @@ module.exports = {
source: '/.well-known/openid-configuration',
destination: '/api/well-known/openid-configuration',
},
{
source: '/.well-known/oidc-configuration',
destination: '/well-known/oidc-configuration',
},
{
source: '/.well-known/sp-metadata',
destination: '/api/well-known/sp-metadata',

1140
npm/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -39,26 +39,26 @@
"coverage-map": "map.js"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "3.462.0",
"@aws-sdk/credential-providers": "3.462.0",
"@aws-sdk/util-dynamodb": "3.462.0",
"@aws-sdk/client-dynamodb": "3.484.0",
"@aws-sdk/credential-providers": "3.484.0",
"@aws-sdk/util-dynamodb": "3.484.0",
"@boxyhq/error-code-mnemonic": "0.1.1",
"@boxyhq/metrics": "0.2.6",
"@boxyhq/saml20": "1.3.2",
"@googleapis/admin": "13.0.0",
"axios": "1.6.2",
"@boxyhq/saml20": "1.3.3",
"@googleapis/admin": "14.0.0",
"axios": "1.6.3",
"encoding": "0.1.13",
"jose": "5.1.2",
"jose": "5.2.0",
"lodash": "4.17.21",
"mixpanel": "0.18.0",
"mongodb": "6.3.0",
"mssql": "10.0.1",
"mysql2": "3.6.5",
"node-forge": "1.3.1",
"openid-client": "5.6.1",
"openid-client": "5.6.2",
"pg": "8.11.3",
"redis": "4.6.11",
"reflect-metadata": "0.1.13",
"redis": "4.6.12",
"reflect-metadata": "0.2.1",
"ripemd160": "2.0.2",
"typeorm": "0.3.17",
"xml2js": "0.6.2",
@ -67,7 +67,7 @@
"devDependencies": {
"@faker-js/faker": "8.3.1",
"@types/lodash": "4.14.202",
"@types/node": "20.10.1",
"@types/node": "20.10.6",
"@types/sinon": "17.0.2",
"@types/tap": "15.0.11",
"cross-env": "7.0.3",
@ -75,9 +75,9 @@
"nock": "13.4.0",
"sinon": "17.0.1",
"tap": "18.6.1",
"ts-node": "10.9.1",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "5.3.2"
"typescript": "5.3.3"
},
"engines": {
"node": ">=16",

View File

@ -5,6 +5,7 @@ import {
PaginationParams,
SetupLinkService,
Index,
JacksonOption,
} from '../typings';
import * as dbutils from '../db/utils';
import { IndexNames, validateTenantAndProduct, validateRedirectUrl, extractRedirectUrls } from './utils';
@ -57,9 +58,11 @@ const throwIfInvalidService = (service: string) => {
*/
export class SetupLinkController {
setupLinkStore: Storable;
opts: JacksonOption;
constructor({ setupLinkStore }) {
constructor({ setupLinkStore, opts }) {
this.setupLinkStore = setupLinkStore;
this.opts = opts;
}
/**
@ -183,7 +186,7 @@ export class SetupLinkController {
redirectUrl,
defaultRedirectUrl,
validTill: +new Date(new Date().setDate(new Date().getDate() + 3)),
url: `${process.env.NEXTAUTH_URL}/setup/${token}`,
url: `${this.opts.externalUrl}/setup/${token}`,
};
await this.setupLinkStore.put(

View File

@ -4,8 +4,8 @@ import saml20 from '@boxyhq/saml20';
import xmlbuilder from 'xmlbuilder';
import { getDefaultCertificate } from '../saml/x509';
// Service Provider SAML Configuration
export class SPSAMLConfig {
// Service Provider SSO Configuration
export class SPSSOConfig {
constructor(private opts: JacksonOption) {}
private get acsUrl(): string {
@ -28,6 +28,10 @@ export class SPSAMLConfig {
return 'RSA-SHA256';
}
public get oidcRedirectURI(): string {
return `${this.opts.externalUrl}${this.opts.oidcPath}`;
}
public async get(): Promise<{
acsUrl: string;
entityId: string;

View File

@ -36,6 +36,13 @@ export class DirectoryGroups {
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
const { displayName, groupId } = body as { displayName: string; groupId?: string };
// Check if the group already exists
const { data: groups } = await this.groups.search(displayName, directory.id);
if (groups && groups.length > 0) {
return this.respondWithError({ code: 409, message: 'Group already exists' });
}
const { data: group } = await this.groups.create({
directoryId: directory.id,
name: displayName,
@ -220,7 +227,10 @@ export class DirectoryGroups {
private respondWithError(error: ApiError | null) {
return {
status: error ? error.code : 500,
data: null,
data: {
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
detail: error ? error.message : 'Internal Server Error',
},
};
}
@ -294,9 +304,6 @@ export class DirectoryGroups {
});
}
return {
status: 404,
data: {},
};
return this.respondWithError({ code: 404, message: 'Not found' });
}
}

View File

@ -31,6 +31,13 @@ export class DirectoryUsers {
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
const { id, first_name, last_name, email, active } = extractStandardUserAttributes(body);
// Check if the user already exists
const { data: users } = await this.users.search(email, directory.id);
if (users && users.length > 0) {
return this.respondWithError({ code: 409, message: 'User already exists' });
}
const { data: user } = await this.users.create({
directoryId: directory.id,
id,
@ -167,7 +174,10 @@ export class DirectoryUsers {
private respondWithError(error: ApiError | null) {
return {
status: error ? error.code : 500,
data: null,
data: {
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
detail: error ? error.message : 'Internal Server Error',
},
};
}
@ -246,9 +256,6 @@ export class DirectoryUsers {
});
}
return {
status: 404,
data: {},
};
return this.respondWithError({ code: 404, message: 'Not found' });
}
}

View File

@ -146,6 +146,15 @@ export class App {
const id = appID(tenant, product);
const foundApp = await this.store.get(id);
if (foundApp) {
throw new JacksonError(
'Cannot create another app for the same tenant and product. An app already exists.',
400
);
}
const app: SAMLFederationApp = {
id,
name,
@ -215,7 +224,7 @@ export class App {
throw new JacksonError('SAML Federation app not found', 404);
}
return app;
return app as SAMLFederationApp;
}
if ('tenant' in params && 'product' in params) {
@ -225,7 +234,7 @@ export class App {
throw new JacksonError('SAML Federation app not found', 404);
}
return app;
return app as SAMLFederationApp;
}
throw new JacksonError('Provide either the `id` or `tenant` and `product` to get the app', 400);
@ -327,11 +336,6 @@ export class App {
* in: formData
* required: false
* type: string
* - name: entityId
* description: Entity ID
* in: formData
* required: false
* type: string
* - name: logoUrl
* description: Logo URL
* in: formData
@ -393,10 +397,6 @@ export class App {
toUpdate['acsUrl'] = params.acsUrl;
}
if ('entityId' in params) {
toUpdate['entityId'] = params.entityId;
}
if ('logoUrl' in params) {
toUpdate['logoUrl'] = params.logoUrl || null;
}
@ -411,7 +411,7 @@ export class App {
if (Object.keys(toUpdate).length === 0) {
throw new JacksonError(
'Please provide at least one of the following parameters: acsUrl, entityId, name, logoUrl, faviconUrl, primaryColor',
'Please provide at least one of the following parameters: acsUrl, name, logoUrl, faviconUrl, primaryColor',
400
);
}

View File

@ -1,6 +1,6 @@
import { JacksonError } from '../../controller/error';
import { throwIfInvalidLicense } from '../common/checkLicense';
import type { Storable, Product, JacksonOption } from '../../typings';
import type { Storable, JacksonOption, ProductConfig } from '../../typings';
export class ProductController {
private productStore: Storable;
@ -11,32 +11,39 @@ export class ProductController {
this.opts = opts;
}
public async get(productId: string) {
public async get(productId: string): Promise<ProductConfig> {
await throwIfInvalidLicense(this.opts.boxyhqLicenseKey);
const product = (await this.productStore.get(productId)) as Product;
const productConfig = (await this.productStore.get(productId)) as ProductConfig;
if (!product) {
throw new JacksonError('Product not found.', 404);
if (!productConfig) {
console.error(`Product config not found for ${productId}`);
}
return product;
return {
...productConfig,
id: productId,
name: productConfig?.name || null,
teamId: productConfig?.teamId || null,
teamName: productConfig?.teamName || null,
logoUrl: productConfig?.logoUrl || null,
faviconUrl: productConfig?.faviconUrl || null,
companyName: productConfig?.companyName || null,
primaryColor: productConfig?.primaryColor || '#25c2a0',
};
}
public async upsert(params: Partial<Product> & { id: string }) {
public async upsert(params: Partial<ProductConfig> & { id: string }) {
await throwIfInvalidLicense(this.opts.boxyhqLicenseKey);
if (!('id' in params)) {
throw new JacksonError('Provide a product id', 400);
}
const product = await this.productStore.get(params.id);
const productConfig = (await this.productStore.get(params.id)) as ProductConfig;
if (!product) {
await this.productStore.put(params.id, { ...params });
return;
}
const toUpdate = productConfig ? { ...productConfig, ...params } : params;
await this.productStore.put(product.id, { ...product, ...params });
await this.productStore.put(params.id, toUpdate);
}
}

View File

@ -9,7 +9,7 @@ import { HealthCheckController } from './controller/health-check';
import { LogoutController } from './controller/logout';
import initDirectorySync from './directory-sync';
import { OidcDiscoveryController } from './controller/oidc-discovery';
import { SPSAMLConfig } from './controller/sp-config';
import { SPSSOConfig } from './controller/sp-config';
import { SetupLinkController } from './controller/setup-link';
import { AnalyticsController } from './controller/analytics';
import * as x509 from './saml/x509';
@ -66,7 +66,7 @@ export const controllers = async (
setupLinkController: SetupLinkController;
directorySyncController: IDirectorySyncController;
oidcDiscoveryController: OidcDiscoveryController;
spConfig: SPSAMLConfig;
spConfig: SPSSOConfig;
samlFederatedController: ISAMLFederationController;
brandingController: IBrandingController;
checkLicense: () => Promise<boolean>;
@ -93,7 +93,7 @@ export const controllers = async (
const adminController = new AdminController({ connectionStore, samlTracer });
const healthCheckController = new HealthCheckController({ healthCheckStore });
await healthCheckController.init();
const setupLinkController = new SetupLinkController({ setupLinkStore });
const setupLinkController = new SetupLinkController({ setupLinkStore, opts });
const productController = new ProductController({ productStore, opts });
if (!opts.noAnalytics) {
@ -124,7 +124,7 @@ export const controllers = async (
});
const oidcDiscoveryController = new OidcDiscoveryController({ opts });
const spConfig = new SPSAMLConfig(opts);
const spConfig = new SPSSOConfig(opts);
const directorySyncController = await initDirectorySync({ db, opts, eventController });
// Enterprise Features

View File

@ -505,7 +505,8 @@ export type OIDCErrorCodes =
| 'request_uri_not_supported'
| 'registration_not_supported';
export interface ISPSAMLConfig {
export interface ISPSSOConfig {
oidcRedirectURI: string;
get(): Promise<{
acsUrl: string;
entityId: string;
@ -577,9 +578,13 @@ export type GetByProductParams = {
export type SortOrder = 'ASC' | 'DESC';
export type Product = {
export interface ProductConfig {
id: string;
name: string;
teamId: string;
teamName: string;
};
name: string | null;
teamId: string | null;
teamName: string | null;
logoUrl: string | null;
primaryColor: string | null;
faviconUrl: string | null;
companyName: string | null;
}

View File

@ -39,6 +39,26 @@ const users = [
groups: [],
active: true,
},
{
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
userName: 'david@example.com',
name: {
givenName: 'David',
familyName: 'Phillips',
},
emails: [
{
primary: true,
value: 'david@example.com',
type: 'work',
},
],
displayName: 'David Phillips',
locale: 'en-US',
externalId: '00u1b1hpjh91GaknX547',
groups: [],
active: true,
},
];
export default users;

View File

@ -41,6 +41,11 @@ tap.test('Directory groups /', async (t) => {
const { data } = await directorySync.requests.handle(groupsRequest.create(directory, groups[0]));
createdGroup = data;
// Creating same group again should return 409
const { status } = await directorySync.requests.handle(groupsRequest.create(directory, groups[0]));
t.equal(status, 409);
});
t.afterEach(async () => {

View File

@ -41,6 +41,11 @@ tap.test('Directory users /', async (t) => {
const { data } = await directorySync.requests.handle(requests.create(directory, users[0]));
createdUser = data;
// Creating same user again should return 409
const { status } = await directorySync.requests.handle(requests.create(directory, users[0]));
t.equal(status, 409);
});
t.afterEach(async () => {

View File

@ -63,6 +63,7 @@ tap.test('Webhook Events /', async (t) => {
t.test('Webhook Events / ', async (t) => {
t.afterEach(async () => {
await directorySync.webhookLogs.deleteAll(directory.id);
await directorySync.users.deleteAll(directory.id);
});
t.test("Should be able to get the directory's webhook", async (t) => {

28688
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "jackson",
"version": "1.15.2",
"version": "1.15.5",
"private": true,
"description": "SAML 2.0 service",
"keywords": [
@ -54,35 +54,36 @@
"format": "prettier --write ."
},
"dependencies": {
"@boxyhq/metrics": "0.2.5",
"@boxyhq/react-ui": "3.3.21",
"@boxyhq/metrics": "0.2.6",
"@boxyhq/react-ui": "3.3.25",
"@boxyhq/saml-jackson": "file:npm",
"@heroicons/react": "2.0.18",
"@retracedhq/logs-viewer": "2.5.2",
"@retracedhq/retraced": "0.7.2",
"@heroicons/react": "2.1.1",
"@retracedhq/logs-viewer": "2.7.0",
"@retracedhq/retraced": "0.7.4",
"@tailwindcss/typography": "0.5.10",
"axios": "1.6.2",
"blockly": "10.2.2",
"classnames": "2.3.2",
"axios": "1.6.3",
"blockly": "10.3.0",
"chroma-js": "2.4.2",
"classnames": "2.5.1",
"cors": "2.8.5",
"daisyui": "3.9.4",
"daisyui": "4.4.24",
"i18next": "22.5.1",
"medium-zoom": "1.1.0",
"micromatch": "4.0.5",
"next": "14.0.3",
"next": "14.0.4",
"next-auth": "4.24.5",
"next-i18next": "13.3.0",
"next-mdx-remote": "4.4.1",
"nodemailer": "6.9.7",
"nodemailer": "6.9.8",
"raw-body": "2.5.2",
"react": "18.2.0",
"react-daisyui": "4.1.2",
"react-daisyui": "5.0.0",
"react-dom": "18.2.0",
"react-i18next": "12.3.1",
"react-syntax-highlighter": "15.5.0",
"remark-gfm": "3.0.1",
"request-ip": "3.3.0",
"sharp": "0.33.0",
"sharp": "0.32.6",
"swr": "2.2.4"
},
"devDependencies": {
@ -90,22 +91,23 @@
"@playwright/test": "1.40.1",
"@types/cors": "2.8.17",
"@types/micromatch": "4.0.6",
"@types/node": "20.8.8",
"@types/react": "18.2.39",
"@typescript-eslint/eslint-plugin": "6.13.1",
"@typescript-eslint/parser": "6.13.1",
"@types/node": "20.10.1",
"@types/react": "18.2.46",
"@typescript-eslint/eslint-plugin": "6.17.0",
"@typescript-eslint/parser": "6.17.0",
"autoprefixer": "10.4.16",
"cross-env": "7.0.3",
"env-cmd": "10.1.0",
"eslint": "8.54.0",
"eslint-config-next": "14.0.3",
"eslint-config-prettier": "9.0.0",
"postcss": "8.4.31",
"prettier": "3.1.0",
"prettier-plugin-tailwindcss": "0.5.7",
"release-it": "17.0.0",
"eslint": "8.56.0",
"eslint-config-next": "14.0.4",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-i18next": "6.0.3",
"postcss": "8.4.32",
"prettier": "3.1.1",
"prettier-plugin-tailwindcss": "0.5.10",
"release-it": "17.0.1",
"swagger-jsdoc": "6.2.8",
"tailwindcss": "3.3.5",
"tailwindcss": "3.4.0",
"ts-node": "10.9.1",
"tsconfig-paths": "4.2.0",
"typescript": "5.2.2"

View File

@ -18,6 +18,7 @@ const unauthenticatedRoutes = [
'/admin/auth/login',
'/admin/auth/idp-login',
'/well-known/saml-configuration',
'/well-known/oidc-configuration',
'/well-known/idp-configuration',
'/oauth/jwks',
'/idp/select',

View File

@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
import Link from 'next/link';
import Image from 'next/image';
import { useSession, getCsrfToken, signIn, SessionProvider } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import { useTranslation, Trans } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { errorToast, successToast } from '@components/Toaster';
import { ButtonOutline } from '@components/ButtonOutline';
@ -114,7 +114,9 @@ const Login = ({
<div className='flex justify-center'>
<Image src='/logo.png' alt='BoxyHQ logo' width={50} height={50} />
</div>
<h2 className='text-center text-3xl font-extrabold text-gray-900'>BoxyHQ Admin Portal</h2>
<h2 className='text-center text-3xl font-extrabold text-gray-900'>
{t('boxyhq_admin_portal')}
</h2>
<p className='text-center text-sm text-gray-600'>{t('boxyhq_tagline')}</p>
</div>
@ -170,17 +172,21 @@ const Login = ({
{/* No login methods enabled */}
{!isEmailPasswordEnabled && !isMagicLinkEnabled && (
<div className='mt-10 text-center font-medium text-gray-600'>
<p>
Please visit&nbsp;
<a
href='https://boxyhq.com/docs/admin-portal/overview'
target='_blank'
rel='noopener noreferrer'
className='underline underline-offset-2'>
BoxyHQ documentation
</a>
&nbsp;to learn how to enable the Magic Link or Email/Password authentication methods.
</p>
<Trans
i18nKey='learn_to_enable_auth_methods'
t={t}
components={{
docLink: (
<a
href='https://boxyhq.com/docs/admin-portal/overview'
target='_blank'
rel='noopener noreferrer'
className='underline underline-offset-2'>
{t('documentation')}
</a>
),
}}
/>
</div>
)}

View File

@ -9,6 +9,7 @@ import ErrorMessage from '@components/Error';
import { LinkBack } from '@components/LinkBack';
import { Select } from 'react-daisyui';
import { retracedOptions } from '@lib/env';
import { useTranslation } from 'next-i18next';
const LogsViewer = dynamic(() => import('@components/retraced/LogsViewer'), {
ssr: false,
@ -20,6 +21,7 @@ export interface Props {
const Events: NextPage<Props> = ({ host }: Props) => {
const router = useRouter();
const { t } = useTranslation('common');
const [environment, setEnvironment] = useState('');
const [group, setGroup] = useState('');
@ -62,7 +64,7 @@ const Events: NextPage<Props> = ({ host }: Props) => {
<div className='flex space-x-2'>
<div className='form-control max-w-xs'>
<label className='label pl-0'>
<span className='label-text'>Environment</span>
<span className='label-text'>{t('environment')}</span>
</label>
{project ? (
<Select
@ -81,7 +83,7 @@ const Events: NextPage<Props> = ({ host }: Props) => {
</div>
<div className='form-control max-w-xs'>
<label className='label pl-0'>
<span className='label-text'>Group (Tenant)</span>
<span className='label-text'>{t('group_or_tenant')}</span>
</label>
{groups ? (
<Select

View File

@ -32,7 +32,7 @@ const ProjectList: NextPage = () => {
return (
<div>
<div className='mb-5 flex items-center justify-between'>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>Projects</h2>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{t('projects')}</h2>
<LinkPrimary Icon={PlusIcon} href={'/admin/retraced/projects/new'}>
{t('new_project')}
</LinkPrimary>
@ -46,16 +46,16 @@ const ProjectList: NextPage = () => {
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr>
<th scope='col' className='px-6 py-3'>
Name
{t('name')}
</th>
<th scope='col' className='px-6 py-3'>
Id
{t('id')}
</th>
<th scope='col' className='px-6 py-3'>
Created At
{t('created_at')}
</th>
<th scope='col' className='px-6 py-3'>
Actions
{t('actions')}
</th>
</tr>
</thead>

View File

@ -58,7 +58,7 @@ const SAMLTraceInspector: NextPage = () => {
<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'>TraceID:</span>
<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'>

View File

@ -1,5 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { boxyhqHosted } from '@lib/env';
import { getPortalBranding, getProductBranding } from '@ee/branding/utils';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
@ -26,12 +28,14 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { token } = req.query as { token: string };
const setupLink = await setupLinkController.getByToken(token);
const branding = boxyhqHosted ? await getProductBranding(setupLink.product) : await getPortalBranding();
return res.json({
data: {
...setupLink,
tenant: undefined,
product: undefined,
...branding,
},
});
};

View File

@ -2,15 +2,16 @@ import { useEffect, useRef, useState } from 'react';
import getRawBody from 'raw-body';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import type { OIDCSSORecord, Product, SAMLSSORecord } from '@boxyhq/saml-jackson';
import type { OIDCSSORecord, ProductConfig, SAMLSSORecord } from '@boxyhq/saml-jackson';
import type { InferGetServerSidePropsType } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import jackson from '@lib/jackson';
import Head from 'next/head';
import { hexToHsl, darkenHslColor } from '@lib/color';
import { hexToOklch } from '@lib/color';
import Image from 'next/image';
import { PoweredBy } from '@components/PoweredBy';
import { getPortalBranding } from '@lib/settings';
import { getPortalBranding, getProductBranding } from '@ee/branding/utils';
import { boxyhqHosted } from '@lib/env';
interface Connection {
name: string;
@ -26,7 +27,7 @@ export default function ChooseIdPConnection({
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { t } = useTranslation('common');
const primaryColor = hexToHsl(branding.primaryColor);
const primaryColor = hexToOklch(branding.primaryColor);
const title = authFlow === 'sp-initiated' ? t('select_an_idp') : t('select_an_app');
const selectors = {
@ -42,9 +43,7 @@ export default function ChooseIdPConnection({
{branding?.faviconUrl && <link rel='icon' href={branding.faviconUrl} />}
</Head>
{primaryColor && (
<style>{`:root { --p: ${primaryColor}; --pf: ${darkenHslColor(primaryColor, 30)}; }`}</style>
)}
{primaryColor && <style>{`:root { --p: ${primaryColor}; }`}</style>}
{branding?.logoUrl && (
<div className='flex justify-center'>
@ -55,7 +54,7 @@ export default function ChooseIdPConnection({
{authFlow in selectors ? (
selectors[authFlow]
) : (
<p className='text-center text-sm text-slate-600'>Invalid request. Please try again.</p>
<p className='text-center text-sm text-slate-600'>{t('invalid_request_try_again')}</p>
)}
</div>
<div className='my-4'>
@ -100,10 +99,7 @@ const IdpSelector = ({ connections }: { connections: Connection[] }) => {
);
})}
</ul>
<p className='text-center text-sm text-slate-600'>
Choose an Identity Provider to continue. If you don&apos;t see your Identity Provider, please contact
your administrator.
</p>
<p className='text-center text-sm text-slate-600'>{t('choose_an_identity_provider_to_continue')}</p>
</>
);
};
@ -145,7 +141,7 @@ const AppSelector = ({
};
if (!SAMLResponse) {
return <p className='text-center text-sm text-slate-600'>No SAMLResponse found. Please try again.</p>;
return <p className='text-center text-sm text-slate-600'>{t('no_saml_response_try_again')}</p>;
}
return (
@ -173,9 +169,7 @@ const AppSelector = ({
})}
</ul>
</form>
<p className='text-center text-sm text-slate-600'>
Choose an app to continue. If you don&apos;t see your app, please contact your administrator.
</p>
<p className='text-center text-sm text-slate-600'>{t('choose_an_app_to_continue')}</p>
</>
);
};
@ -233,7 +227,7 @@ export const getServerSideProps = async ({ query, locale, req }) => {
}
// Get the branding to use for the IdP selector screen
let branding = await getPortalBranding();
let branding = boxyhqHosted && product ? await getProductBranding(product) : await getPortalBranding();
// For SAML federated requests, use the branding from the SAML federated app
if (samlFederationApp && (await checkLicense())) {
@ -274,21 +268,23 @@ export const getServerSideProps = async ({ query, locale, req }) => {
};
}
// Fetch products to display the product name instead of the product ID
const products = (await Promise.allSettled(
connectionsTransformed.map((connection) => productController.get(connection.product))
)) as PromiseFulfilledResult<Product>[];
if (boxyhqHosted) {
// Fetch products to display the product name instead of the product ID
const products = (await Promise.allSettled(
connectionsTransformed.map((connection) => productController.get(connection.product))
)) as PromiseFulfilledResult<ProductConfig>[];
connectionsTransformed = connectionsTransformed.map((connection, index) => {
if (products[index].status === 'fulfilled') {
return {
...connection,
product: products[index].value.name || connection.product,
};
}
connectionsTransformed = connectionsTransformed.map((connection, index) => {
if (products[index].status === 'fulfilled') {
return {
...connection,
product: products[index].value.name || connection.product,
};
}
return connection;
});
return connection;
});
}
return {
props: {

View File

@ -3,6 +3,7 @@ import type { NextPage } from 'next';
import { useRouter } from 'next/router';
import Loading from '@components/Loading';
import useSetupLink from '@lib/ui/hooks/useSetupLink';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
const SetupLinkIndexPage: NextPage = () => {
const router = useRouter();
@ -29,4 +30,14 @@ const SetupLinkIndexPage: NextPage = () => {
return null;
};
export const getServerSideProps = async (context) => {
const { locale } = context;
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
};
};
export default SetupLinkIndexPage;

View File

@ -10,6 +10,7 @@ import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'nex
import type { MDXRemoteSerializeResult } from 'next-mdx-remote';
import { ArrowsRightLeftIcon } from '@heroicons/react/24/outline';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useTranslation } from 'next-i18next';
import jackson from '@lib/jackson';
import { jacksonOptions } from '@lib/env';
@ -24,10 +25,12 @@ import SelectIdentityProviders from '@components/setup-link-instructions/SelectI
type NewConnectionProps = InferGetServerSidePropsType<typeof getServerSideProps>;
const AdvancedSPConfigLink = () => {
const { t } = useTranslation('common');
return (
<div className='py-2'>
<Link href='/.well-known/saml-configuration' target='_blank' className='underline-offset-4'>
<span className='text-xs'>Advanced Service Provider (SP) SAML Configuration</span>
<span className='text-xs'>{t('advanced_sp_saml_configuration')}</span>
</Link>
</div>
);
@ -72,6 +75,8 @@ const NewConnection = ({
publicCertUrl,
oidcCallbackUrl,
}: NewConnectionProps) => {
const { t } = useTranslation('common');
const linkSelectIdp = { pathname: '/setup/[token]/sso-connection/new', query: { token: setupLinkToken } };
const scope = {
@ -108,9 +113,9 @@ const NewConnection = ({
}
progress = (100 / selectedIdP.stepCount) * parseInt(step);
heading = `Configure ${selectedIdP?.name}`;
heading = t('configure_identity_provider', { provider: selectedIdP.name });
} else {
heading = 'Select Identity Provider';
heading = t('select_identity_provider');
}
return (
@ -121,7 +126,7 @@ const NewConnection = ({
{source && (
<Link className='btn btn-xs h-0' href={linkSelectIdp}>
<ArrowsRightLeftIcon className='w-5 h-5' />
Change Identity Provider
{t('change_identity_provider')}
</Link>
)}
</div>

View File

@ -0,0 +1,60 @@
import type { NextPage, InferGetStaticPropsType } from 'next';
import React from 'react';
import { useTranslation, Trans } from 'next-i18next';
import jackson from '@lib/jackson';
import { InputWithCopyButton } from '@components/ClipboardButton';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { Toaster } from '@components/Toaster';
const SPConfig: NextPage<InferGetStaticPropsType<typeof getServerSideProps>> = ({ oidcRedirectURI }) => {
const { t } = useTranslation('common');
return (
<>
<Toaster />
<div className='mt-10 flex w-full justify-center px-5'>
<div className='w-full rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-1/2'>
<div className='flex flex-col space-y-3'>
<h2 className='font-bold text-gray-700 md:text-xl'>{t('sp_oidc_config_title')}</h2>
<p className='text-sm leading-6 text-gray-800'>{t('sp_oidc_config_description')}</p>
<p className='text-sm leading-6 text-gray-600'>
<Trans
i18nKey='refer_to_provider_instructions'
t={t}
components={{
guideLink: (
<a
href='https://boxyhq.com/docs/jackson/sso-providers'
target='_blank'
rel='noreferrer'
className='underline underline-offset-4'>
{t('guides')}
</a>
),
}}
/>
</p>
</div>
<div className='mt-6 flex flex-col gap-6'>
<div className='form-control w-full'>
<InputWithCopyButton text={oidcRedirectURI} label={t('sp_oidc_redirect_url')} />
</div>
</div>
</div>
</div>
</>
);
};
export const getServerSideProps = async ({ locale }) => {
const { spConfig } = await jackson();
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
oidcRedirectURI: spConfig.oidcRedirectURI,
},
};
};
export default SPConfig;

View File

@ -1,7 +1,7 @@
import type { NextPage, InferGetStaticPropsType } from 'next';
import Link from 'next/link';
import React from 'react';
import { useTranslation } from 'next-i18next';
import { useTranslation, Trans } from 'next-i18next';
import jackson from '@lib/jackson';
import { InputWithCopyButton } from '@components/ClipboardButton';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
@ -19,15 +19,21 @@ const SPConfig: NextPage<InferGetStaticPropsType<typeof getServerSideProps>> = (
<h2 className='font-bold text-gray-700 md:text-xl'>{t('sp_saml_config_title')}</h2>
<p className='text-sm leading-6 text-gray-800'>{t('sp_saml_config_description')}</p>
<p className='text-sm leading-6 text-gray-600'>
Refer to our&nbsp;
<a
href='https://boxyhq.com/docs/jackson/sso-providers'
target='_blank'
rel='noreferrer'
className='underline underline-offset-4'>
guides
</a>
&nbsp;for provider specific instructions.
<Trans
i18nKey='refer_to_provider_instructions'
t={t}
components={{
guideLink: (
<a
href='https://boxyhq.com/docs/jackson/sso-providers'
target='_blank'
rel='noreferrer'
className='underline underline-offset-4'>
{t('guides')}
</a>
),
}}
/>
</p>
</div>
<div className='mt-6 flex flex-col gap-6'>
@ -67,11 +73,20 @@ const SPConfig: NextPage<InferGetStaticPropsType<typeof getServerSideProps>> = (
{t('assertion_encryption')}
</label>
<p className='text-sm'>
If you want to encrypt the assertion, you can&nbsp;
<Link href='/.well-known/saml.cer' className='underline underline-offset-4' target='_blank'>
download our public certificate.
</Link>
&nbsp;Otherwise select the Unencrypted option.
<Trans
i18nKey='sp_download_our_public_cert'
t={t}
components={{
downloadLink: (
<Link
href='/.well-known/saml.cer'
className='underline underline-offset-4'
target='_blank'>
{t('download')}
</Link>
),
}}
/>
</p>
</div>
</div>

View File

@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Enterprise SSO & Directory Sync",
"version": "1.14.2",
"version": "1.15.4",
"description": "This is the API documentation for SAML Jackson service.",
"termsOfService": "https://boxyhq.com/terms.html",
"contact": {
@ -1262,13 +1262,6 @@
"required": false,
"type": "string"
},
{
"name": "entityId",
"description": "Entity ID",
"in": "formData",
"required": false,
"type": "string"
},
{
"name": "logoUrl",
"description": "Logo URL",