Merge pull request #9 from meienberger/feature/authentication
Feature/authentication
This commit is contained in:
commit
24d09d4277
30 changed files with 1242 additions and 14700 deletions
|
@ -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
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
# - name: Change machine hostname to tipi.local
|
||||
# shell: hostnamectl set-hostname tipi.local
|
||||
|
||||
# - name: Update packages
|
||||
# apt:
|
||||
# update_cache: yes
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
12
dashboard/src/components/LoadingScreen.tsx
Normal file
12
dashboard/src/components/LoadingScreen.tsx
Normal 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;
|
|
@ -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) {
|
||||
|
|
|
@ -52,5 +52,6 @@ export enum AppStatus {
|
|||
}
|
||||
|
||||
export interface IUser {
|
||||
first_name: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
28
dashboard/src/modules/Auth/components/AuthFormLayout.tsx
Normal file
28
dashboard/src/modules/Auth/components/AuthFormLayout.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Container, Flex, SlideFade, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const AuthFormLayout: React.FC<IProps> = ({ children, title, description }) => {
|
||||
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">
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="md:text-lg lg:text-2xl text-center" color="gray.500">
|
||||
{description}
|
||||
</Text>
|
||||
{children}
|
||||
</SlideFade>
|
||||
</Flex>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthFormLayout;
|
57
dashboard/src/modules/Auth/components/LoginForm.tsx
Normal file
57
dashboard/src/modules/Auth/components/LoginForm.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Button } 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 LoginForm: 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 (
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
75
dashboard/src/modules/Auth/components/RegisterForm.tsx
Normal file
75
dashboard/src/modules/Auth/components/RegisterForm.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { Button } 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';
|
||||
|
||||
interface IProps {
|
||||
onSubmit: (values: FormValues) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
type FormValues = { email: string; password: string; passwordConfirm: string };
|
||||
|
||||
const RegisterForm: 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';
|
||||
}
|
||||
|
||||
if (values.password !== values.passwordConfirm) {
|
||||
errors.passwordConfirm = 'Passwords do not match';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
return (
|
||||
<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="passwordConfirm"
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
36
dashboard/src/modules/Auth/containers/AuthWrapper.tsx
Normal file
36
dashboard/src/modules/Auth/containers/AuthWrapper.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
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, user, me, fetchConfigured } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
await me();
|
||||
await fetchConfigured();
|
||||
|
||||
setInitialLoad(false);
|
||||
};
|
||||
if (!user) fetchUser();
|
||||
}, [fetchConfigured, me, user]);
|
||||
|
||||
if (initialLoad && !user) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
return <Onboarding />;
|
||||
}
|
||||
|
||||
return <Login />;
|
||||
};
|
||||
|
||||
export default AuthWrapper;
|
41
dashboard/src/modules/Auth/containers/Login.tsx
Normal file
41
dashboard/src/modules/Auth/containers/Login.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { useToast } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { useAuthStore } from '../../../state/authStore';
|
||||
import AuthFormLayout from '../components/AuthFormLayout';
|
||||
import LoginForm from '../components/LoginForm';
|
||||
|
||||
type FormValues = { email: string; password: string };
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { me, login, loading } = useAuthStore();
|
||||
const toast = useToast();
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
position: 'top',
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (values: FormValues) => {
|
||||
try {
|
||||
await login(values.email, values.password);
|
||||
await me();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthFormLayout title="Welcome back" description="Enter your credentials to login to your Tipi">
|
||||
<LoginForm onSubmit={handleLogin} loading={loading} />
|
||||
</AuthFormLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
39
dashboard/src/modules/Auth/containers/Onboarding.tsx
Normal file
39
dashboard/src/modules/Auth/containers/Onboarding.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useToast } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { useAuthStore } from '../../../state/authStore';
|
||||
import AuthFormLayout from '../components/AuthFormLayout';
|
||||
import RegisterForm from '../components/RegisterForm';
|
||||
|
||||
const Onboarding: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const { me, register, loading } = useAuthStore();
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
position: 'top',
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (values: { email: string; password: string }) => {
|
||||
try {
|
||||
await register(values.email, values.password);
|
||||
await me();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthFormLayout title="Welcome to your Tipi" description="Register your account to get started">
|
||||
<RegisterForm onSubmit={handleRegister} loading={loading} />
|
||||
</AuthFormLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
75
dashboard/src/state/authStore.ts
Normal file
75
dashboard/src/state/authStore.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
}));
|
|
@ -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"
|
||||
|
|
15293
system-api/package-lock.json
generated
15293
system-api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
88
system-api/src/modules/auth/auth.controller.ts
Normal file
88
system-api/src/modules/auth/auth.controller.ts
Normal 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 };
|
44
system-api/src/modules/auth/auth.helpers.ts
Normal file
44
system-api/src/modules/auth/auth.helpers.ts
Normal 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 };
|
11
system-api/src/modules/auth/auth.routes.ts
Normal file
11
system-api/src/modules/auth/auth.routes.ts
Normal 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;
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable no-unused-vars */
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import compression from 'compression';
|
||||
// import suExec from 'su-exec';
|
||||
|
@ -6,7 +7,9 @@ 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';
|
||||
|
||||
// suExec.init();
|
||||
|
||||
|
@ -14,6 +17,7 @@ const app = express();
|
|||
const port = 3001;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
if (isProd) {
|
||||
app.use(compression());
|
||||
|
@ -22,12 +26,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 });
|
||||
});
|
||||
|
||||
|
|
1
templates/users-sample.json
Normal file
1
templates/users-sample.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
Loading…
Add table
Reference in a new issue