mirror of https://github.com/boxyhq/jackson.git
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:
parent
f95f8714ee
commit
031aac3e21
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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} />
|
||||
|
|
14
lib/utils.ts
14
lib/utils.ts
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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 = () => {
|
|||
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-outline'
|
||||
className='btn-outline btn'
|
||||
disabled={connections.length === 0 || connections.length < paginate.pageLimit}
|
||||
onClick={() =>
|
||||
setPaginate((curState) => ({
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import type { NextPage } from 'next';
|
||||
|
||||
import WellKnownURLs from '@components/connection/WellKnownURLs';
|
||||
|
||||
const Dashboard: NextPage = () => {
|
||||
return <WellKnownURLs />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
|
@ -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(),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 22 KiB |
Loading…
Reference in New Issue