Error pages for Jackson browser based flows (#134)

* Add cookie serialization/parse utils

* Escape hatch for error page

* Draft impl for error page

* Error page redirect for /saml

* Const for cookie name and delete cookie once read

* Error page layout

* Uncomment code

* Render nothing if statusCode is null

* Minor formatting fix

* Remove cookie npm dependency

* Remove types for cookie npm

* Remove deleteCookie op

* Simplify error page layout

* Move hooks to lib/ui

* Sort class name

* Separate ui/server utils

* Use setErrorCookie & getErrorCookie

* updated package-lock.json

Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Aswin V 2022-03-29 21:03:51 +05:30 committed by GitHub
parent 3313580ed8
commit 58b9769373
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 125 additions and 47 deletions

View File

@ -4,17 +4,17 @@ import Link from 'next/link';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import Logo from '../public/logo.png';
import { LogoutIcon, MenuIcon, ShieldCheckIcon } from '@heroicons/react/outline';
import useOnClickOutside from 'hooks/useOnClickOutside';
import ActiveLink from './ActiveLink';
import useKeyPress from 'hooks/useKeyPress';
import useMediaQuery from 'hooks/useMediaQuery';
import useOnClickOutside from '@lib/ui/hooks/useOnClickOutside';
import useKeyPress from '@lib/ui/hooks/useKeyPress';
import useMediaQuery from '@lib/ui/hooks/useMediaQuery';
import { useSession, signOut } from 'next-auth/react';
const navigation = [
{
path: '/admin/saml/config',
text: <span className='ml-4'>SAML Configurations</span>,
icon: <ShieldCheckIcon className='w-5 h-5' aria-hidden />,
icon: <ShieldCheckIcon className='h-5 w-5' aria-hidden />,
},
];
@ -67,10 +67,10 @@ function Layout({ children }: { children: ReactNode }) {
</Head>
<header
role='banner'
className='fixed left-0 right-0 z-10 p-5 bg-white border-b border-gray-900/10 dark:border-gray-300/10 dark:bg-gray-900 md:px-12'>
className='fixed left-0 right-0 z-10 border-b border-gray-900/10 bg-white p-5 dark:border-gray-300/10 dark:bg-gray-900 md:px-12'>
<div className='flex items-center justify-between'>
<Link href='/'>
<a title='Go to dashboard' className='flex items-center ml-10 font-bold leading-10 md:ml-0'>
<a title='Go to dashboard' className='ml-10 flex items-center font-bold leading-10 md:ml-0'>
<Image src={Logo} alt='BoxyHQ' layout='fixed' width={36} height={36} />
<h1 className='ml-2 text-secondary hover:text-primary dark:text-white'>SAML Jackson</h1>
</a>
@ -78,7 +78,7 @@ function Layout({ children }: { children: ReactNode }) {
<div className='relative'>
<button
type='button'
className='flex items-center justify-center w-8 h-8 uppercase rounded-full bg-secondary text-cyan-50'
className='flex h-8 w-8 items-center justify-center rounded-full bg-secondary uppercase text-cyan-50'
aria-label='user settings'
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}>
@ -86,14 +86,14 @@ function Layout({ children }: { children: ReactNode }) {
</button>
{isOpen && (
<ul
className='absolute right-0 z-50 py-1 overflow-hidden text-sm font-semibold bg-white rounded-lg shadow-lg dark:highlight-white/5 top-full w-36 text-slate-700 ring-1 ring-slate-900/10 dark:bg-slate-800 dark:text-slate-300 dark:ring-0'
className='dark:highlight-white/5 absolute right-0 top-full z-50 w-36 overflow-hidden rounded-lg bg-white py-1 text-sm font-semibold text-slate-700 shadow-lg ring-1 ring-slate-900/10 dark:bg-slate-800 dark:text-slate-300 dark:ring-0'
ref={userDropDownRef}>
<li>
<button
type='button'
className='flex items-center justify-center w-full h-8 px-2 py-1 cursor-pointer'
className='flex h-8 w-full cursor-pointer items-center justify-center px-2 py-1'
onClick={() => signOut()}>
<LogoutIcon className='w-5 h-5' aria-hidden />
<LogoutIcon className='h-5 w-5' aria-hidden />
Log out
</button>
</li>
@ -114,7 +114,7 @@ function Layout({ children }: { children: ReactNode }) {
aria-controls='menu'
onClick={() => setIsSideNavOpen((curState) => !curState)}>
<span className='sr-only'>Menu</span>
<MenuIcon aria-hidden='true' className='w-6 h-6 text-black dark:text-slate-50'></MenuIcon>
<MenuIcon aria-hidden='true' className='h-6 w-6 text-black dark:text-slate-50'></MenuIcon>
</button>
<ul
className={`fixed top-0 bottom-0 left-0 w-60 border-r border-gray-900/10 p-6 transition-transform dark:border-gray-300/10 ${
@ -140,9 +140,6 @@ function Layout({ children }: { children: ReactNode }) {
className='relative top-[81px] h-[calc(100%_-_81px)] overflow-auto p-6 md:left-60 md:w-[calc(100%_-_theme(space.60))]'>
{children}
</main>
{/* <footer role="contentinfo">
<p>&copy; 2022 BoxyHQ, Inc.</p>
</footer> */}
</>
);
}

32
lib/ui/utils.ts Normal file
View File

@ -0,0 +1,32 @@
// returns the cookie with the given name,
// or undefined if not found
export function getErrorCookie() {
const matches = document.cookie.match(
new RegExp('(?:^|; )' + 'jackson_error'.replace(/([.$?*|{}()[]\\\/\+^])/g, '\\$1') + '=([^;]*)')
);
return matches ? decodeURIComponent(matches[1]) : undefined;
}
export interface APIError extends Error {
info?: string;
status: number;
}
export const fetcher = async (url: string, queryParams = '') => {
const res = await fetch(`${url}${queryParams}`);
let resContent;
try {
resContent = await res.clone().json();
} catch (e) {
resContent = await res.clone().text();
}
if (!res.ok) {
const error = new Error('An error occurred while fetching the data.') as APIError;
// Attach extra info to the error object.
error.info = resContent;
error.status = res.status;
throw error;
}
return resContent;
};

View File

@ -1,4 +1,4 @@
import { NextApiRequest } from 'next';
import { NextApiRequest, NextApiResponse } from 'next';
import env from '@lib/env';
import micromatch from 'micromatch';
@ -16,30 +16,6 @@ export const extractAuthToken = (req: NextApiRequest) => {
return null;
};
export interface APIError extends Error {
info?: string;
status: number;
}
export const fetcher = async (url: string, queryParams = '') => {
const res = await fetch(`${url}${queryParams}`);
let resContent;
try {
resContent = await res.clone().json();
} catch (e) {
resContent = await res.clone().text();
}
if (!res.ok) {
const error = new Error('An error occurred while fetching the data.') as APIError;
// Attach extra info to the error object.
error.info = resContent;
error.status = res.status;
throw error;
}
return resContent;
};
export const validateEmailWithACL = (email) => {
const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined;
const acl = NEXTAUTH_ACL?.split(',');
@ -51,3 +27,15 @@ export const validateEmailWithACL = (email) => {
}
return false;
};
/**
* This sets `cookie` using the `res` object
*/
export const setErrorCookie = (res: NextApiResponse, value: unknown, options: { path?: string } = {}) => {
const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
let cookieContents = 'jackson_error' + '=' + stringValue;
if (options.path) {
cookieContents += '; Path=' + options.path;
}
res.setHeader('Set-Cookie', cookieContents);
};

View File

@ -86,4 +86,4 @@
"engines": {
"node": ">=14.18.1 <=16.x"
}
}
}

View File

@ -1,9 +1,16 @@
import Layout from '@components/Layout';
import { SessionProvider } from 'next-auth/react';
import type { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import '../styles/globals.css';
function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
const { pathname } = useRouter();
if (pathname === '/error') {
return <Component {...pageProps} />;
}
return (
<SessionProvider session={session}>
<Layout>

View File

@ -1,6 +1,6 @@
import { NextPage } from 'next';
import useSWR from 'swr';
import { fetcher } from '@lib/utils';
import { fetcher } from '@lib/ui/utils';
import AddEdit from '@components/saml/AddEdit';
import { useRouter } from 'next/router';

View File

@ -1,6 +1,6 @@
import { NextPage } from 'next';
import useSWR from 'swr';
import { fetcher } from '@lib/utils';
import { fetcher } from '@lib/ui/utils';
import Link from 'next/link';
import { ArrowSmLeftIcon, ArrowSmRightIcon, PencilAltIcon } from '@heroicons/react/outline';
import { useState } from 'react';

View File

@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { OAuthReqBody } from '@boxyhq/saml-jackson';
import { setErrorCookie } from '@lib/utils';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
@ -22,7 +23,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} catch (err: any) {
console.error('authorize error:', err);
const { message, statusCode = 500 } = err;
res.status(statusCode).send(message);
// set error in cookie redirect to error page
setErrorCookie(res, { message, statusCode }, { path: '/error' });
res.redirect('/error');
}
}

View File

@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { setErrorCookie } from '@lib/utils';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
@ -15,7 +16,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} catch (err: any) {
console.error('callback error:', err);
const { message, statusCode = 500 } = err;
res.status(statusCode).send(message);
// set error in cookie redirect to error page
setErrorCookie(res, { message, statusCode }, { path: '/error' });
res.redirect('/error');
}
}

51
pages/error.tsx Normal file
View File

@ -0,0 +1,51 @@
import { getErrorCookie } from '@lib/ui/utils';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export default function Error() {
const [error, setError] = useState({ statusCode: null, message: '' });
const { pathname } = useRouter();
useEffect(() => {
const _error = getErrorCookie() || '';
try {
const { statusCode, message } = JSON.parse(_error);
setError({ statusCode, message });
} catch (err) {
console.error('Unknown error format');
}
}, [pathname]);
const { statusCode, message } = error;
let statusText = '';
if (typeof statusCode === 'number') {
if (statusCode >= 400 && statusCode <= 499) {
statusText = 'client-side error';
}
if (statusCode >= 500 && statusCode <= 599) {
statusText = 'server error';
}
}
if (statusCode === null) {
return null;
}
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>
</div>
);
}

View File

@ -18,7 +18,6 @@ module.exports = {
primary: withOpacityValue('--color-primary'),
secondary: withOpacityValue('--color-secondary'),
},
extend: {},
},
plugins: [],
};