mirror of https://github.com/boxyhq/jackson.git
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:
parent
c8fb34823b
commit
224358df28
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}%`;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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",
|
||||
|
|
|
@ -16,6 +16,7 @@ const unAuthenticatedApiRoutes = [
|
|||
'/api/scim/v2.0/**',
|
||||
'/api/well-known/**',
|
||||
'/api/setup/**',
|
||||
'/api/branding',
|
||||
];
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
|
|
|
@ -81,4 +81,12 @@ module.exports = {
|
|||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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`;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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({});
|
||||
|
||||
|
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'ee/branding/api/admin/index';
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'ee/branding/api/index';
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue