Ability to customize the branding of Setup Link pages and the IdP selection pages (ee) (#965)

* display toast and adjust the width of the content

* customize the branding for setup links

* use the branding in setup links page

* Admin Branding WIP

* Update settings

* Move to ee folder

* If the licence is not valid, return the default branding

* update translation

* Add logo to the idp selection page

* add license check to the API

* read default branding from a common place

* add LicenseRequired

* cleanup

* Add License check to NPM

* Fix

* Add --pf css variable

* fix the idp selection page

* use default branding if value is not set

* Fixes

* Improved the store and keys

* Infer the return type

* Whitelabeling the IdP selection screen per tenant and product

* Fix the param type

* Fix the unit tests

* Fix mismatch in server/client rendering

* Switch to radio button look and feel

* Use rounded border only for textual inputs

* Cleanup import

* Move routing to `useEffect`

* Fix server render mismatch

* fixed merge conflict

* fixed merge conflict

---------

Co-authored-by: Aswin V <vaswin91@gmail.com>
Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Kiran K 2023-03-09 20:20:25 +05:30 committed by GitHub
parent c8fb34823b
commit 224358df28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 750 additions and 165 deletions

9
components/PoweredBy.tsx Normal file
View File

@ -0,0 +1,9 @@
export const PoweredBy = () => {
return (
<p className='text-center text-xs text-gray-500'>
<a href='https://boxyhq.com/' target='_blank' rel='noopener noreferrer'>
Powered by BoxyHQ
</a>
</p>
);
};

View File

@ -117,6 +117,18 @@ export const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => {
text: t('settings'),
icon: Cog8ToothIcon,
active: asPath.includes('/admin/settings'),
items: [
{
href: '/admin/settings/sso-connection',
text: 'Single Sign-On',
active: asPath.includes('/admin/settings/sso-connection'),
},
{
href: '/admin/settings/branding',
text: 'Branding',
active: asPath.includes('/admin/settings/branding'),
},
],
},
];

View File

@ -146,39 +146,33 @@ const CreateConnection = ({
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
{t('create_sso_connection')}
</h2>
<div className='mb-4 flex'>
<div className='mr-2 py-3'>{t('select_type')}:</div>
<div className='flex flex-nowrap items-stretch justify-start gap-1 rounded-md border-2 border-dashed py-3'>
<div>
<input
type='radio'
name='connection'
value='saml'
className='peer sr-only'
checked={newConnectionType === 'saml'}
onChange={handleNewConnectionTypeChange}
id='saml-conn'
/>
<label
htmlFor='saml-conn'
className='cursor-pointer rounded-md border-2 border-solid py-3 px-8 font-semibold hover:shadow-md peer-checked:border-secondary-focus peer-checked:bg-secondary peer-checked:text-white'>
{t('saml')}
<div className='mb-4 flex items-center'>
<div className='mr-2 py-3'>{t('select_sso_type')}:</div>
<div className='flex w-52'>
<div className='form-control'>
<label className='label mr-4 cursor-pointer'>
<input
type='radio'
name='connection'
value='saml'
className='radio-primary radio'
checked={newConnectionType === 'saml'}
onChange={handleNewConnectionTypeChange}
/>
<span className='label-text ml-1'>{t('saml')}</span>
</label>
</div>
<div>
<input
type='radio'
name='connection'
value='oidc'
className='peer sr-only'
checked={newConnectionType === 'oidc'}
onChange={handleNewConnectionTypeChange}
id='oidc-conn'
/>
<label
htmlFor='oidc-conn'
className='cursor-pointer rounded-md border-2 border-solid px-8 py-3 font-semibold hover:shadow-md peer-checked:bg-secondary peer-checked:text-white'>
{t('oidc')}
<div className='form-control'>
<label className='label mr-4 cursor-pointer' data-testid='sso-type-oidc'>
<input
type='radio'
name='connection'
value='oidc'
className='radio-primary radio'
checked={newConnectionType === 'oidc'}
onChange={handleNewConnectionTypeChange}
/>
<span className='label-text ml-1'>{t('oidc')}</span>
</label>
</div>
</div>

View File

@ -3,35 +3,55 @@ import Head from 'next/head';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/router';
import Logo from '../../public/logo.png';
import InvalidSetupLinkAlert from '@components/setup-link/InvalidSetupLinkAlert';
import Loading from '@components/Loading';
import useSetupLink from '@lib/ui/hooks/useSetupLink';
import usePortalBranding from '@lib/ui/hooks/usePortalBranding';
import { useTranslation } from 'next-i18next';
import { hexToHsl, darkenHslColor } from '@lib/color';
import { PoweredBy } from '@components/PoweredBy';
export const SetupLinkLayout = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('common');
const router = useRouter();
const { token } = router.query as { token: string };
const { branding } = usePortalBranding();
const { setupLink, error, isLoading } = useSetupLink(token);
if (isLoading) {
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;
return (
<>
<Head>
<title>Setup Link - BoxyHQ</title>
<link rel='icon' href='/favicon.ico' />
<title>{`${title} - ${branding?.companyName}`}</title>
{branding?.faviconUrl && <link rel='icon' href={branding.faviconUrl} />}
</Head>
{primaryColor && (
<style>{`:root { --p: ${primaryColor}; --pf: ${darkenHslColor(primaryColor, 30)}; }`}</style>
)}
<div className='flex flex-1 flex-col'>
<div className='sticky top-0 z-10 flex h-16 flex-shrink-0 border-b bg-white'>
<div className='flex flex-shrink-0 items-center px-4'>
<Link href={`/setup/${token}`}>
<div 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'>Setup</span>
{branding?.logoUrl && (
<Image src={branding.logoUrl} alt={branding.companyName} width={42} height={42} />
)}
<span className='ml-4 text-xl font-bold text-gray-900'>{title}</span>
</div>
</Link>
</div>
@ -45,6 +65,7 @@ export const SetupLinkLayout = ({ children }: { children: React.ReactNode }) =>
</div>
</main>
</div>
<PoweredBy />
</>
);
};

View File

@ -96,7 +96,7 @@ test.describe('Admin Portal SSO - OIDC', () => {
// Find the new connection button and click on it
await page.getByTestId('create-connection').click();
// Toggle connection type to OIDC
await page.getByText('OIDC').click();
await page.getByTestId('sso-type-oidc').click();
// Fill the name for the connection
const nameInput = page.getByTestId('name');
await nameInput.fill(TEST_OIDC_SSO_CONNECTION_NAME);

View File

@ -0,0 +1,51 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { strings } from '@lib/strings';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { checkLicense } = await jackson();
if (!(await checkLicense())) {
return res.status(404).json({
error: {
message: strings['enterise_license_not_found'],
},
});
}
const { method } = req;
try {
switch (method) {
case 'POST':
return handlePOST(req, res);
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', 'POST, GET');
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
}
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({ error: { message } });
}
};
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const { brandingController } = await jackson();
const { logoUrl, faviconUrl, companyName, primaryColor } = req.body;
return res.json({
data: await brandingController?.update({ logoUrl, faviconUrl, companyName, primaryColor }),
});
};
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { brandingController } = await jackson();
return res.json({ data: await brandingController?.get() });
};
export default handler;

26
ee/branding/api/index.ts Normal file
View File

@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPortalBranding } from '@lib/settings';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
try {
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', 'GET');
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
}
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({ error: { message } });
}
};
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
return res.json({ data: await getPortalBranding() });
};
export default handler;

