Add a new UI for the login screen (#647)

* Add a new UI for the login screen

* Update the error page UI

* text tweak, fixed active element in menu bar, updated logo to generic one

* text tweaks

* fixed unauth route, it needs to be the original one, not the redirected one

* Display the list of well-known URLs on login screen

* Display the well-known URLs on the dashboard

* added description to links

* tweak to login page

Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Kiran K 2022-11-04 00:18:32 +05:30 committed by GitHub
parent f95f8714ee
commit 031aac3e21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 261 additions and 49 deletions

View File

@ -1,4 +1,4 @@
import { ShieldCheckIcon, UsersIcon } from '@heroicons/react/20/solid'; import { ShieldCheckIcon, UsersIcon, HomeIcon } from '@heroicons/react/20/solid';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import classNames from 'classnames'; import classNames from 'classnames';
@ -12,11 +12,17 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
const { asPath } = useRouter(); const { asPath } = useRouter();
const menus = [ const menus = [
{
href: '/admin/dashboard',
text: 'Dashboard',
icon: HomeIcon,
active: asPath.includes('/admin/dashboard'),
},
{ {
href: '/admin/connection', href: '/admin/connection',
text: 'SSO Connections', text: 'Enterprise SSO',
icon: ShieldCheckIcon, icon: ShieldCheckIcon,
active: asPath.includes('/admin/saml'), active: asPath.includes('/admin/connection'),
}, },
{ {
href: '/admin/directory-sync', href: '/admin/directory-sync',

View File

@ -268,14 +268,14 @@ const AddEdit = ({ connection }: AddEditProps) => {
return ( return (
<> <>
<Link href='/admin/connection'> <Link href='/admin/connection'>
<a className='btn btn-outline items-center space-x-2'> <a className='btn-outline btn items-center space-x-2'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' /> <ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span>Back</span> <span>Back</span>
</a> </a>
</Link> </Link>
<div> <div>
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'> <h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
{isEditView ? 'Edit Connection' : 'Create Connection'} {isEditView ? 'Edit SSO Connection' : 'Create SSO Connection'}
</h2> </h2>
{!isEditView && ( {!isEditView && (
<div className='mb-4 flex'> <div className='mb-4 flex'>
@ -388,7 +388,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
readOnly={readOnly} readOnly={readOnly}
maxLength={maxLength} maxLength={maxLength}
onChange={getHandleChange()} onChange={getHandleChange()}
className={`textarea textarea-bordered h-24 w-full ${ className={`textarea-bordered textarea h-24 w-full ${
isArray ? 'whitespace-pre' : '' isArray ? 'whitespace-pre' : ''
}`} }`}
rows={rows} rows={rows}
@ -408,7 +408,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
readOnly={readOnly} readOnly={readOnly}
maxLength={maxLength} maxLength={maxLength}
onChange={getHandleChange({ key: 'checked' })} onChange={getHandleChange({ key: 'checked' })}
className='checkbox checkbox-primary ml-5 align-middle' className='checkbox-primary checkbox ml-5 align-middle'
/> />
</> </>
) : ( ) : (
@ -421,7 +421,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
readOnly={readOnly} readOnly={readOnly}
maxLength={maxLength} maxLength={maxLength}
onChange={getHandleChange()} onChange={getHandleChange()}
className='input input-bordered w-full' className='input-bordered input w-full'
/> />
)} )}
</div> </div>
@ -429,7 +429,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
} }
)} )}
<div className='flex'> <div className='flex'>
<button type='submit' className='btn btn-primary'> <button type='submit' className='btn-primary btn'>
Save Changes Save Changes
</button> </button>
<p <p
@ -461,7 +461,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
</div> </div>
<button <button
type='button' type='button'
className='btn btn-error' className='btn-error btn'
onClick={toggleDelConfirm} onClick={toggleDelConfirm}
data-modal-toggle='popup-modal'> data-modal-toggle='popup-modal'>
Delete Delete

View File

@ -0,0 +1,48 @@
import { ArrowRightOnRectangleIcon } from '@heroicons/react/20/solid';
const links = [
{
title: 'SP Metadata',
description:
'The metadata file that your customers who use federated management systems like OpenAthens and Shibboleth will need to configure your service.',
href: '/.well-known/sp-metadata',
},
{
title: 'SAML Configuration',
description:
'The configuration setup guide that your customers will need to refer to when setting up SAML application with their Identity Provider.',
href: '/.well-known/saml-configuration',
},
{
title: 'OpenID Configuration',
description:
'Our OpenID configuration URI which your customers will need if they are connecting via OAuth 2.0 or Open ID Connect.',
href: '/.well-known/openid-configuration',
},
];
const WellKnownURLs = ({ className }: { className?: string }) => {
return (
<div className={className}>
<p>Here are the set of URIs you might commonly need access to:</p>
<br />
<ul className='flex flex-col space-y-1'>
{links.map((link) => {
return (
<li key={link.href} className='text-sm'>
<p>{link.description}</p>
<a href={link.href} target='_blank' rel='noreferrer'>
<div className='link flex'>
<ArrowRightOnRectangleIcon className='mr-1 h-5 w-5' /> {link.title}
</div>
</a>
<br />
</li>
);
})}
</ul>
</div>
);
};
export default WellKnownURLs;

View File

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

View File

@ -1,16 +1,16 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import micromatch from 'micromatch'; import micromatch from 'micromatch';
export const validateEmailWithACL = (email) => { export const validateEmailWithACL = (email: string) => {
const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined; const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined;
const acl = NEXTAUTH_ACL?.split(',');
if (acl) { if (!NEXTAUTH_ACL) {
if (micromatch.isMatch(email, acl)) { return false;
return true;
}
} }
return false;
const acl = NEXTAUTH_ACL.split(',');
return micromatch.isMatch(email, acl);
}; };
/** /**

View File

@ -1,33 +1,65 @@
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
import type { Session } from 'next-auth'; import type { Session } from 'next-auth';
import type { NextPage } from 'next';
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { appWithTranslation } from 'next-i18next'; import { appWithTranslation } from 'next-i18next';
import { ReactElement, ReactNode } from 'react';
import { AccountLayout } from '@components/layouts'; import { AccountLayout } from '@components/layouts';
import '../styles/globals.css'; import '../styles/globals.css';
function MyApp({ const unauthenticatedRoutes = [
Component, '/',
pageProps: { session, ...pageProps }, '/admin/auth/login',
}: AppProps<{ '/well-known/saml-configuration',
session: Session; '/oauth/jwks',
}>) { '/idp/select',
'/error',
];
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const { pathname } = useRouter(); const { pathname } = useRouter();
if (pathname !== '/' && !pathname.startsWith('/admin')) { const { session, ...props } = pageProps;
return <Component {...pageProps} />;
const getLayout = Component.getLayout;
// If a page level layout exists, use it
if (getLayout) {
return (
<>
{getLayout(<Component {...props} />)}
<Toaster toastOptions={{ duration: 5000 }} />
</>
);
}
if (unauthenticatedRoutes.includes(pathname)) {
return <Component {...props} />;
} }
return ( return (
<SessionProvider session={session}> <SessionProvider session={session}>
<AccountLayout> <AccountLayout>
<Component {...pageProps} /> <Component {...props} />
<Toaster /> <Toaster toastOptions={{ duration: 5000 }} />
</AccountLayout> </AccountLayout>
</SessionProvider> </SessionProvider>
); );
} }
export default appWithTranslation<never>(MyApp); export default appWithTranslation<never>(MyApp);
export type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
pageProps: {
session?: Session;
};
};
export type NextPageWithLayout<P = Record<string, unknown>> = NextPage<P> & {
getLayout?: (page: ReactElement) => ReactNode;
};

113
pages/admin/auth/login.tsx Normal file
View File

@ -0,0 +1,113 @@
import type { ReactElement } from 'react';
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
import { useSession, getCsrfToken, signIn } from 'next-auth/react';
import { useRouter } from 'next/router';
import Image from 'next/image';
import toast from 'react-hot-toast';
import { SessionProvider } from 'next-auth/react';
import { useState } from 'react';
import classNames from 'classnames';
import WellKnownURLs from '@components/connection/WellKnownURLs';
const Login = ({ csrfToken }: InferGetServerSidePropsType<typeof getServerSideProps>) => {
const router = useRouter();
const { status } = useSession();
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
if (status === 'authenticated') {
router.push('/admin/connection');
}
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const response = await signIn('email', {
email,
csrfToken,
redirect: false,
});
setLoading(false);
if (!response) {
return;
}
const { error } = response;
if (error) {
toast.error(error);
return;
}
toast.success('A sign in link has been sent to your email address.');
};
return (
<>
<div className='flex min-h-screen flex-col items-center justify-center'>
<div className='flex flex-col'>
<div className='mt-4 border p-6 text-left shadow-md'>
<div className='space-y-3'>
<div className='flex justify-center'>
<Image src='/logo.png' alt='BoxyHQ logo' width={50} height={50} />
</div>
<h2 className='text-center text-3xl font-extrabold text-gray-900'>BoxyHQ Admin UI</h2>
<p className='text-center text-sm text-gray-600'>
Enterprise readiness for B2B SaaS, straight out of the box.
</p>
</div>
<form onSubmit={onSubmit}>
<div className='mt-8'>
<div>
<label className='block' htmlFor='email'>
Email
<label>
<input
type='email'
placeholder='Email'
className='input-bordered input mb-5 mt-2 w-full rounded-md'
required
onChange={(e) => {
setEmail(e.target.value);
}}
value={email}
/>
</label>
</label>
</div>
<div className='flex items-baseline justify-between'>
<button
className={classNames('btn-primary btn-block btn rounded-md', loading ? 'loading' : '')}
type='submit'>
Send Magic Link
</button>
</div>
</div>
</form>
</div>
</div>
<WellKnownURLs className='mt-5 border p-5' />
</div>
</>
);
};
Login.getLayout = function getLayout(page: ReactElement) {
return <SessionProvider>{page}</SessionProvider>;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
return {
props: {
csrfToken: await getCsrfToken(context),
},
};
};
export default Login;

View File

@ -31,9 +31,9 @@ const Connections: NextPage = () => {
return ( return (
<div> <div>
<div className='mb-5 flex items-center justify-between'> <div className='mb-5 flex items-center justify-between'>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>Connections</h2> <h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>Enterprise SSO</h2>
<Link href={`/admin/connection/new`}> <Link href={`/admin/connection/new`}>
<a className='btn btn-primary' data-test-id='create-connection'> <a className='btn-primary btn' data-test-id='create-connection'>
+ New Connection + New Connection
</a> </a>
</Link> </Link>
@ -95,7 +95,7 @@ const Connections: NextPage = () => {
<div className='mt-4 flex justify-center'> <div className='mt-4 flex justify-center'>
<button <button
type='button' type='button'
className='btn btn-outline' className='btn-outline btn'
disabled={paginate.page === 0} disabled={paginate.page === 0}
aria-label='Previous' aria-label='Previous'
onClick={() => onClick={() =>
@ -111,7 +111,7 @@ const Connections: NextPage = () => {
&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;
<button <button
type='button' type='button'
className='btn btn-outline' className='btn-outline btn'
disabled={connections.length === 0 || connections.length < paginate.pageLimit} disabled={connections.length === 0 || connections.length < paginate.pageLimit}
onClick={() => onClick={() =>
setPaginate((curState) => ({ setPaginate((curState) => ({

View File

@ -0,0 +1,9 @@
import type { NextPage } from 'next';
import WellKnownURLs from '@components/connection/WellKnownURLs';
const Dashboard: NextPage = () => {
return <WellKnownURLs />;
};
export default Dashboard;

View File

@ -38,16 +38,18 @@ export default NextAuth({
}, },
}, },
}, },
secret: process.env.NEXTAUTH_SECRET,
callbacks: { callbacks: {
async signIn({ user }): Promise<boolean> { async signIn({ user }): Promise<boolean> {
if (!user.email) { if (!user.email) {
return false; return false;
} }
const email = user.email;
return validateEmailWithACL(email); return validateEmailWithACL(user.email);
}, },
}, },
pages: {
signIn: '/admin/auth/login',
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
adapter: Adapter(), adapter: Adapter(),

View File

@ -20,7 +20,7 @@ export default function Error() {
let statusText = ''; let statusText = '';
if (typeof statusCode === 'number') { if (typeof statusCode === 'number') {
if (statusCode >= 400 && statusCode <= 499) { if (statusCode >= 400 && statusCode <= 499) {
statusText = 'client-side error'; statusText = 'client error';
} }
if (statusCode >= 500 && statusCode <= 599) { if (statusCode >= 500 && statusCode <= 599) {
statusText = 'server error'; statusText = 'server error';
@ -32,19 +32,21 @@ export default function Error() {
} }
return ( return (
<div className='h-full'> <div className='flex h-screen'>
<div className='h-[20%] translate-y-[100%] px-[20%] text-[hsl(152,56%,40%)]'> <div className='m-auto'>
<svg className='mb-5 h-10 w-10' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={2}> <section className='bg-white dark:bg-gray-900'>
<path <div className='mx-auto max-w-screen-xl py-8 px-4 lg:py-16 lg:px-6'>
strokeLinecap='round' <div className='mx-auto max-w-screen-sm text-center'>
strokeLinejoin='round' <h1 className='mb-4 text-7xl font-extrabold tracking-tight text-primary lg:text-9xl'>
d='M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' {error.statusCode}
/> </h1>
</svg> <p className='mb-4 text-3xl font-bold tracking-tight text-gray-900 dark:text-white md:text-4xl'>
<h1 className='text-xl font-extrabold md:text-6xl'>{error.statusCode}</h1> {statusText}
<h2 className='uppercase'>{statusText}</h2> </p>
<p className='mt-6 inline-block'>SAML error: </p> <p className='mb-4 text-lg font-light'>SAML error: {message}</p>
<p className='mr-2 text-xl font-bold'>{message}</p> </div>
</div>
</section>
</div> </div>
</div> </div>
); );

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 22 KiB