Feature: Authentication

This commit is contained in:
Nicolas Meienberger 2022-05-03 22:09:19 +02:00
parent 8f5f2c09e8
commit 56759e9ee5
28 changed files with 1205 additions and 14686 deletions

View file

@ -49,20 +49,6 @@
- name: Make docker-compose executable
shell: chmod +x /usr/local/bin/docker-compose
# - name: Disable iptables for docker by editing file /etc/default/docker
# lineinfile:
# path: /etc/default/docker
# regexp: "^DOCKER_OPTS="
# line: "DOCKER_OPTS=\"--iptables=false\""
# state: present
# - name: Create file /etc/docker/daemon.json with content hello world written inside
# lineinfile:
# path: /etc/docker/daemon.json
# regexp: "^"
# line: "{ \"iptables\": false }"
# state: present
- name: Create group docker
group:
name: docker

View file

@ -1,6 +1,3 @@
# - name: Change machine hostname to tipi.local
# shell: hostnamectl set-hostname tipi.local
# - name: Update packages
# apt:
# update_cache: yes

View file

@ -18,6 +18,7 @@
"final-form": "^4.20.6",
"framer-motion": "^6",
"immer": "^9.0.12",
"js-cookie": "^3.0.1",
"next": "12.1.4",
"react": "18.0.0",
"react-dom": "18.0.0",
@ -29,6 +30,7 @@
"zustand": "^3.7.2"
},
"devDependencies": {
"@types/js-cookie": "^3.0.2",
"@types/node": "17.0.23",
"@types/react": "17.0.43",
"@types/react-dom": "17.0.14",

View file

@ -6,16 +6,17 @@ interface IProps {
placeholder?: string;
error?: string;
type?: Parameters<typeof Input>[0]['type'];
label: string;
label?: string;
className?: string;
isInvalid?: boolean;
size?: Parameters<typeof Input>[0]['size'];
}
const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, ...rest }) => {
const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, ...rest }) => {
return (
<div className={clsx('transition-all', className)}>
<label>{label}</label>
<Input type={type} placeholder={placeholder} isInvalid={isInvalid} {...rest} />
{label && <label>{label}</label>}
<Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
{isInvalid && <span className="text-red-500 text-sm">{error}</span>}
</div>
);

View file

@ -48,6 +48,7 @@ const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
<Head>
<title>Tipi</title>
</Head>
<Flex height="100vh" direction="column">
<MenuDrawer isOpen={isOpen} onClose={onClose}>
<Menu />

View file

@ -1,5 +1,6 @@
import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
import { FaRegMoon } from 'react-icons/fa';
import { FiLogOut } from 'react-icons/fi';
import Package from '../../../package.json';
import { Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
import React from 'react';
@ -7,10 +8,12 @@ import Link from 'next/link';
import clsx from 'clsx';
import { useRouter } from 'next/router';
import { IconType } from 'react-icons';
import { useAuthStore } from '../../state/authStore';
const SideMenu: React.FC = () => {
const router = useRouter();
const { colorMode, setColorMode } = useColorMode();
const { logout } = useAuthStore();
const path = router.pathname.split('/')[1];
const renderMenuItem = (title: string, name: string, Icon: IconType) => {
@ -49,6 +52,10 @@ const SideMenu: React.FC = () => {
<Flex flex="1" />
<List>
<div className="mx-3">
<ListItem onClick={logout} className="cursor-pointer hover:font-bold flex items-center mb-5">
<FiLogOut size={20} className="mr-3" />
<p className="flex-1">Log out</p>
</ListItem>
<ListItem className="flex items-center">
<FaRegMoon size={20} className="mr-3" />
<p className="flex-1">Dark mode</p>

View file

@ -0,0 +1,12 @@
import { Flex, Spinner } from '@chakra-ui/react';
import React from 'react';
const LoadingScreen = () => {
return (
<Flex height="100vh" alignItems="center" justifyContent="center">
<Spinner size="lg" />
</Flex>
);
};
export default LoadingScreen;

View file

@ -17,6 +17,7 @@ const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
params,
data,
url: `${BASE_URL}${endpoint}`,
withCredentials: true,
});
if (response.data.error) {

View file

@ -52,5 +52,6 @@ export enum AppStatus {
}
export interface IUser {
first_name: string;
name: string;
email: string;
}

View file

@ -3,7 +3,6 @@ import React from 'react';
import { FiExternalLink } from 'react-icons/fi';
import { AppConfig } from '../../../core/types';
import { useAppsStore } from '../../../state/appsStore';
import { useNetworkStore } from '../../../state/networkStore';
import AppActions from '../components/AppActions';
import InstallModal from '../components/InstallModal';
import StopModal from '../components/StopModal';
@ -21,7 +20,6 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const { internalIp } = useNetworkStore();
const { install, update, uninstall, stop, start, fetchApp } = useAppsStore();
const handleError = (error: unknown) => {
@ -88,7 +86,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
};
const handleOpen = () => {
window.open(`http://${internalIp}:${app.port}`, '_blank');
window.open(`http://${process.env.INTERNAL_IP}:${app.port}`, '_blank');
};
return (

View file

@ -0,0 +1,68 @@
import { useToast } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import LoadingScreen from '../../../components/LoadingScreen';
import { useAuthStore } from '../../../state/authStore';
import Login from './Login';
import Onboarding from './Onboarding';
const AuthWrapper: React.FC = ({ children }) => {
const [initialLoad, setInitialLoad] = useState(true);
const { configured, loading, user, login, me, fetchConfigured, register } = useAuthStore();
const toast = useToast();
useEffect(() => {
const fetchUser = async () => {
await me();
await fetchConfigured();
setInitialLoad(false);
};
if (!user) fetchUser();
}, [fetchConfigured, me, user]);
const handleError = (error: unknown) => {
if (error instanceof Error) {
toast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleLogin = async (values: { email: string; password: string }) => {
try {
await login(values.email, values.password);
await me();
} catch (error) {
handleError(error);
}
};
const handleRegister = async (values: { email: string; password: string }) => {
try {
await register(values.email, values.password);
await me();
} catch (error) {
handleError(error);
}
};
if (initialLoad && !user) {
return <LoadingScreen />;
}
if (user) {
return <>{children}</>;
}
if (!configured) {
return <Onboarding loading={loading} onSubmit={handleRegister} />;
}
return <Login loading={loading} onSubmit={handleLogin} />;
};
export default AuthWrapper;

View file

@ -0,0 +1,78 @@
import { Button, Container, Flex, SlideFade, Text } from '@chakra-ui/react';
import React from 'react';
import { Field, Form } from 'react-final-form';
import validator from 'validator';
import FormInput from '../../../components/Form/FormInput';
type FormValues = { email: string; password: string };
interface IProps {
onSubmit: (values: FormValues) => void;
loading: boolean;
}
const Login: React.FC<IProps> = ({ onSubmit, loading }) => {
const validateFields = (values: FormValues) => {
const errors: Record<string, string> = {};
if (!validator.isEmail(values.email || '')) {
errors.email = 'Invalid email';
}
if (!values.password) {
errors.password = 'Required';
}
return errors;
};
return (
<Container maxW="1250px">
<Flex flex={1} height="100vh" overflowY="hidden">
<SlideFade in className="flex flex-1 flex-col justify-center items-center" offsetY="20px">
<img className="self-center mb-5 logo" src="/tipi.png" width={512} height={512} />
<Text className="text-xl md:text-2xl lg:text-5xl font-bold" size="3xl">
Welcome back
</Text>
<Text className="md:text-lg lg:text-2xl text-center" color="gray.500">
Enter your credentials to login to your Tipi
</Text>
<Form<FormValues>
onSubmit={onSubmit}
validateOnBlur={true}
validate={(values) => validateFields(values)}
render={({ handleSubmit, validating, submitting }) => (
<form className="flex flex-col" onSubmit={handleSubmit}>
<Field
name="email"
render={({ input, meta }) => (
<FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Email" {...input} />
)}
/>
<Field
name="password"
render={({ input, meta }) => (
<FormInput
size="lg"
className="mt-3 w-full"
error={meta.error}
isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)}
placeholder="Password"
type="password"
{...input}
/>
)}
/>
<Button isLoading={validating || submitting || loading} className="mt-2" colorScheme="green" type="submit">
Login
</Button>
</form>
)}
/>
</SlideFade>
</Flex>
</Container>
);
};
export default Login;

View file

@ -0,0 +1,92 @@
import { Button, Container, Flex, SlideFade, Text } from '@chakra-ui/react';
import React from 'react';
import { Field, Form } from 'react-final-form';
import validator from 'validator';
import FormInput from '../../../components/Form/FormInput';
type FormValues = { email: string; password: string };
interface IProps {
onSubmit: (values: FormValues) => void;
loading: boolean;
}
const Onboarding: React.FC<IProps> = ({ onSubmit, loading }) => {
const validateFields = (values: FormValues) => {
const errors: Record<string, string> = {};
if (!validator.isEmail(values.email || '')) {
errors.email = 'Invalid email';
}
if (!values.password) {
errors.password = 'Required';
}
return errors;
};
return (
<Container maxW="1250px">
<Flex flex={1} height="100vh" overflowY="hidden">
<SlideFade in className="flex flex-1 flex-col justify-center items-center" offsetY="20px">
<img className="self-center mb-5 logo" src="/tipi.png" width={512} height={512} />
<Text className="text-xl md:text-2xl lg:text-5xl font-bold" size="3xl">
Welcome to your Tipi
</Text>
<Text className="md:text-lg lg:text-2xl text-center" color="gray.500">
Register your account to get started
</Text>
<Form<FormValues>
onSubmit={onSubmit}
validateOnBlur={true}
validate={(values) => validateFields(values)}
render={({ handleSubmit, validating, submitting }) => (
<form className="flex flex-col" onSubmit={handleSubmit}>
<Field
name="email"
render={({ input, meta }) => (
<FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Email" {...input} />
)}
/>
<Field
name="password"
render={({ input, meta }) => (
<FormInput
size="lg"
className="mt-3 w-full"
error={meta.error}
isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)}
placeholder="Password"
type="password"
{...input}
/>
)}
/>
<Field
name="password-repeat"
render={({ input, meta }) => (
<FormInput
size="lg"
className="mt-3 w-full"
error={meta.error}
isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)}
placeholder="Repeat password"
type="password"
{...input}
/>
)}
/>
<Button isLoading={validating || submitting || loading} className="mt-2" colorScheme="green" type="submit">
Enter
</Button>
</form>
)}
/>
</SlideFade>
</Flex>
</Container>
);
};
export default Onboarding;

View file

@ -3,20 +3,15 @@ import '@fontsource/open-sans/400.css';
import '../styles/globals.css';
import { ChakraProvider } from '@chakra-ui/react';
import type { AppProps } from 'next/app';
import { useEffect } from 'react';
import { useNetworkStore } from '../state/networkStore';
import { theme } from '../styles/theme';
import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
function MyApp({ Component, pageProps }: AppProps) {
const { fetchInternalIp } = useNetworkStore();
useEffect(() => {
fetchInternalIp();
}, [fetchInternalIp]);
return (
<ChakraProvider theme={theme}>
<Component {...pageProps} />
<AuthWrapper>
<Component {...pageProps} />
</AuthWrapper>
</ChakraProvider>
);
}

View file

@ -1,7 +1,5 @@
import type { NextPage } from 'next';
import Layout from '../components/Layout';
import api from '../core/api';
import { IUser } from '../core/types';
import Dashboard from '../modules/Dashboard/containers/Dashboard';
const Home: NextPage = () => {
@ -12,20 +10,4 @@ const Home: NextPage = () => {
);
};
export async function getServerSideProps() {
const token = localStorage.getItem('tipi_token');
// Fetch data from external API
const res = await api.fetch<IUser>({
endpoint: '/user',
method: 'post',
data: { token },
});
console.log(res);
// Pass data to the page via props
return { props: { user: res } };
}
export default Home;

View file

@ -0,0 +1,75 @@
import create from 'zustand';
import Cookies from 'js-cookie';
import api from '../core/api';
import { IUser } from '../core/types';
type AppsStore = {
user: IUser | null;
configured: boolean;
me: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
fetchConfigured: () => Promise<void>;
loading: boolean;
};
export const useAuthStore = create<AppsStore>((set) => ({
user: null,
configured: false,
loading: false,
me: async () => {
try {
set({ loading: true });
const response = await api.fetch<{ user: IUser | null }>({ endpoint: '/auth/me' });
set({ user: response.user, loading: false });
} catch (error) {
set({ loading: false, user: null });
}
},
login: async (email: string, password: string) => {
set({ loading: true });
try {
const response = await api.fetch<{ user: IUser }>({
endpoint: '/auth/login',
method: 'post',
data: { email, password },
});
set({ user: response.user, loading: false });
} catch (e) {
set({ loading: false });
throw e;
}
},
logout: async () => {
// Cookies.remove('token2');
Cookies.remove('tipi_token');
set({ user: null, loading: false });
},
register: async (email: string, password: string) => {
set({ loading: true });
try {
const response = await api.fetch<{ user: IUser }>({
endpoint: '/auth/register',
method: 'post',
data: { email, password },
});
set({ user: response.user, loading: false });
} catch (e) {
set({ loading: false });
throw e;
}
},
fetchConfigured: async () => {
try {
const response = await api.fetch<{ configured: boolean }>({ endpoint: '/auth/configured' });
set({ configured: response.configured });
} catch (e) {
set({ configured: false });
}
},
}));

View file

@ -904,6 +904,11 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64"
integrity sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==
"@types/js-cookie@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.2.tgz#451eaeece64c6bdac8b2dde0caab23b085899e0d"
integrity sha512-6+0ekgfusHftJNYpihfkMu8BWdeHs9EOJuGcSofErjstGPfPGEu9yTu4t460lTzzAMl2cM5zngQJqPMHbbnvYA==
"@types/json-schema@^7.0.9":
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@ -2296,6 +2301,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
js-cookie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414"
integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"

View file

@ -122,7 +122,7 @@ if [[ "$command" = "uninstall" ]]; then
echo "Deleting app data for app ${app}..."
if [[ -d "${app_data_dir}" ]]; then
rm -rf "${app_data_dir}"
sudo rm -rf "${app_data_dir}"
fi
echo "Successfully uninstalled app ${app}"

File diff suppressed because it is too large Load diff

View file

@ -16,22 +16,33 @@
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.0.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"helmet": "^5.0.2",
"internal-ip": "^7.0.0",
"jsonwebtoken": "^8.5.1",
"node-port-scanner": "^3.0.1",
"p-iteration": "^1.1.8",
"passport": "^0.5.2",
"passport-cookie": "^1.0.9",
"passport-http-bearer": "^1.0.1",
"public-ip": "^5.0.0",
"systeminformation": "^5.11.9",
"tcp-port-used": "^1.0.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.2",
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/jsonwebtoken": "^8.5.8",
"@types/passport": "^1.0.7",
"@types/passport-http-bearer": "^1.0.37",
"@types/tcp-port-used": "^1.0.1",
"@types/validator": "^13.7.2",
"concurrently": "^7.1.0",

View file

@ -3,11 +3,12 @@ import * as dotenv from 'dotenv';
interface IConfig {
NODE_ENV: string;
ROOT_FOLDER: string;
JWT_SECRET: string;
}
dotenv.config();
const { NODE_ENV = 'development', ROOT_FOLDER = '' } = process.env;
const { NODE_ENV = 'development', ROOT_FOLDER = '', JWT_SECRET = '' } = process.env;
const missing = [];
@ -20,6 +21,7 @@ if (missing.length > 0) {
const config: IConfig = {
NODE_ENV,
ROOT_FOLDER,
JWT_SECRET,
};
export default config;

View file

@ -15,6 +15,8 @@ interface FormField {
env_variable: string;
}
export type Maybe<T> = T | null | undefined;
export interface AppConfig {
id: string;
port: number;
@ -32,3 +34,9 @@ export interface AppConfig {
installed: boolean;
status: 'running' | 'stopped';
}
export interface IUser {
email: string;
name: string;
password: string;
}

View file

@ -0,0 +1,88 @@
import { NextFunction, Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { IUser } from '../../config/types';
import { readJsonFile, writeFile } from '../fs/fs.helpers';
import { getJwtToken, getUser } from './auth.helpers';
const login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = req.body;
if (!email || !password) {
throw new Error('Missing id or password');
}
const user = getUser(email);
if (!user) {
throw new Error('User not found');
}
const token = await getJwtToken(user, password);
res.cookie('tipi_token', token, {
httpOnly: false,
secure: false,
maxAge: 1000 * 60 * 60 * 24 * 7,
});
res.status(200).json({ token });
} catch (e) {
next(e);
}
};
const register = async (req: Request, res: Response, next: NextFunction) => {
try {
const users: IUser[] = readJsonFile('/state/users.json');
if (users.length > 0) {
throw new Error('There is already an admin user');
}
const { email, password, name } = req.body;
if (!email || !password) {
throw new Error('Missing email or password');
}
if (users.find((user) => user.email === email)) {
throw new Error('User already exists');
}
const hash = await bcrypt.hash(password, 10);
const newuser: IUser = { email, name, password: hash };
const token = await getJwtToken(newuser, password);
res.cookie('tipi_token', token, {
httpOnly: false,
secure: false,
maxAge: 1000 * 60 * 60 * 24 * 7,
});
writeFile('/state/users.json', JSON.stringify([newuser]));
res.status(200).json({ token });
} catch (e) {
next(e);
}
};
const me = async (req: Request, res: Response) => {
const { user } = req;
if (user) {
res.status(200).json({ user });
} else {
res.status(200).json({ user: null });
}
};
const isConfigured = async (req: Request, res: Response) => {
const users: IUser[] = readJsonFile('/state/users.json');
res.status(200).json({ configured: users.length > 0 });
};
export default { login, me, register, isConfigured };

View file

@ -0,0 +1,44 @@
import jsonwebtoken from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { IUser, Maybe } from '../../config/types';
import { readJsonFile } from '../fs/fs.helpers';
import config from '../../config';
export const getUser = (email: string): Maybe<IUser> => {
const savedUser: IUser[] = readJsonFile('/state/users.json');
const user = savedUser.find((u) => u.email === email);
return user;
};
const compareHashPassword = (password: string, hash = ''): Promise<boolean> => {
return bcrypt.compare(password, hash || '');
};
const getJwtToken = async (user: IUser, password: string) => {
const validPassword = await compareHashPassword(password, user.password || '');
if (validPassword) {
if (config.JWT_SECRET) {
return jsonwebtoken.sign({ email: user.email }, config.JWT_SECRET, {
expiresIn: '7d',
});
}
}
throw new Error('Wrong password');
};
const tradeTokenForUser = (token: string): Maybe<IUser> => {
try {
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
const users: IUser[] = readJsonFile('/state/users.json');
return users.find((user) => user.email === email);
} catch (error) {
return null;
}
};
export { tradeTokenForUser, getJwtToken };

View file

@ -0,0 +1,11 @@
import { Router } from 'express';
import AuthController from './auth.controller';
const router = Router();
router.route('/login').post(AuthController.login);
router.route('/me').get(AuthController.me);
router.route('/configured').get(AuthController.isConfigured);
router.route('/register').post(AuthController.register);
export default router;

View file

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
import express, { NextFunction, Request, Response } from 'express';
import compression from 'compression';
import helmet from 'helmet';
@ -5,12 +6,15 @@ import cors from 'cors';
import { isProd } from './constants/constants';
import appsRoutes from './modules/apps/apps.routes';
import systemRoutes from './modules/system/system.routes';
import networkRoutes from './modules/network/network.routes';
import authRoutes from './modules/auth/auth.routes';
import { tradeTokenForUser } from './modules/auth/auth.helpers';
import cookieParser from 'cookie-parser';
const app = express();
const port = 3001;
app.use(express.json());
app.use(cookieParser());
if (isProd) {
app.use(compression());
@ -19,12 +23,32 @@ if (isProd) {
app.use(cors());
app.use('/system', systemRoutes);
app.use('/apps', appsRoutes);
app.use('/network', networkRoutes);
// Get user from token
app.use((req, res, next) => {
let user = null;
if (req?.cookies?.tipi_token) {
user = tradeTokenForUser(req.cookies.tipi_token);
if (user) req.user = user;
}
next();
});
const restrict = (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
} else {
next();
}
};
app.use('/auth', authRoutes);
app.use('/system', restrict, systemRoutes);
app.use('/apps', restrict, appsRoutes);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
app.use((err: Error, req: Request, res: Response, _: NextFunction) => {
res.status(200).json({ error: err.message });
});

View file

@ -0,0 +1 @@
[]