mirror of https://github.com/boxyhq/jackson.git
Merge branch 'main' into feat/audit-logs-saas-app
This commit is contained in:
commit
48e731416f
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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'>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
55
lib/color.ts
55
lib/color.ts
|
@ -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(' ');
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
<a
|
||||
href='https://boxyhq.com/docs/admin-portal/overview'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='underline underline-offset-2'>
|
||||
BoxyHQ documentation
|
||||
</a>
|
||||
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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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'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'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: {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
<a
|
||||
href='https://boxyhq.com/docs/jackson/sso-providers'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='underline underline-offset-4'>
|
||||
guides
|
||||
</a>
|
||||
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
|
||||
<Link href='/.well-known/saml.cer' className='underline underline-offset-4' target='_blank'>
|
||||
download our public certificate.
|
||||
</Link>
|
||||
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>
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue