Skip to content

Commit

Permalink
refactor: improve hoc, logouthandler
Browse files Browse the repository at this point in the history
  • Loading branch information
BJvdA committed Apr 15, 2021
1 parent c049a66 commit d8af09a
Show file tree
Hide file tree
Showing 10 changed files with 163 additions and 49 deletions.
1 change: 1 addition & 0 deletions example/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class MyApp extends App {
export default process.env.PASSWORD_PROTECT
? withPasswordProtect(MyApp, {
loginComponentProps: {
backUrl: 'https://github.com/storyofams/next-password-protect',
logo: 'https://storyofams.com/public/[email protected]',
},
})
Expand Down
5 changes: 5 additions & 0 deletions example/src/pages/api/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { logoutHandler } from '@storyofams/next-password-protect';

export default logoutHandler({
cookieName: 'authorization',
});
10 changes: 10 additions & 0 deletions example/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@ import React from 'react';
import { NextSeo } from 'next-seo';

const Home = () => {
const clearCookie = async () => {
await fetch('/api/logout', {
method: 'post',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
window.location.reload();
};

return (
<>
<NextSeo
title="Next password protect"
description="Next password protect example app"
/>
<h1>Logged in page</h1>
<button onClick={clearCookie}>Logout</button>
</>
);
};
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ module.exports = {
'@testing-library/jest-dom/extend-expect',
'./jest.setup.js',
],
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
};
83 changes: 83 additions & 0 deletions src/api/__tests__/logoutHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { EventEmitter } from 'events';
import { createMocks } from 'node-mocks-http';

import { logoutHandler } from '../logoutHandler';

describe('[api] logoutHandler', () => {
it('should set max age on cookie to now to clear', async () => {
const { req, res } = createMocks(
{ method: 'POST', body: { password: 'password' } },
{ eventEmitter: EventEmitter },
);

const maxAge = 0;
const now = Date.now();

await logoutHandler()(req as any, res as any);

expect(res._getStatusCode()).toBe(200);
expect(res._getHeaders()).toHaveProperty(
'set-cookie',
`next-password-protect=; Max-Age=${maxAge}; Path=/; Expires=${new Date(
now + maxAge,
).toUTCString()}; HttpOnly`,
);

jest.restoreAllMocks();
});

it('should handle secure option', async () => {
const { req, res } = createMocks(
{ method: 'POST', body: { password: 'password' } },
{ eventEmitter: EventEmitter },
);

const maxAge = 0;
const now = Date.now();

await logoutHandler({ cookieSecure: true })(req as any, res as any);

expect(res._getStatusCode()).toBe(200);
expect(res._getHeaders()).toHaveProperty(
'set-cookie',
`next-password-protect=; Max-Age=${maxAge}; Path=/; Expires=${new Date(
now + maxAge,
).toUTCString()}; HttpOnly; Secure`,
);

jest.restoreAllMocks();
});

it('should throw on incorrect method', async () => {
const { req, res } = createMocks(
{ method: 'GET' },
{ eventEmitter: EventEmitter },
);

await logoutHandler()(req as any, res as any);

expect(res._getStatusCode()).toBe(500);

jest.restoreAllMocks();
});

it('should gracefully error', async () => {
const { req, res } = createMocks(
{ method: 'POST', body: { password: 'password' } },
{ eventEmitter: EventEmitter },
);

jest.spyOn(Date, 'now').mockImplementation(() => {
throw new Error();
});

await logoutHandler()(req as any, res as any);

expect(res._getStatusCode()).toBe(500);
expect(res._getData()).toBe(
JSON.stringify({ message: 'An error has occured.' }),
);

jest.restoreAllMocks();
});
});
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './passwordCheckHandler';
export * from './loginHandler';
export * from './logoutHandler';
38 changes: 38 additions & 0 deletions src/api/logoutHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Request, Response } from 'express';

import { sendJson } from './sendJson';
import { setCookie } from './setCookie';

interface PasswordProtectHandlerOptions {
/* @default next-password-protect */
cookieName?: string;
cookieSameSite?: boolean | 'lax' | 'none' | 'strict';
cookieSecure?: boolean;
}

export const logoutHandler = (
options?: PasswordProtectHandlerOptions,
) => async (req: Request, res: Response) => {
res.setHeader('Content-Type', 'application/json');

try {
if (req.method !== 'POST') {
throw new Error('Invalid method.');
}

setCookie(res, options?.cookieName || 'next-password-protect', '', {
httpOnly: true,
sameSite: options?.cookieSameSite || false,
secure:
options?.cookieSecure !== undefined
? options?.cookieSecure
: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});

sendJson(res, 200);
} catch (err) {
sendJson(res, 500, { message: err.message || 'An error has occured.' });
}
};
1 change: 1 addition & 0 deletions src/hoc/LoginComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export const LoginComponent = ({
color: buttonColor || '#111',
marginTop: '32px',
cursor: 'pointer',
textAlign: 'center',
}}
>
{isBusy ? 'Logging in...' : 'Login'}
Expand Down
33 changes: 0 additions & 33 deletions src/hoc/withAuth.tsx

This file was deleted.

39 changes: 23 additions & 16 deletions src/hoc/withPasswordProtect.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import React, { ReactNode, useEffect, useState } from 'react';
import React, { ElementType, useEffect, useState } from 'react';
import { useAmp } from 'next/amp';
import type { AppProps } from 'next/app';

import {
LoginComponent as DefaultLoginComponent,
LoginComponentProps,
} from './LoginComponent';
import { withAuth } from './withAuth';

interface PasswordProtectHOCOptions {
/* @default /api/passwordCheck */
checkApiUrl?: string;
/* @default /api/login */
loginApiUrl?: string;
loginComponent?: ReactNode;
loginComponent?: ElementType;
loginComponentProps?: Omit<LoginComponentProps, 'apiUrl'>;
}

Expand All @@ -22,6 +22,7 @@ export const withPasswordProtect = (
options?: PasswordProtectHOCOptions,
) => {
const ProtectedApp = ({ Component, pageProps, ...props }: AppProps) => {
const isAmp = useAmp();
const [isAuthenticated, setAuthenticated] = useState<undefined | boolean>(
undefined,
);
Expand All @@ -46,20 +47,26 @@ export const withPasswordProtect = (
checkIfLoggedIn();
}, []);

if (isAuthenticated === undefined) {
return null;
}

if (isAuthenticated) {
return <App Component={Component} pageProps={pageProps} {...props} />;
}

// AMP is not yet supported
if (isAmp) {
return null;
}

const LoginComponent: ElementType =
options?.loginComponent || DefaultLoginComponent;

return (
<App
Component={withAuth(
Component,
options?.loginComponent || DefaultLoginComponent,
pageProps,
isAuthenticated,
{
apiUrl: options?.loginApiUrl,
...(options?.loginComponentProps || {}),
},
)}
pageProps={pageProps}
{...props}
<LoginComponent
apiUrl={options?.loginApiUrl}
{...(options?.loginComponentProps || {})}
/>
);
};
Expand Down

0 comments on commit d8af09a

Please sign in to comment.