Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
reactStrictMode: true,
distDir: 'build',
};

module.exports = nextConfig;
64 changes: 35 additions & 29 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"export": "next build && next export",
"start": "next start",
"lint": "eslint 'src/**/*.{ts,tsx}' --fix && eslint 'pages/**/*.{ts,tsx}' --fix",
"prettify": "prettier -c --write src/**/* && prettier -c --write pages/**/*",
Expand All @@ -12,43 +13,47 @@
"pre-commit": "lint-staged"
},
"dependencies": {
"@reduxjs/toolkit": "^1.9.1",
"axios": "^1.2.1",
"next": "13.1.1",
"next-redux-wrapper": "^8.0.0",
"@reduxjs/toolkit": "^1.9.5",
"axios": "^1.4.0",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"next": "13.4.4",
"next-redux-wrapper": "^8.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-redux": "^8.0.5",
"redux": "^4.2.0",
"redux-saga": "^1.2.2",
"reselect": "^4.1.7"
"redux": "^4.2.1",
"redux-saga": "^1.2.3",
"reselect": "^4.1.8",
"universal-cookie": "^4.0.4"
},
"devDependencies": {
"@types/node": "18.11.17",
"@types/react": "18.0.26",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"autoprefixer": "^10.4.13",
"eslint": "8.30.0",
"@types/lodash": "^4.14.195",
"@types/node": "20.2.5",
"@types/react": "18.2.7",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"autoprefixer": "^10.4.14",
"eslint": "8.41.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-next": "13.1.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-config-next": "13.4.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-redux": "^4.0.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"husky": "^8.0.2",
"immer": "^9.0.16",
"lint-staged": "^13.1.0",
"postcss": "^8.4.20",
"prettier": "^2.8.1",
"sass": "^1.57.1",
"tailwindcss": "^3.2.4",
"typescript": "4.9.4"
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"immer": "^10.0.2",
"lint-staged": "^13.2.2",
"postcss": "^8.4.24",
"prettier": "^2.8.8",
"sass": "^1.62.1",
"tailwindcss": "^3.3.2",
"typescript": "5.0.4"
},
"husky": {
"hooks": {
Expand All @@ -68,7 +73,8 @@
]
},
"engines": {
"npm": "8.19.2",
"node": "18.12.1"
"npm": "please-use-yarn",
"node": "18.16.0",
"yarn": ">=1.22.0"
}
}
142 changes: 142 additions & 0 deletions pages-auth/next-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import jwt_decode from 'jwt-decode';
import {
GetServerSideProps,
GetServerSidePropsContext,
GetServerSidePropsResult,
} from 'next';
import { Store } from 'redux';
// helper classes
import { BaseError } from 'classes/BaseError';
// helper
import Utils from 'utils/Utils';
// constants
import { getDefaultRoute } from 'constants/DefaultRoutes';
import NextRouteConfig from 'constants/NextRouteConfig';
import { StatusCodes } from 'constants/status-codes';
import { Roles } from 'enums/Roles';
// services
import LocalStorageService from 'services/LocalStorageService';
// store
import { authFetchMeAction } from 'store/actions/auth.action';
import { AppState } from 'store/reducers';