151
ee/branding/pages/index.tsx Normal file
View File

@ -0,0 +1,151 @@
import type { NextPage } from 'next';
import { useState, useEffect } from 'react';
import { useTranslation } from 'next-i18next';
import { ButtonPrimary } from '@components/ButtonPrimary';
import { errorToast, successToast } from '@components/Toaster';
import type { ApiResponse } from 'types';
import type { AdminPortalBranding } from '@boxyhq/saml-jackson';
import LicenseRequired from '@components/LicenseRequired';
const Branding: NextPage = () => {
const { t } = useTranslation('common');
const [loading, setLoading] = useState(false);
const [branding, setBranding] = useState<AdminPortalBranding>({
logoUrl: '',
faviconUrl: '',
companyName: '',
primaryColor: '',
});
// Fetch settings
const fetchSettings = async () => {
const rawResponse = await fetch('/api/admin/branding', {
headers: {
'Content-Type': 'application/json',
},
});
const response: ApiResponse<AdminPortalBranding> = await rawResponse.json();
if ('data' in response) {
setBranding(response.data);
}
};
// Update settings
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch('/api/admin/branding', {
method: 'POST',
body: JSON.stringify(branding),
headers: {
'Content-Type': 'application/json',
},
});
setLoading(false);
const response: ApiResponse<AdminPortalBranding> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if ('data' in response) {
successToast(t('settings_updated_successfully'));
}
};
// Handle input change
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setBranding({
...branding,
[target.id]: target.value,
});
};
useEffect(() => {
fetchSettings();
}, []);
return (
<LicenseRequired>
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{t('settings_branding_title')}</h2>
<p className='py-3 text-base leading-6 text-gray-800'>{t('settings_branding_description')}</p>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-2'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('branding_logo_url_label')}</span>
</label>
<input
type='url'
id='logoUrl'
className='input-bordered input'
onChange={onChange}
value={branding.logoUrl || ''}
placeholder='https://company.com/logo.png'
/>
<label className='label'>
<span className='label-text-alt'>{t('branding_logo_url_alt')}</span>
</label>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('branding_favicon_url_label')}</span>
</label>
<input
type='url'
id='faviconUrl'
className='input-bordered input'
onChange={onChange}
value={branding.faviconUrl || ''}
placeholder='https://company.com/favicon.ico'
/>
<label className='label'>
<span className='label-text-alt'>{t('branding_favicon_url_alt')}</span>
</label>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('branding_company_name_label')}</span>
</label>
<input
type='text'
id='companyName'
className='input-bordered input'
onChange={onChange}
value={branding.companyName || ''}
placeholder={t('branding_company_name_label')}
/>
<label className='label'>
<span className='label-text-alt'>{t('branding_company_name_alt')}</span>
</label>
</div>
<div className='form-control'>
<label className='label'>
<span className='label-text'>{t('branding_primary_color_label')}</span>
</label>
<input type='color' id='primaryColor' onChange={onChange} value={branding.primaryColor || ''} />
<label className='label'>
<span className='label-text-alt'>{t('branding_primary_color_alt')}</span>
</label>
</div>
<div className='mt-5'>
<ButtonPrimary loading={loading}>{t('save_changes')}</ButtonPrimary>
</div>
</div>
</form>
</div>
</LicenseRequired>
);
};
export default Branding;

