mirror of https://github.com/boxyhq/jackson.git
Admin portal sso login (#762)
* env, login button & translations * added setting in sidebar Added login with sso button Added connection create form in settings * added new pages for Self SSO connection CRUD * Fixed Self SSO issue * Use @boxyhq/react-ui component for SSO * `await` on method instead of class * Fix import * Set fields to non-editable for settings view * Tweak for settings view * Add link for settings in sidebar * Take in admin SSO defaults from env * Tweak edit page for settings view * Remove `NEXT_PUBLIC` prefix * Switch back to getSSP from getStaticProps * Sync lock file * Set defaults in env * Filter out admin sso tenant/product * Load admin SSO tenant/product * Update heading * Fix back link * Use latest published version * Set `clientId` to dummy in provider init * Use the defaults from env * Fix redirectUrl after savingConnection for settingsView * Use `isLoading` from SWR * Fix settings view url for mutation and redirect in Edit * Replace api route path * Use rewrite instead of router.push and other tweaks * Reuse `ConnectionList` for settings * Use pagination query params in settings api * Import styles from sdk * Fix failing build * Use latest version * - Display badge for system sso connections - Reuse admin connection for retrieving system sso connections * Tweak styling * Construct profile in updateUser as done previously * Update react-ui * Remove extra truthy check * Hide pagination buttons for settings view * Install @boxyhq/react-ui as symlink to local * Tweak badge size * Rename admin portal sso envs * Fix the edit redirection for system sso Co-authored-by: ukrocks007 <ukrocks.mehta@gmail.com> Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com> Co-authored-by: Kiran K <kiran@boxyhq.com>
This commit is contained in:
parent
93d7ef1470
commit
b14a0f1623
|
@ -2,6 +2,8 @@
|
|||
EXTERNAL_URL=
|
||||
SAML_AUDIENCE=https://saml.boxyhq.com
|
||||
JACKSON_API_KEYS="secret"
|
||||
ADMIN_PORTAL_SSO_TENANT="_jackson_boxyhq"
|
||||
ADMIN_PORTAL_SSO_PRODUCT="_jackson_admin_portal"
|
||||
IDP_ENABLED=
|
||||
PRE_LOADED_CONNECTION=
|
||||
CLIENT_SECRET_VERIFIER=
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useTranslation } from 'next-i18next';
|
|||
import SSOLogo from '@components/logo/SSO';
|
||||
import DSyncLogo from '@components/logo/DSync';
|
||||
import AuditLogsLogo from '@components/logo/AuditLogs';
|
||||
import { Cog8ToothIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
type SidebarProps = {
|
||||
isOpen: boolean;
|
||||
|
@ -91,6 +92,12 @@ export const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/admin/settings',
|
||||
text: t('settings'),
|
||||
icon: Cog8ToothIcon,
|
||||
active: asPath.includes('/admin/settings'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
@ -15,13 +15,16 @@ import { fetcher } from '@lib/ui/utils';
|
|||
import Loading from '@components/Loading';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import { Badge } from 'react-daisyui';
|
||||
|
||||
const ConnectionList = ({
|
||||
setupLinkToken,
|
||||
idpEntityID,
|
||||
isSettingsView = false,
|
||||
}: {
|
||||
setupLinkToken?: string;
|
||||
idpEntityID?: string;
|
||||
isSettingsView?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const { paginate, setPaginate } = usePaginate();
|
||||
|
@ -30,16 +33,19 @@ const ConnectionList = ({
|
|||
const displayTenantProduct = setupLinkToken ? false : true;
|
||||
const getConnectionsUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? `/api/admin/connections?isSystemSSO`
|
||||
: `/api/admin/connections?pageOffset=${paginate.offset}&pageLimit=${pageLimit}`;
|
||||
const createConnectionUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection/new`
|
||||
: isSettingsView
|
||||
? `/admin/settings/sso-connection/new`
|
||||
: '/admin/sso-connection/new';
|
||||
|
||||
const { data, error, isLoading } = useSWR<ApiSuccess<(SAMLSSORecord | OIDCSSORecord)[]>, ApiError>(
|
||||
getConnectionsUrl,
|
||||
fetcher,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
const { data, error, isLoading } = useSWR<
|
||||
ApiSuccess<((SAMLSSORecord | OIDCSSORecord) & { isSystemSSO?: boolean })[]>,
|
||||
ApiError
|
||||
>(getConnectionsUrl, fetcher, { revalidateOnFocus: false });
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
|
@ -62,12 +68,14 @@ const ConnectionList = ({
|
|||
return (
|
||||
<div>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{t('enterprise_sso')}</h2>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t(isSettingsView ? 'admin_portal_sso' : 'enterprise_sso')}
|
||||
</h2>
|
||||
<div className='flex gap-2'>
|
||||
<LinkPrimary Icon={PlusIcon} href={createConnectionUrl} data-test-id='create-connection'>
|
||||
{t('new_connection')}
|
||||
</LinkPrimary>
|
||||
{!setupLinkToken && (
|
||||
{!setupLinkToken && !isSettingsView && (
|
||||
<LinkPrimary
|
||||
Icon={LinkIcon}
|
||||
href='/admin/sso-connection/setup-link/new'
|
||||
|
@ -117,7 +125,7 @@ const ConnectionList = ({
|
|||
{connections.map((connection) => {
|
||||
const connectionIsSAML = 'idpMetadata' in connection;
|
||||
const connectionIsOIDC = 'oidcProvider' in connection;
|
||||
|
||||
const isSystemSSO = connection?.isSystemSSO;
|
||||
return (
|
||||
<tr
|
||||
key={connection.clientID}
|
||||
|
@ -127,6 +135,16 @@ const ConnectionList = ({
|
|||
(connectionIsSAML
|
||||
? connection.idpMetadata?.provider
|
||||
: connection.oidcProvider?.provider)}
|
||||
{isSystemSSO && (
|
||||
<Badge
|
||||
color='primary'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ml-1 uppercase'
|
||||
aria-label='is an sso connection for the admin portal'>
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
{displayTenantProduct && (
|
||||
<>
|
||||
|
@ -151,6 +169,8 @@ const ConnectionList = ({
|
|||
router.push(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection/edit/${connection.clientID}`
|
||||
: isSettingsView || isSystemSSO
|
||||
? `/admin/settings/sso-connection/edit/${connection.clientID}`
|
||||
: `/admin/sso-connection/edit/${connection.clientID}`
|
||||
);
|
||||
}}
|
||||
|
@ -164,20 +184,22 @@ const ConnectionList = ({
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
itemsCount={connections.length}
|
||||
offset={paginate.offset}
|
||||
onPrevClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset - pageLimit,
|
||||
});
|
||||
}}
|
||||
onNextClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset + pageLimit,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!isSettingsView && (
|
||||
<Pagination
|
||||
itemsCount={connections.length}
|
||||
offset={paginate.offset}
|
||||
onPrevClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset - pageLimit,
|
||||
});
|
||||
}}
|
||||
onNextClick={() => {
|
||||
setPaginate({
|
||||
offset: paginate.offset + pageLimit,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { getCommonFields } from './fieldCatalog';
|
||||
import { saveConnection, fieldCatalogFilterByConnection, renderFieldList } from './utils';
|
||||
import {
|
||||
saveConnection,
|
||||
fieldCatalogFilterByConnection,
|
||||
renderFieldList,
|
||||
useFieldCatalog,
|
||||
type AdminPortalSSODefaults,
|
||||
} from './utils';
|
||||
import { mutate } from 'swr';
|
||||
import { ApiResponse } from 'types';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
|
@ -10,15 +15,18 @@ import { LinkBack } from '@components/LinkBack';
|
|||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { InputWithCopyButton } from '@components/ClipboardButton';
|
||||
|
||||
const fieldCatalog = [...getCommonFields()];
|
||||
|
||||
const CreateConnection = ({
|
||||
setupLinkToken,
|
||||
idpEntityID,
|
||||
isSettingsView = false,
|
||||
adminPortalSSODefaults,
|
||||
}: {
|
||||
setupLinkToken?: string;
|
||||
idpEntityID?: string;
|
||||
isSettingsView?: boolean;
|
||||
adminPortalSSODefaults?: AdminPortalSSODefaults;
|
||||
}) => {
|
||||
const fieldCatalog = useFieldCatalog({ isSettingsView });
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
@ -33,10 +41,20 @@ const CreateConnection = ({
|
|||
const connectionIsSAML = newConnectionType === 'saml';
|
||||
const connectionIsOIDC = newConnectionType === 'oidc';
|
||||
|
||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}` : '/admin/sso-connection';
|
||||
const redirectUrl = setupLinkToken ? `/setup/${setupLinkToken}/sso-connection` : '/admin/sso-connection';
|
||||
const backUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
const redirectUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
const mutationUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/api/admin/connections?adminSSO'
|
||||
: '/api/admin/connections';
|
||||
|
||||
// FORM LOGIC: SUBMIT
|
||||
|
@ -69,7 +87,9 @@ const CreateConnection = ({
|
|||
};
|
||||
|
||||
// STATE: FORM
|
||||
const [formObj, setFormObj] = useState<Record<string, string>>({});
|
||||
const [formObj, setFormObj] = useState<Record<string, string>>(
|
||||
isSettingsView ? { ...adminPortalSSODefaults } : {}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -3,8 +3,7 @@ import { useEffect, useState } from 'react';
|
|||
import { mutate } from 'swr';
|
||||
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import { EditViewOnlyFields, getCommonFields } from './fieldCatalog';
|
||||
import { saveConnection, fieldCatalogFilterByConnection, renderFieldList } from './utils';
|
||||
import { saveConnection, fieldCatalogFilterByConnection, renderFieldList, useFieldCatalog } from './utils';
|
||||
import { ApiResponse } from 'types';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
@ -12,9 +11,7 @@ import { LinkBack } from '@components/LinkBack';
|
|||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { ButtonDanger } from '@components/ButtonDanger';
|
||||
|
||||
const fieldCatalog = [...getCommonFields(true), ...EditViewOnlyFields];
|
||||
|
||||
function getInitialState(connection) {
|
||||
function getInitialState(connection, fieldCatalog) {
|
||||
const _state = {};
|
||||
|
||||
fieldCatalog.forEach(({ key, attributes }) => {
|
||||
|
@ -37,9 +34,12 @@ function getInitialState(connection) {
|
|||
type EditProps = {
|
||||
connection?: Record<string, any>;
|
||||
setupLinkToken?: string;
|
||||
isSettingsView?: boolean;
|
||||
};
|
||||
|
||||
const EditConnection = ({ connection, setupLinkToken }: EditProps) => {
|
||||
const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }: EditProps) => {
|
||||
const fieldCatalog = useFieldCatalog({ isEditView: true, isSettingsView });
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
|
@ -101,26 +101,46 @@ const EditConnection = ({ connection, setupLinkToken }: EditProps) => {
|
|||
}
|
||||
|
||||
if (res.ok) {
|
||||
await mutate(setupLinkToken ? `/api/setup/${setupLinkToken}/connections` : '/api/admin/connections');
|
||||
router.replace(setupLinkToken ? `/setup/${setupLinkToken}/sso-connection` : '/admin/sso-connection');
|
||||
await mutate(
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/connections`
|
||||
: isSettingsView
|
||||
? `/api/admin/connections?isSystemSSO`
|
||||
: '/api/admin/connections'
|
||||
);
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// STATE: FORM
|
||||
const [formObj, setFormObj] = useState<Record<string, string>>(() => getInitialState(connection));
|
||||
const [formObj, setFormObj] = useState<Record<string, string>>(() =>
|
||||
getInitialState(connection, fieldCatalog)
|
||||
);
|
||||
// Resync form state on save
|
||||
useEffect(() => {
|
||||
const _state = getInitialState(connection);
|
||||
const _state = getInitialState(connection, fieldCatalog);
|
||||
setFormObj(_state);
|
||||
}, [connection]);
|
||||
}, [connection, fieldCatalog]);
|
||||
|
||||
const filteredFieldsByConnection = fieldCatalog.filter(
|
||||
fieldCatalogFilterByConnection(connectionIsSAML ? 'saml' : connectionIsOIDC ? 'oidc' : null)
|
||||
);
|
||||
|
||||
const backUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkBack href={setupLinkToken ? `/setup/${setupLinkToken}` : '/admin/sso-connection'} />
|
||||
<LinkBack href={backUrl} />
|
||||
<div>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('edit_sso_connection')}
|
||||
|
|
|
@ -5,7 +5,13 @@
|
|||
* `accessor` only used to set initial state and retrieve saved value. Useful when key is different from retrieved payload.
|
||||
*/
|
||||
|
||||
export const getCommonFields = (isEditView?: boolean) => [
|
||||
export const getCommonFields = ({
|
||||
isEditView,
|
||||
isSettingsView,
|
||||
}: {
|
||||
isEditView?: boolean;
|
||||
isSettingsView?: boolean;
|
||||
}) => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
|
@ -31,6 +37,7 @@ export const getCommonFields = (isEditView?: boolean) => [
|
|||
hideInSetupView: true,
|
||||
}
|
||||
: {
|
||||
editable: !isSettingsView,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
|
@ -45,6 +52,7 @@ export const getCommonFields = (isEditView?: boolean) => [
|
|||
hideInSetupView: true,
|
||||
}
|
||||
: {
|
||||
editable: !isSettingsView,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
|
@ -53,16 +61,15 @@ export const getCommonFields = (isEditView?: boolean) => [
|
|||
label: 'Allowed redirect URLs (newline separated)',
|
||||
type: 'textarea',
|
||||
placeholder: 'http://localhost:3366',
|
||||
attributes: { isArray: true, rows: 3, hideInSetupView: true },
|
||||
attributes: { isArray: true, rows: 3, hideInSetupView: true, editable: !isSettingsView },
|
||||
},
|
||||
{
|
||||
key: 'defaultRedirectUrl',
|
||||
label: 'Default redirect URL',
|
||||
type: 'url',
|
||||
placeholder: 'http://localhost:3366/login/saml',
|
||||
attributes: { hideInSetupView: true },
|
||||
attributes: { hideInSetupView: true, editable: !isSettingsView },
|
||||
},
|
||||
|
||||
{
|
||||
key: 'oidcDiscoveryUrl',
|
||||
label: 'Well-known URL of OpenId Provider',
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { errorToast } from '@components/Toaster';
|
||||
import { FormEvent, SetStateAction } from 'react';
|
||||
import { FormEvent, SetStateAction, useMemo } from 'react';
|
||||
import { EditViewOnlyFields, getCommonFields } from './fieldCatalog';
|
||||
|
||||
export const saveConnection = async ({
|
||||
formObj,
|
||||
|
@ -46,6 +47,7 @@ export const saveConnection = async ({
|
|||
);
|
||||
callback(res);
|
||||
};
|
||||
|
||||
export function fieldCatalogFilterByConnection(connection) {
|
||||
return ({ attributes }) =>
|
||||
attributes.connection && connection !== null ? attributes.connection === connection : true;
|
||||
|
@ -78,6 +80,29 @@ type FieldCatalog = {
|
|||
};
|
||||
};
|
||||
|
||||
export const useFieldCatalog = ({
|
||||
isEditView,
|
||||
isSettingsView,
|
||||
}: {
|
||||
isEditView?: boolean;
|
||||
isSettingsView?: boolean;
|
||||
}) => {
|
||||
const fieldCatalog = useMemo(() => {
|
||||
if (isEditView) {
|
||||
return [...getCommonFields({ isEditView: true, isSettingsView }), ...EditViewOnlyFields];
|
||||
}
|
||||
return [...getCommonFields({ isSettingsView })];
|
||||
}, [isEditView, isSettingsView]);
|
||||
return fieldCatalog;
|
||||
};
|
||||
|
||||
export type AdminPortalSSODefaults = {
|
||||
tenant: string;
|
||||
product: string;
|
||||
redirectUrl: string;
|
||||
defaultRedirectUrl: string;
|
||||
};
|
||||
|
||||
export function renderFieldList(args: {
|
||||
isEditView?: boolean;
|
||||
formObj: Record<string, string>;
|
||||
|
@ -99,7 +124,7 @@ export function renderFieldList(args: {
|
|||
required = true,
|
||||
},
|
||||
}: FieldCatalog) => {
|
||||
const disabled = args.isEditView && editable === false;
|
||||
const disabled = editable === false;
|
||||
const value =
|
||||
disabled && typeof formatForDisplay === 'function'
|
||||
? formatForDisplay(args.formObj[key])
|
||||
|
@ -134,14 +159,18 @@ export function renderFieldList(args: {
|
|||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj)}
|
||||
className={`textarea-bordered textarea h-24 w-full ${isArray ? 'whitespace-pre' : ''}`}
|
||||
className={`textarea-bordered textarea h-24 w-full ${isArray ? 'whitespace-pre' : ''} ${
|
||||
isHidden ? (isHidden(args.formObj[key]) == true ? 'hidden' : '') : ''
|
||||
}`}
|
||||
rows={rows}
|
||||
/>
|
||||
) : type === 'checkbox' ? (
|
||||
<>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className='inline-block align-middle text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||
className={`inline-block align-middle text-sm font-medium text-gray-900 dark:text-gray-300 ${
|
||||
isHidden ? (isHidden(args.formObj[key]) == true ? 'hidden' : '') : ''
|
||||
}`}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
|
@ -165,7 +194,9 @@ export function renderFieldList(args: {
|
|||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj)}
|
||||
className='input-bordered input w-full'
|
||||
className={`input-bordered input w-full ${
|
||||
isHidden ? (isHidden(args.formObj[key]) == true ? 'hidden' : '') : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -64,6 +64,14 @@ const jacksonOptions: JacksonOption = {
|
|||
process.env.BOXYHQ_NO_ANALYTICS === 'true',
|
||||
};
|
||||
|
||||
const adminPortalSSODefaults = {
|
||||
tenant: process.env.ADMIN_PORTAL_SSO_TENANT || '_jackson_boxyhq',
|
||||
product: process.env.ADMIN_PORTAL_SSO_PRODUCT || '_jackson_admin_portal',
|
||||
redirectUrl: externalUrl,
|
||||
defaultRedirectUrl: `${externalUrl}/api/auth/callback/boxyhq-saml`,
|
||||
};
|
||||
|
||||
export { adminPortalSSODefaults };
|
||||
export { retraced as retracedOptions };
|
||||
export { apiKeys };
|
||||
export { jacksonOptions };
|
||||
|
|
|
@ -2,7 +2,6 @@ import { Storable } from '@boxyhq/saml-jackson';
|
|||
import DB from 'npm/src/db/db';
|
||||
import { jacksonOptions } from './env';
|
||||
import type { AdapterUser, VerificationToken } from 'next-auth/adapters';
|
||||
import { validateEmailWithACL } from './utils';
|
||||
import defaultDb from 'npm/src/db/defaultDb';
|
||||
|
||||
const g = global as any;
|
||||
|
@ -27,17 +26,15 @@ export default function Adapter() {
|
|||
return;
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
// ?? we already do the validation in signIn callback (see pages/api/auth/[...nextauth].ts)
|
||||
if (validateEmailWithACL(email)) {
|
||||
return {
|
||||
id: email,
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
role: 'admin',
|
||||
emailVerified: new Date(),
|
||||
} as AdapterUser;
|
||||
}
|
||||
return null;
|
||||
return email
|
||||
? ({
|
||||
id: email,
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
role: 'admin',
|
||||
emailVerified: new Date(),
|
||||
} as AdapterUser)
|
||||
: null;
|
||||
},
|
||||
async getUserByAccount({ providerAccountId, provider }) {
|
||||
return;
|
||||
|
@ -47,17 +44,13 @@ export default function Adapter() {
|
|||
return null;
|
||||
}
|
||||
const email = user.id;
|
||||
// ?? we already do the validation in signIn callback (see pages/api/auth/[...nextauth].ts)
|
||||
if (validateEmailWithACL(email)) {
|
||||
return {
|
||||
id: email,
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
role: 'admin',
|
||||
emailVerified: new Date(),
|
||||
} as AdapterUser;
|
||||
}
|
||||
return null;
|
||||
return {
|
||||
id: email,
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
role: 'admin',
|
||||
emailVerified: new Date(),
|
||||
} as AdapterUser;
|
||||
},
|
||||
// will be required in a future release, but are not yet invoked
|
||||
async deleteUser(userId) {
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"idp_type": "IdP Type",
|
||||
"idp_entity_id": "IdP Entity ID",
|
||||
"last_name": "Last Name",
|
||||
"login_with_sso": "Login with SSO",
|
||||
"login_success_toast": "A sign in link has been sent to your email address.",
|
||||
"link_generated": "Link Generated",
|
||||
"link_regenerated": "Link Regenerated",
|
||||
|
@ -111,6 +112,8 @@
|
|||
"view": "View",
|
||||
"view_idp_configuration": "View IdP Configuration",
|
||||
"edit": "Edit",
|
||||
"settings": "Settings",
|
||||
"admin_portal_sso": "SSO for Admin Portal",
|
||||
"sp_metadata_description": "The metadata file that your customers who use federated management systems like OpenAthens and Shibboleth will need to configure your service.",
|
||||
"sp_config_description": "The configuration setup guide that your customers will need to refer to when setting up SAML application with their Identity Provider.",
|
||||
"saml_public_cert_description": "The SAML Public Certificate if you want to enable encryption with your Identity Provider.",
|
||||
|
|
|
@ -75,6 +75,10 @@ module.exports = {
|
|||
source: '/.well-known',
|
||||
destination: '/well-known',
|
||||
},
|
||||
{
|
||||
source: '/admin/settings',
|
||||
destination: '/admin/settings/sso-connection',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -35,6 +35,7 @@
|
|||
"test": "cd npm && npm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/react-ui": "file:sdk/ui/react",
|
||||
"@boxyhq/saml-jackson": "file:./npm",
|
||||
"@heroicons/react": "2.0.13",
|
||||
"@opentelemetry/api": "1.3.0",
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
|
||||
import { useSession, getCsrfToken, signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useSession, getCsrfToken, signIn, SessionProvider } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import Link from 'next/link';
|
||||
import { ButtonOutline } from '@components/ButtonOutline';
|
||||
import Loading from '@components/Loading';
|
||||
import { Login as SSOLogin } from '@boxyhq/react-ui';
|
||||
import { adminPortalSSODefaults } from '@lib/env';
|
||||
|
||||
const Login = ({ csrfToken }: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
||||
const Login = ({ csrfToken, tenant, product }: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const { status } = useSession();
|
||||
|
@ -56,6 +55,10 @@ const Login = ({ csrfToken }: InferGetServerSidePropsType<typeof getServerSidePr
|
|||
}
|
||||
};
|
||||
|
||||
const onSSOSubmit = async (ssoIdentifier: string) => {
|
||||
await signIn('boxyhq-saml', undefined, { client_id: ssoIdentifier });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex min-h-screen flex-col items-center justify-center'>
|
||||
|
@ -90,12 +93,21 @@ const Login = ({ csrfToken }: InferGetServerSidePropsType<typeof getServerSidePr
|
|||
</label>
|
||||
</div>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<ButtonPrimary type='submit' loading={loading} className='btn-block'>
|
||||
<ButtonOutline type='submit' loading={loading} className='btn-block'>
|
||||
{t('send_magic_link')}
|
||||
</ButtonPrimary>
|
||||
</ButtonOutline>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SSOLogin
|
||||
buttonText={t('login_with_sso')}
|
||||
ssoIdentifier={`tenant=${tenant}&product=${product}`}
|
||||
onSubmit={onSSOSubmit}
|
||||
classNames={{
|
||||
container: 'mt-2',
|
||||
button: 'btn-outline btn-block btn',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Link href='/.well-known' className='my-3 text-sm underline' target='_blank'>
|
||||
|
@ -112,9 +124,12 @@ Login.getLayout = function getLayout(page: ReactElement) {
|
|||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { locale }: GetServerSidePropsContext = context;
|
||||
const { tenant, product } = adminPortalSSODefaults;
|
||||
return {
|
||||
props: {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
tenant,
|
||||
product,
|
||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import type { GetServerSidePropsContext, NextPage } from 'next';
|
||||
import useSWR from 'swr';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { fetcher } from '@lib/ui/utils';
|
||||
import EditConnection from '@components/connection/EditConnection';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import Loading from '@components/Loading';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||
|
||||
const EditSSOConnection: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { id } = router.query as { id: string };
|
||||
|
||||
const { data, error, isLoading } = useSWR<ApiSuccess<SAMLSSORecord | OIDCSSORecord>, ApiError>(
|
||||
id ? `/api/admin/connections/${id}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
errorToast(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EditConnection connection={data?.data} isSettingsView />;
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default EditSSOConnection;
|
|
@ -0,0 +1,17 @@
|
|||
import type { GetServerSidePropsContext, NextPage } from 'next';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import ConnectionList from '@components/connection/ConnectionList';
|
||||
|
||||
const ConnectionsIndexPageForSettings: NextPage = () => {
|
||||
return <ConnectionList isSettingsView />;
|
||||
};
|
||||
|
||||
export default ConnectionsIndexPageForSettings;
|
||||
|
||||
export async function getStaticProps({ locale }: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import type { GetServerSidePropsContext, NextPage } from 'next';
|
||||
import CreateConnection from '@components/connection/CreateConnection';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import type { AdminPortalSSODefaults } from '@components/connection/utils';
|
||||
import { adminPortalSSODefaults } from '@lib/env';
|
||||
|
||||
type Props = {
|
||||
adminPortalSSODefaults: AdminPortalSSODefaults;
|
||||
};
|
||||
|
||||
const NewSSOConnection: NextPage<Props> = ({ adminPortalSSODefaults }) => {
|
||||
return <CreateConnection isSettingsView adminPortalSSODefaults={adminPortalSSODefaults} />;
|
||||
};
|
||||
|
||||
export async function getStaticProps({ locale }: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
|
||||
adminPortalSSODefaults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default NewSSOConnection;
|
|
@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
|||
|
||||
import jackson from '@lib/jackson';
|
||||
import { strategyChecker } from '@lib/utils';
|
||||
import { adminPortalSSODefaults } from '@lib/env';
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { method } = req;
|
||||
|
@ -23,14 +24,26 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
|
||||
// Get all connections
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { adminController } = await jackson();
|
||||
const { adminController, connectionAPIController } = await jackson();
|
||||
|
||||
const { pageOffset, pageLimit } = req.query as {
|
||||
const { pageOffset, pageLimit, isSystemSSO } = req.query as {
|
||||
pageOffset: string;
|
||||
pageLimit: string;
|
||||
isSystemSSO?: string; // if present will be '' else undefined
|
||||
};
|
||||
|
||||
const connections = await adminController.getAllConnection(+(pageOffset || 0), +(pageLimit || 0));
|
||||
const { tenant: adminPortalSSOTenant, product: adminPortalSSOProduct } = adminPortalSSODefaults;
|
||||
|
||||
const connections =
|
||||
isSystemSSO === undefined
|
||||
? (await adminController.getAllConnection(+(pageOffset || 0), +(pageLimit || 0)))?.map((conn) => ({
|
||||
...conn,
|
||||
isSystemSSO: adminPortalSSOTenant === conn.tenant && adminPortalSSOProduct === conn.product,
|
||||
}))
|
||||
: await connectionAPIController.getConnections({
|
||||
tenant: adminPortalSSOTenant,
|
||||
product: adminPortalSSOProduct,
|
||||
});
|
||||
|
||||
return res.json({ data: connections });
|
||||
};
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import Adapter from '@lib/nextAuthAdapter';
|
||||
import NextAuth from 'next-auth';
|
||||
import EmailProvider from 'next-auth/providers/email';
|
||||
import BoxyHQSAMLProvider from 'next-auth/providers/boxyhq-saml';
|
||||
import { validateEmailWithACL } from '@lib/utils';
|
||||
import { jacksonOptions as env } from '@lib/env';
|
||||
import { sessionName } from '@lib/constants';
|
||||
|
||||
export default NextAuth({
|
||||
|
@ -9,6 +11,16 @@ export default NextAuth({
|
|||
colorScheme: 'light',
|
||||
},
|
||||
providers: [
|
||||
BoxyHQSAMLProvider({
|
||||
authorization: { params: { scope: '' } },
|
||||
issuer: env.externalUrl,
|
||||
clientId: 'dummy',
|
||||
clientSecret: 'dummy',
|
||||
httpOptions: {
|
||||
timeout: 30000,
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: process.env.SMTP_HOST,
|
||||
|
@ -40,12 +52,11 @@ export default NextAuth({
|
|||
},
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user }): Promise<boolean> {
|
||||
async signIn({ user, account }): Promise<boolean> {
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return validateEmailWithACL(user.email);
|
||||
return account?.provider === 'boxyhq-saml' ? true : validateEmailWithACL(user.email);
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
|
|
Loading…
Reference in New Issue