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 Link from 'next/link';
import classNames from 'classnames';
@ -12,11 +12,17 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
const { asPath } = useRouter();
const menus = [
{
href: '/admin/dashboard',
text: 'Dashboard',
icon: HomeIcon,
active: asPath.includes('/admin/dashboard'),
},
{
href: '/admin/connection',
text: 'SSO Connections',
text: 'Enterprise SSO',
icon: ShieldCheckIcon,
active: asPath.includes('/admin/saml'),
active: asPath.includes('/admin/connection'),
},
{
href: '/admin/directory-sync',

View File

@ -268,14 +268,14 @@ const AddEdit = ({ connection }: AddEditProps) => {
return (
<>
<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' />
<span>Back</span>
</a>
</Link>
<div>
<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>
{!isEditView && (
<div className='mb-4 flex'>
@ -388,7 +388,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
readOnly={readOnly}
maxLength={maxLength}
onChange={getHandleChange()}
className={`textarea textarea-bordered h-24 w-full ${
className={`textarea-bordered textarea h-24 w-full ${
isArray ? 'whitespace-pre' : ''
}`}
rows={rows}
@ -408,7 +408,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
readOnly={readOnly}
maxLength={maxLength}
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}
maxLength={maxLength}
onChange={getHandleChange()}
className='input input-bordered w-full'
className='input-bordered input w-full'
/>
)}
</div>
@ -429,7 +429,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
}
)}
<div className='flex'>
<button type='submit' className='btn btn-primary'>
<button type='submit' className='btn-primary btn'>
Save Changes
</button>
<p
@ -461,7 +461,7 @@ const AddEdit = ({ connection }: AddEditProps) => {
</div>
<button
type='button'
className='btn btn-error'
className='btn-error btn'
onClick={toggleDelConfirm}
data-modal-toggle='popup-modal'>
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 (
<>
<Head>
<title>SAML Jackson - BoxyHQ</title>
<title>Admin UI | BoxyHQ</title>
<link rel='icon' href='/favicon.ico' />
</Head>
<Sidebar isOpen={isOpen} setIsOpen={setIsOpen} />

View File

@ -1,16 +1,16 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import micromatch from 'micromatch';
export const validateEmailWithACL = (email) => {
export const validateEmailWithACL = (email: string) => {
const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined;
const acl = NEXTAUTH_ACL?.split(',');
if (acl) {
if (micromatch.isMatch(email, acl)) {
return true;
}
if (!NEXTAUTH_ACL) {
return false;
}
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 { Session } from 'next-auth';
import type { NextPage } from 'next';
import { SessionProvider } from 'next-auth/react';
import { useRouter } from 'next/router';
import { Toaster } from 'react-hot-toast';
import { appWithTranslation } from 'next-i18next';
import { ReactElement, ReactNode } from 'react';
import { AccountLayout } from '@components/layouts';
import '../styles/globals.css';
function MyApp({
Component,
pageProps: { session, ...pageProps },
}: AppProps<{
session: Session;
}>) {
const unauthenticatedRoutes = [
'/',
'/admin/auth/login',
'/well-known/saml-configuration',
'/oauth/jwks',
'/idp/select',
'/error',
];
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const { pathname } = useRouter();
if (pathname !== '/' && !pathname.startsWith('/admin')) {
return <Component {...pageProps} />;
const { session, ...props } = 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 (
<SessionProvider session={session}>
<AccountLayout>
<Component {...pageProps} />
<Toaster />
<Component {...props} />
<Toaster toastOptions={{ duration: 5000 }} />
</AccountLayout>
</SessionProvider>
);
}
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 (
<div>
<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`}>
<a className='btn btn-primary' data-test-id='create-connection'>
<a className='btn-primary btn' data-test-id='create-connection'>
+ New Connection
</a>
</Link>
@ -95,7 +95,7 @@ const Connections: NextPage = () => {
<div className='mt-4 flex justify-center'>
<button
type='button'
className='btn btn-outline'
className='btn-outline btn'
disabled={paginate.page === 0}
aria-label='Previous'
onClick={() =>
@ -111,7 +111,7 @@ const Connections: NextPage = () => {
&nbsp;&nbsp;&nbsp;&nbsp;
<button
type='button'
className='btn btn-outline'
className='btn-outline btn'
disabled={connections.length === 0 || connections.length < paginate.pageLimit}
onClick={() =>
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: {
async signIn({ user }): Promise<boolean> {
if (!user.email) {
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
// @ts-ignore
adapter: Adapter(),

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 22 KiB