View File

@ -19,11 +19,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
switch (method) {
case 'GET':
return handleGET(req, res);
return await handleGET(req, res);
case 'PUT':
return handlePUT(req, res);
return await handlePUT(req, res);
case 'DELETE':
return handleDELETE(req, res);
return await handleDELETE(req, res);
default:
res.setHeader('Allow', 'GET, PUT, DELETE');
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
@ -60,22 +60,26 @@ const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const { id } = req.query as { id: string };
const { name, acsUrl, entityId } = req.body as Pick<SAMLFederationApp, 'acsUrl' | 'entityId' | 'name'>;
const { name, acsUrl, entityId, logoUrl, faviconUrl, primaryColor } = req.body as Pick<
SAMLFederationApp,
'acsUrl' | 'entityId' | 'name' | 'logoUrl' | 'faviconUrl' | 'primaryColor'
>;
try {
const updatedApp = await samlFederatedController.app.update(id, {
name,
acsUrl,
entityId,
logoUrl,
faviconUrl,
primaryColor,
});
res.status(200).json({
data: updatedApp,
});
return res.status(200).json({ data: updatedApp });
} catch (error: any) {
const { message, statusCode = 500 } = error;
res.status(statusCode).json({
return res.status(statusCode).json({
error: { message },
});
}

View File

@ -1,5 +1,5 @@
import type { NextPage } from 'next';
import type { SAMLFederationApp } from '@boxyhq/saml-jackson';
import type { AdminPortalBranding, SAMLFederationApp } from '@boxyhq/saml-jackson';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { useRouter } from 'next/router';
@ -20,13 +20,16 @@ const UpdateApp: NextPage = () => {
const { t } = useTranslation('common');
const router = useRouter();
const [loading, setLoading] = useState(false);
const [app, setApp] = useState<SAMLFederationApp>({
const [app, setApp] = useState<SAMLFederationApp & Omit<AdminPortalBranding, 'companyName'>>({
id: '',
name: '',
tenant: '',
product: '',
acsUrl: '',
entityId: '',
logoUrl: '',
faviconUrl: '',
primaryColor: '',
});
const { id } = router.query as { id: string };
@ -103,7 +106,7 @@ const UpdateApp: NextPage = () => {
</div>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
<div className='space-y-3'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('tenant')}</span>
@ -129,7 +132,7 @@ const UpdateApp: NextPage = () => {
value={app.name}
/>
</div>
<div className='form-control'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('acs_url')}</span>
</label>
@ -142,7 +145,7 @@ const UpdateApp: NextPage = () => {
value={app.acsUrl}
/>
</div>
<div className='form-control'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('entity_id')}</span>
</label>
@ -155,6 +158,53 @@ const UpdateApp: NextPage = () => {
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>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('branding_logo_url_label')}</span>
</label>
<input
type='url'
id='logoUrl'
className='input-bordered input'
onChange={onChange}
placeholder='https://company.com/logo.png'
value={app.logoUrl || ''}
/>
<label className='label'>
<span className='label-text-alt'>{t('branding_logo_url_alt')}</span>
</label>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('branding_favicon_url_label')}</span>
</label>
<input
type='url'
id='faviconUrl'
className='input-bordered input'
onChange={onChange}
placeholder='https://company.com/favicon.ico'
value={app.faviconUrl || ''}
/>
<label className='label'>
<span className='label-text-alt'>{t('branding_favicon_url_alt')}</span>
</label>
</div>
<div className='form-control'>
<label className='label'>
<span className='label-text'>{t('branding_primary_color_label')}</span>
</label>
<input type='color' id='primaryColor' onChange={onChange} value={app.primaryColor || ''} />
<label className='label'>
<span className='label-text-alt'>{t('branding_primary_color_alt')}</span>
</label>
</div>
<div>
<ButtonPrimary type='submit' loading={loading}>
{t('save_changes')}

View File

@ -106,7 +106,7 @@ const NewApp: NextPage = () => {
placeholder='saml-jackson'
/>
</div>
<div className='form-control'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('acs_url')}</span>
</label>
@ -119,7 +119,7 @@ const NewApp: NextPage = () => {
placeholder='https://your-idp.com/saml/acs'
/>
</div>
<div className='form-control'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('entity_id')}</span>
</label>

53
lib/color.ts Normal file
View File

@ -0,0 +1,53 @@
// 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;
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}%`;
};

View File

@ -9,6 +9,7 @@ import type {
IOidcDiscoveryController,
ISPSAMLConfig,
ISAMLFederationController,
IBrandingController,
} from '@boxyhq/saml-jackson';
import jackson from '@boxyhq/saml-jackson';
@ -26,6 +27,7 @@ let oidcDiscoveryController: IOidcDiscoveryController;
let spConfig: ISPSAMLConfig;
let samlFederatedController: ISAMLFederationController;
let checkLicense: () => Promise<boolean>;
let brandingController: IBrandingController | null;
const g = global as any;
@ -40,7 +42,8 @@ export default async function init() {
!g.directorySyncController ||
!g.oidcDiscoveryController ||
!g.spConfig ||
!g.samlFederatedController
!g.samlFederatedController ||
!g.brandingController
) {
const ret = await jackson(jacksonOptions);
connectionAPIController = ret.connectionAPIController;
@ -54,6 +57,7 @@ export default async function init() {
spConfig = ret.spConfig;
samlFederatedController = ret.samlFederatedController;
checkLicense = ret.checkLicense;
brandingController = ret.brandingController;
g.connectionAPIController = connectionAPIController;
g.oauthController = oauthController;
@ -67,6 +71,7 @@ export default async function init() {
g.isJacksonReady = true;
g.samlFederatedController = samlFederatedController;
g.checkLicense = checkLicense;
g.brandingController = brandingController;
} else {
connectionAPIController = g.connectionAPIController;
oauthController = g.oauthController;
@ -79,6 +84,7 @@ export default async function init() {
spConfig = g.spConfig;
samlFederatedController = g.samlFederatedController;
checkLicense = g.checkLicense;
brandingController = g.brandingController;
}
return {
@ -93,5 +99,6 @@ export default async function init() {
setupLinkController,
samlFederatedController,
checkLicense,
brandingController,
};
}

27
lib/settings.ts Normal file
View File

@ -0,0 +1,27 @@
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

@ -0,0 +1,25 @@
import useSWR from 'swr';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
const usePortalBranding = () => {
const url = '/api/branding';
const { data, error, isLoading } = useSWR<
ApiSuccess<{
logoUrl: string;
primaryColor: string;
faviconUrl: string;
companyName: string;
}>,
ApiError
>(url, fetcher);
return {
branding: data?.data,
isLoading,
error,
};
};
export default usePortalBranding;

View File

@ -65,7 +65,7 @@
"sso_error": "SSO error",
"scim_endpoint": "SCIM Endpoint",
"scim_token": "SCIM Token",
"select_type": "Select Type",
"select_sso_type": "Select SSO type",
"select_an_app": "Select an App to continue",
"selection_list_empty": "Selection list empty",
"send_magic_link": "Send Magic Link",
@ -172,6 +172,20 @@
"password": "Password",
"sign_in": "Sign In",
"email_required": "Email is required",
"settings_branding_description": "Customize the look and feel of your portal. These values will be used in the Setup Links and IdP selection page.",
"settings_updated_successfully": "Settings updated successfully",
"settings_branding_title": "Portal Customization",
"branding_logo_url_label": "Logo URL",
"branding_favicon_url_label": "Favicon URL",
"branding_company_name_label": "Company Name",
"branding_primary_color_label": "Primary Color",
"configure_sso": "Configure Single Sign-On",
"configure_dsync": "Configure Directory Sync",
"branding_logo_url_alt": "Provide a URL to your logo. Recommend PNG or SVG formats.",
"branding_favicon_url_alt": "Provide a URL to your favicon. Recommend PNG, SVG, or ICO formats.",
"branding_company_name_alt": "Provide your company name or product name.",
"branding_primary_color_alt": "Primary color will be applied to buttons, links, and other elements.",
"select_an_idp": "Select an Identity Provider to continue",
"audit_logs": "Audit Logs",
"privacy_vault": "Privacy Vault",
"model_published_successfully": "Model published successfully",

View File

@ -16,6 +16,7 @@ const unAuthenticatedApiRoutes = [
'/api/scim/v2.0/**',
'/api/well-known/**',
'/api/setup/**',
'/api/branding',
];
export async function middleware(req: NextRequest) {

View File

@ -81,4 +81,12 @@ module.exports = {
},
];
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*',
},
],
},
};

View File

@ -43,6 +43,7 @@ export class SAMLHandler {
product?: string;
entityId?: string;
idp_hint?: string;
samlFedAppId?: string;
}): Promise<
| {
connection: SAMLSSORecord | OIDCSSORecord;
@ -54,7 +55,7 @@ export class SAMLHandler {
postForm: string;
}
> {
const { authFlow, originalParams, tenant, product, idp_hint, entityId } = params;
const { authFlow, originalParams, tenant, product, idp_hint, entityId, samlFedAppId = '' } = params;
let connections: (SAMLSSORecord | OIDCSSORecord)[] | null = null;
@ -101,6 +102,7 @@ export class SAMLHandler {
tenant,
product,
authFlow,
samlFedAppId,
...originalParams,
});

View File

@ -0,0 +1,47 @@
import type { Storable, AdminPortalBranding } from '../../typings';
export class BrandingController {
private store: Storable;
private storeKey = 'branding';
constructor({ store }: { store: Storable }) {
this.store = store;
}
// Get branding
public async get() {
const branding: AdminPortalBranding = await this.store.get(this.storeKey);
const defaultBranding = {
logoUrl: null,
faviconUrl: null,
companyName: null,
primaryColor: null,
};
return branding ? branding : defaultBranding;
}
// Update branding
public async update(params: Partial<AdminPortalBranding>) {
const { logoUrl, faviconUrl, companyName, primaryColor } = params;
const currentBranding = await this.get();
const newBranding = {
logoUrl: logoUrl ?? null,
faviconUrl: faviconUrl ?? null,
companyName: companyName ?? null,
primaryColor: primaryColor ?? null,
};
const updatedbranding = {
...currentBranding,
...newBranding,
};
await this.store.put(this.storeKey, updatedbranding);
return updatedbranding;
}
}

View File

@ -1,15 +1,12 @@
import type {
Storable,
JacksonOption,
SAMLFederationAppWithMetadata,
SAMLFederationApp,
} from '../../typings';
import type { Storable, JacksonOption, SAMLFederationApp } from '../../typings';
import { appID } from '../../controller/utils';
import { createMetadataXML } from '../../saml/lib';
import { JacksonError } from '../../controller/error';
import { getDefaultCertificate } from '../../saml/x509';
import { IndexNames, validateTenantAndProduct } from '../../controller/utils';
type NewAppParams = Pick<SAMLFederationApp, 'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId'>;
export class App {
protected store: Storable;
private opts: JacksonOption;
@ -20,13 +17,7 @@ export class App {
}
// Create a new SAML Federation app for the tenant and product
public async create({
name,
tenant,
product,
acsUrl,
entityId,
}: Omit<SAMLFederationApp, 'id'>): Promise<SAMLFederationApp> {
public async create({ name, tenant, product, acsUrl, entityId }: NewAppParams) {
if (!tenant || !product || !acsUrl || !entityId || !name) {
throw new JacksonError(
'Missing required parameters. Required parameters are: name, tenant, product, acsUrl, entityId',
@ -38,13 +29,16 @@ export class App {
const id = appID(tenant, product);
const app = {
const app: SAMLFederationApp = {
id,
name,
tenant,
product,
acsUrl,
entityId,
logoUrl: null,
faviconUrl: null,
primaryColor: null,
};
await this.store.put(id, app, {
@ -52,11 +46,11 @@ export class App {
value: entityId,
});
return { ...app };
return app;
}
// Get an app by tenant and product
public async get(id: string): Promise<SAMLFederationApp> {
public async get(id: string) {
if (!id) {
throw new JacksonError('Missing required parameters. Required parameters are: id', 400);
}
@ -67,11 +61,11 @@ export class App {
throw new JacksonError('SAML Federation app not found', 404);
}
return { ...app };
return app;
}
// Get the app by SP EntityId
public async getByEntityId(entityId: string): Promise<SAMLFederationApp> {
public async getByEntityId(entityId: string) {
if (!entityId) {
throw new JacksonError('Missing required parameters. Required parameters are: entityId', 400);
}
@ -85,46 +79,46 @@ export class App {
throw new JacksonError('SAML Federation app not found', 404);
}
return { ...apps[0] };
return apps[0];
}
// Update the app
public async update(
id: string,
{ acsUrl, entityId, name }: Partial<Omit<SAMLFederationApp, 'id'>>
): Promise<SAMLFederationApp> {
if (!id && (!acsUrl || !entityId || !name)) {
public async update(id: string, params: Partial<Omit<SAMLFederationApp, 'id'>>) {
const { acsUrl, entityId, name, logoUrl, faviconUrl, primaryColor } = params;
if (!id) {
throw new JacksonError('Missing the app id', 400);
}
if (!acsUrl && !entityId && !name && !logoUrl && !faviconUrl && !primaryColor) {
throw new JacksonError(
"Missing required parameters. Required parameters are: id, acsUrl, entityId, name'",
'Missing required parameters. Please provide at least one of the following parameters: acsUrl, entityId, name, logoUrl, faviconUrl, primaryColor',
400
);
}
const app = await this.get(id);
const updatedApp = {
const updatedApp: SAMLFederationApp = {
...app,
name: name || app.name,
acsUrl: acsUrl || app.acsUrl,
entityId: entityId || app.entityId,
logoUrl: logoUrl || app.logoUrl,
faviconUrl: faviconUrl || app.faviconUrl,
primaryColor: primaryColor || app.primaryColor,
};
await this.store.put(id, updatedApp);
return { ...updatedApp };
return updatedApp;
}
// Get all apps
public async getAll({
pageOffset,
pageLimit,
}: {
pageOffset?: number;
pageLimit?: number;
}): Promise<SAMLFederationApp[]> {
const apps = (await this.store.getAll(pageOffset, pageLimit)) as SAMLFederationApp[];
public async getAll({ pageOffset, pageLimit }: { pageOffset?: number; pageLimit?: number }) {
const apps: SAMLFederationApp[] = await this.store.getAll(pageOffset, pageLimit);
return apps.map((app) => ({ ...app }));
return apps;
}
// Delete the app
@ -140,7 +134,7 @@ export class App {
}
// Get the metadata for the app
public async getMetadata(): Promise<Pick<SAMLFederationAppWithMetadata, 'metadata'>['metadata']> {
public async getMetadata() {
const { publicKey } = await getDefaultCertificate();
const ssoUrl = `${this.opts.externalUrl}/api/federated-saml/sso`;

View File

@ -86,7 +86,7 @@ export class SSO {
throw new JacksonError('No SAML connection found.', 404);
}
const { redirectUrl } = await this.samlHandler.createSAMLRequest({
return await this.samlHandler.createSAMLRequest({
connection,
requestParams: {
id,
@ -97,10 +97,6 @@ export class SSO {
relayState,
},
});
return {
redirectUrl,
};
} catch (err: unknown) {
const error_description = getErrorMessage(err);

View File

@ -9,6 +9,9 @@ export type SAMLFederationApp = {
product: string;
acsUrl: string;
entityId: string;
logoUrl: string | null;
faviconUrl: string | null;
primaryColor: string | null;
};
export type SAMLFederationAppWithMetadata = SAMLFederationApp & {

View File

@ -16,6 +16,7 @@ import { AnalyticsController } from './controller/analytics';
import * as x509 from './saml/x509';
import initFederatedSAML, { type ISAMLFederationController } from './ee/federated-saml';
import checkLicense from './ee/common/checkLicense';
import { BrandingController } from './ee/branding';
import SAMLTracer from './saml-tracer';
const defaultOpts = (opts: JacksonOption): JacksonOption => {
@ -66,6 +67,7 @@ export const controllers = async (
oidcDiscoveryController: OidcDiscoveryController;
spConfig: SPSAMLConfig;
samlFederatedController: ISAMLFederationController;
brandingController: IBrandingController | null;
checkLicense: () => Promise<boolean>;
}> => {
opts = defaultOpts(opts);
@ -81,6 +83,7 @@ export const controllers = async (
const healthCheckStore = db.store('_health:check');
const setupLinkStore = db.store('setup:link');
const certificateStore = db.store('x509:certificates');
const settingsStore = db.store('portal:settings');
const samlTracer = new SAMLTracer({ db });
@ -120,7 +123,12 @@ export const controllers = async (
const oidcDiscoveryController = new OidcDiscoveryController({ opts });
const spConfig = new SPSAMLConfig(opts);
const directorySyncController = await initDirectorySync({ db, opts });
// Enterprise Features
const samlFederatedController = await initFederatedSAML({ db, opts, samlTracer });
const brandingController = (await checkLicense(opts.boxyhqLicenseKey))
? new BrandingController({ store: settingsStore })
: null;
// write pre-loaded connections if present
const preLoadedConnection = opts.preLoadedConnection || opts.preLoadedConfig;
@ -154,6 +162,7 @@ export const controllers = async (
directorySyncController,
oidcDiscoveryController,
samlFederatedController,
brandingController,
checkLicense: () => {
return checkLicense(opts.boxyhqLicenseKey);
},
@ -166,3 +175,4 @@ export * from './typings';
export * from './ee/federated-saml/types';
export type SAMLJackson = Awaited<ReturnType<typeof controllers>>;
export type ISetupLinkController = InstanceType<typeof SetupLinkController>;
export type IBrandingController = InstanceType<typeof BrandingController>;

View File

@ -474,3 +474,16 @@ export type SetupLink = {
};
export type SetupLinkService = 'sso' | 'dsync';
// Admin Portal settings
export type AdminPortalSettings = {
branding: AdminPortalBranding;
};
// Admin Portal branding options
export type AdminPortalBranding = {
logoUrl: string | null;
faviconUrl: string | null;
primaryColor: string | null;
companyName: string | null;
};

View File

@ -69,6 +69,28 @@ tap.test('Federated SAML App', async (t) => {
t.end();
});
tap.test('Should be able to update the app branding', async (t) => {
const response = await samlFederatedController.app.update(app.id, {
logoUrl: 'https://company.com/logo.png',
faviconUrl: 'https://company.com/favicon.ico',
primaryColor: '#000000',
});
t.ok(response);
t.match(response.logoUrl, 'https://company.com/logo.png');
t.match(response.faviconUrl, 'https://company.com/favicon.ico');
t.match(response.primaryColor, '#000000');
const updatedApp = await samlFederatedController.app.get(app.id);
t.ok(updatedApp);
t.match(updatedApp.logoUrl, 'https://company.com/logo.png');
t.match(updatedApp.faviconUrl, 'https://company.com/favicon.ico');
t.match(updatedApp.primaryColor, '#000000');
t.end();
});
tap.test('Should be able to get all SAML Federation apps', async (t) => {
const response = await samlFederatedController.app.getAll({});

View File

@ -0,0 +1,11 @@
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export { default } from 'ee/branding/pages/index';
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
};
}

View File

@ -0,0 +1 @@
export { default } from 'ee/branding/api/admin/index';

1
pages/api/branding.ts Normal file
View File

@ -0,0 +1 @@
export { default } from 'ee/branding/api/index';

View File

@ -3,31 +3,60 @@ import getRawBody from 'raw-body';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
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 Image from 'next/image';
import { PoweredBy } from '@components/PoweredBy';
import { getPortalBranding } from '@lib/settings';
export default function ChooseIdPConnection({
connections,
SAMLResponse,
requestType,
branding,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { t } = useTranslation('common');
const primaryColor = hexToHsl(branding.primaryColor);
const title = requestType === 'sp-initiated' ? t('select_an_idp') : t('select_an_app');
return (
<div className='mx-auto my-28 w-[500px]'>
<div className='mx-5 flex flex-col space-y-10 rounded border border-gray-300 p-10'>
<Head>
<title>{`${title} - ${branding.companyName}`}</title>
{branding?.faviconUrl && <link rel='icon' href={branding.faviconUrl} />}
</Head>
{primaryColor && (
<style>{`:root { --p: ${primaryColor}; --pf: ${darkenHslColor(primaryColor, 30)}; }`}</style>
)}
{branding?.logoUrl && (
<div className='flex justify-center'>
<Image src={branding.logoUrl} alt={branding.companyName} width={50} height={50} />
</div>
)}
{requestType === 'sp-initiated' ? (
<IdpSelector connections={connections} />
) : (
<AppSelector connections={connections} SAMLResponse={SAMLResponse} />
)}
</div>
<div className='my-4'>
<PoweredBy />
</div>
</div>
);
}
const IdpSelector = ({ connections }: { connections: (OIDCSSORecord | SAMLSSORecord)[] }) => {
const router = useRouter();
const { t } = useTranslation('common');
// SP initiated SSO: Redirect to the same path with idp_hint set to the selected connection clientID
const connectionSelected = (clientID: string) => {
@ -36,13 +65,17 @@ const IdpSelector = ({ connections }: { connections: (OIDCSSORecord | SAMLSSORec
return (
<>
<h3 className='text-center text-xl font-bold'>Select an Identity Provider to continue</h3>
<h3 className='text-center text-xl font-bold'>{t('select_an_idp')}</h3>
<ul className='flex flex-col space-y-5'>
{connections.map((connection) => {
const idpMetadata = 'idpMetadata' in connection ? connection.idpMetadata : undefined;
const oidcProvider = 'oidcProvider' in connection ? connection.oidcProvider : undefined;
const name = connection.name || (idpMetadata ? idpMetadata.provider : `${oidcProvider?.provider}`);
const name =
connection.name ||
(idpMetadata
? idpMetadata.friendlyProviderName || idpMetadata.provider
: `${oidcProvider?.provider}`);
return (
<li key={connection.clientID} className='rounded bg-gray-100'>
@ -55,7 +88,7 @@ const IdpSelector = ({ connections }: { connections: (OIDCSSORecord | SAMLSSORec
<div className='flex items-center gap-2 py-3 px-3'>
<div className='placeholder avatar'>
<div className='w-8 rounded-full bg-primary text-white'>
<span className='text-xs font-bold'>{name.charAt(0).toUpperCase()}</span>
<span className='text-lg font-bold'>{name.charAt(0).toUpperCase()}</span>
</div>
</div>
{name}
@ -124,21 +157,18 @@ const AppSelector = ({
);
};
export const getServerSideProps: GetServerSideProps<{
connections: (OIDCSSORecord | SAMLSSORecord)[];
SAMLResponse: string | null;
requestType: 'sp-initiated' | 'idp-initiated';
}> = async ({ query, locale, req }) => {
const { connectionAPIController } = await jackson();
export const getServerSideProps = async ({ query, locale, req }) => {
const { connectionAPIController, samlFederatedController, checkLicense } = await jackson();
const paramsToRelay = { ...query } as { [key: string]: string };
const { authFlow, entityId, tenant, product, idp_hint } = query as {
const { authFlow, entityId, tenant, product, idp_hint, samlFedAppId } = query as {
authFlow: 'saml' | 'oauth';
tenant?: string;
product?: string;
idp_hint?: string;
entityId?: string;
samlFedAppId?: string;
};
// The user has selected an IdP to continue with
@ -167,6 +197,27 @@ export const getServerSideProps: GetServerSideProps<{
connections = await connectionAPIController.getConnections({ entityId: decodeURIComponent(entityId) });
}
const samlFederationApp = samlFedAppId ? await samlFederatedController.app.get(samlFedAppId) : null;
if (samlFedAppId && !samlFederationApp) {
return {
notFound: true,
};
}
// Get the branding to use for the IdP selector screen
let branding = await getPortalBranding();
// For SAML federated requests, use the branding from the SAML federated app
if (samlFederationApp && (await checkLicense())) {
branding = {
logoUrl: samlFederationApp?.logoUrl || branding.logoUrl,
primaryColor: samlFederationApp?.primaryColor || branding.primaryColor,
faviconUrl: samlFederationApp?.faviconUrl || branding.faviconUrl,
companyName: samlFederationApp?.name || branding.companyName,
};
}
// For idp-initiated flows, we need to parse the SAMLResponse from the request body and pass it to the component
if (req.method == 'POST') {
const body = await getRawBody(req);
@ -187,6 +238,7 @@ export const getServerSideProps: GetServerSideProps<{
requestType: 'idp-initiated',
SAMLResponse,
connections,
branding,
},
};
}
@ -197,6 +249,7 @@ export const getServerSideProps: GetServerSideProps<{
requestType: 'sp-initiated',
SAMLResponse: null,
connections,
branding,
},
};
};

View File

@ -1,4 +1,4 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import type { NextPage } from 'next';
import { useRouter } from 'next/router';
import React from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
@ -12,12 +12,12 @@ const DirectoryEditPage: NextPage = () => {
return <EditDirectory directoryId={directoryId} setupLinkToken={token} />;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
export const getServerSideProps = async (context) => {
const { locale } = context;
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
...(await serverSideTranslations(locale, ['common'])),
},
};
};

View File

@ -1,4 +1,4 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import type { NextPage } from 'next';
import React from 'react';
import { useRouter } from 'next/router';
import DirectoryInfo from '@components/dsync/DirectoryInfo';
@ -12,12 +12,12 @@ const DirectoryDetailsPage: NextPage = () => {
return <DirectoryInfo directoryId={directoryId} setupLinkToken={token} />;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
export const getServerSideProps = async (context) => {
const { locale } = context;
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
...(await serverSideTranslations(locale, ['common'])),
},
};
};

View File

@ -1,4 +1,4 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import type { NextPage } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import DirectoryList from '@components/dsync/DirectoryList';
@ -11,12 +11,12 @@ const DirectoryIndexPage: NextPage = () => {
return <DirectoryList setupLinkToken={token} />;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
export const getServerSideProps = async (context) => {
const { locale } = context;
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
...(await serverSideTranslations(locale, ['common'])),
},
};
};

View File

@ -1,4 +1,4 @@
import type { NextPage, GetServerSideProps } from 'next';
import type { NextPage } from 'next';
import React from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
@ -12,10 +12,10 @@ const DirectoryCreatePage: NextPage = () => {
return <CreateDirectory setupLinkToken={token} />;
};
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
export const getServerSideProps = async ({ locale }) => {
return {
props: {
...(await serverSideTranslations(locale ?? '', ['common'])),
...(await serverSideTranslations(locale, ['common'])),
},
};
};

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import type { NextPage } from 'next';
import { useRouter } from 'next/router';
import Loading from '@components/Loading';
@ -10,22 +11,21 @@ const SetupLinkIndexPage: NextPage = () => {
const { setupLink, isLoading } = useSetupLink(token);
const service = setupLink?.service;
useEffect(() => {
if (service === 'sso') {
router.replace(`/setup/${token}/sso-connection`);
}
if (service === 'dsync') {
router.replace(`/setup/${token}/directory-sync`);
}
}, [router, service, token]);
if (isLoading) {
return <Loading />;
}
// We can safely assume that the setupLink is valid here
// because the SetupLink layout is doing the validation before rendering this page.
switch (setupLink?.service) {
case 'sso':
router.replace(`/setup/${token}/sso-connection`);
break;
case 'dsync':
router.replace(`/setup/${token}/directory-sync`);
break;
}
return null;
};

View File

@ -1,4 +1,4 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import type { NextPage } from 'next';
import useSWR from 'swr';
import { useRouter } from 'next/router';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
@ -34,19 +34,12 @@ const ConnectionEditPage: NextPage = () => {
return <EditConnection connection={connection} setupLinkToken={token} />;
};
export async function getStaticProps({ locale }: GetServerSidePropsContext) {
export async function getServerSideProps({ locale }) {
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
...(await serverSideTranslations(locale, ['common'])),
},
};
}
export async function getStaticPaths() {
return {
paths: [],
fallback: 'blocking',
};
}
export default ConnectionEditPage;

View File

@ -1,4 +1,4 @@
import type { GetServerSidePropsContext, NextPage } from 'next';
import type { NextPage } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import ConnectionList from '@components/connection/ConnectionList';
import { useRouter } from 'next/router';
@ -14,19 +14,12 @@ const ConnectionsIndexPage: NextPage = () => {
return <ConnectionList setupLinkToken={token} idpEntityID={idpEntityID} />;
};
export async function getStaticProps({ locale }: GetServerSidePropsContext) {
export async function getServerSideProps({ locale }) {
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
...(await serverSideTranslations(locale, ['common'])),
},
};
}
export async function getStaticPaths() {
return {
paths: [],
fallback: 'blocking',
};
}
export default ConnectionsIndexPage;

View File

@ -1,4 +1,4 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import type { NextPage } from 'next';
import CreateConnection from '@components/connection/CreateConnection';
import { useRouter } from 'next/router';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
@ -14,19 +14,12 @@ const ConnectionCreatePage: NextPage = () => {
return <CreateConnection setupLinkToken={token} idpEntityID={idpEntityID} />;
};
export async function getStaticProps({ locale }: GetServerSidePropsContext) {
export async function getServerSideProps({ locale }) {
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
...(await serverSideTranslations(locale, ['common'])),
},
};
}
export async function getStaticPaths() {
return {
paths: [],
fallback: 'blocking',
};
}
export default ConnectionCreatePage;

View File

@ -24,7 +24,7 @@ a {
}
@layer base {
input {
input:not([type='radio']):not([type='checkbox']) {
@apply rounded !important;
}
}
@ -110,4 +110,4 @@ span.react-datepicker__navigation-icon {
.modal-content > div > textarea {
background-color: white;
}
}