/**
* This is a TypeScript function that handles authentication and authorization for server-side
* rendering in Next.js, with options for allowed roles and public access.
* @param store - The Redux store for the application.
* @param {null | ((context: GetServerSidePropsContext) => Promise<any>)} [callback] - The `callback`
* parameter is an optional function that can be passed to `nextAuth` as an argument. It is a function
* that takes a `GetServerSidePropsContext` object as its argument and returns a Promise that resolves
* to an object. This object can contain any data that needs to be
* @param [config] - The `config` parameter is an optional object that can contain the following
* properties:
* `dataKey?: string;`
* `allowedRoles?: Roles[];`
* `allowPublic?: boolean;`
* @example
* export const getServerSideProps = wrapper.getServerSideProps((store) =>
nextAuth(store, null, { allowPublic: false, allowedRoles: [Roles.ADMIN] })
);
* @returns A higher-order function that takes in a `store`, `callback`, and `config` as arguments and
* returns a `GetServerSideProps` function. The returned function takes in a `context` object as an
* argument and returns a `GetServerSidePropsResult` object. The `GetServerSidePropsResult` object can
* either have a `props` property containing a `serverData` object
*/
const nextAuth =
(
store: Store<AppState>,
callback?: null | ((context: GetServerSidePropsContext) => Promise<any>),
config?: {
dataKey?: string;
allowedRoles?: Roles[];
allowPublic?: boolean;
}
): GetServerSideProps =>
async (
context: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<any>> => {
LocalStorageService.setCookies(context.req.headers.cookie);

try {
let serverData: any = {
query: context.query,
params: Utils.sanitizeObject(context.params),
};

let userType = null;
let userId = null;
let tokenExpired = true;

// checking token expiration
const authToken = await LocalStorageService.getAuthToken();

if (authToken) {
const decoded: any = jwt_decode(authToken || '');
const current = Math.floor(Date.now() / 1000);
tokenExpired = (decoded?.exp || 0) - current < 600;
if (!tokenExpired) {
userType = decoded?.userType;
userId = decoded?.id;
}
}

serverData.userType = userType;
serverData.userId = userId;
serverData.tokenExpired = tokenExpired;

if (!tokenExpired && authToken && !store.getState().auth.userID) {
store.dispatch(authFetchMeAction());
serverData = {
...serverData,
};
}

if (
(config?.allowedRoles || []).includes(userType) ||
config?.allowPublic
) {
if (callback) {
serverData = {
...serverData,
[config?.dataKey || 'data']: await callback?.(context),
};
}

if (!serverData?.authUser && !tokenExpired && authToken) {
serverData = {
...serverData,
};
}

return {
props: {
serverData,
},
};
}

return {
redirect: {
permanent: false,
destination: getDefaultRoute(userType),
},
};
} catch (e: any) {
if (e.status === StatusCodes.UNAUTHORIZED) {
return {
redirect: {
permanent: false,
destination: NextRouteConfig.logout,
},
};
}

return {
props: {
error: BaseError.toJSON(e),
},
};
}
};

export default nextAuth;
64 changes: 64 additions & 0 deletions src/classes/BaseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { AxiosError } from 'axios';
import { StatusCodes, StatusMessage } from 'constants/status-codes';

export enum ErrorCode {
UNIDENTIFIED,
}

export type ErrorStatusCode = ErrorCode | StatusCodes;

export type ValidationErrorType = {
field: string;
message: string;
rule: string;
};

const DEFAULT_ERROR = 'An unexpected error occurred. Please try again';

export class BaseError {
constructor(
readonly message: string = DEFAULT_ERROR,
readonly status: ErrorStatusCode = ErrorCode.UNIDENTIFIED,
readonly errors: ValidationErrorType[] = [],
readonly data: any = []
) {}

static fromJSON(axiosError: AxiosError<any>): BaseError {
if (axiosError.code === 'ECONNABORTED') {
return new BaseError(`Request Timeout (${axiosError.message})`);
}

if (!axiosError.response) {
return new BaseError(
'Unable to connect to server. Please check your internet connection try again.'
);
}

const { status, data } = axiosError.response;
const message: string =
StatusMessage[status as StatusCodes] ?? DEFAULT_ERROR;

return new BaseError(message, status, data?.errors ?? [], data?.data ?? []);
}

static toJSON(json: BaseError) {
return {
message: json.message || 'Error. Please Try Again.',
status: json.status || -1,
errors: json.errors || [],
data: json.data || [],
};
}

isValidationError(): boolean {
return (this.errors || []).length > 0;
}

errorsByKey(key: string): ValidationErrorType | undefined {
return this.errors?.find((er: ValidationErrorType) => er.field === key);
}

hasErrorByKey(key: string): boolean {
return !!this.errorsByKey(key);
}
}
11 changes: 11 additions & 0 deletions src/constants/DefaultRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import NextRouteConfig from 'constants/NextRouteConfig';
import { Roles } from 'enums/Roles';

export const getDefaultRoute = (userType: Roles): string => {
switch (userType) {
case Roles.USER:
return NextRouteConfig.home._ROOT;
default:
return '/';
}
};
11 changes: 11 additions & 0 deletions src/constants/NextRouteConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const NextRouteConfig = {
login: '/login',
logout: '/logout',
signup: '/signup',
resetPassword: '/reset-password',
home: {
_ROOT: '/',
},
};

export default NextRouteConfig;
41 changes: 41 additions & 0 deletions src/constants/status-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export enum StatusCodes {
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
TOO_MANY_REQUESTS = 429,
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
}

export const StatusMessage: { [key in StatusCodes]: string } = {
[StatusCodes.BAD_REQUEST]: 'Bad request, try again',
[StatusCodes.UNAUTHORIZED]: 'Not authorized',
[StatusCodes.FORBIDDEN]:
'Forbidden, not authorized to view the requested resource',
[StatusCodes.NOT_FOUND]: 'Requested resource not found on the server',
[StatusCodes.METHOD_NOT_ALLOWED]: 'Requested Method not allowed',
[StatusCodes.NOT_ACCEPTABLE]: 'Not Acceptable',
[StatusCodes.PROXY_AUTHENTICATION_REQUIRED]:
'Not authorized. Need to authenticate with proxy',
[StatusCodes.REQUEST_TIMEOUT]: 'Request timeout',
[StatusCodes.TOO_MANY_REQUESTS]:
'too many requests in a given amount of time',

[StatusCodes.INTERNAL_SERVER_ERROR]:
'Server encountered an unexpected condition that prevented it from fulfilling the request.',
[StatusCodes.NOT_IMPLEMENTED]:
'Method is not supported by the server and cannot be handled',
[StatusCodes.BAD_GATEWAY]:
'Server, while working as a gateway to get a response needed to handle the request, got an invalid response.',
[StatusCodes.SERVICE_UNAVAILABLE]:
'Server is not ready to handle the request.',
[StatusCodes.GATEWAY_TIMEOUT]: 'Server cannot get a response in time.',
};
4 changes: 4 additions & 0 deletions src/enums/Roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum Roles {
USER = 'user',
ADMIN = 'admin',
}
Loading