commit
d4539f2207
63 changed files with 1656 additions and 14804 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -16,7 +16,9 @@ tipi.config.json
|
|||
!nignx/.gitkeep
|
||||
|
||||
media/data/movies/*
|
||||
media/data/tv/*
|
||||
!media/data/movies/.gitkeep
|
||||
!media/data/tv/.gitkeep
|
||||
|
||||
media/torrents/*
|
||||
!media/torrents/.gitkeep
|
|
@ -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
|
||||
|
|
12
apps/filebrowser/config.json
Normal file
12
apps/filebrowser/config.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "File Browser",
|
||||
"port": 8096,
|
||||
"id": "filebrowser",
|
||||
"description": "Reliable and Performant File Management Desktop Sync and File Sharing",
|
||||
"short_desc": "Access your homeserver files from your browser",
|
||||
"author": "",
|
||||
"website": "https://filebrowser.org/",
|
||||
"source": "https://github.com/filebrowser/filebrowser",
|
||||
"image": "https://avatars.githubusercontent.com/u/35781395?s=200&v=4",
|
||||
"form_fields": {}
|
||||
}
|
8
apps/filebrowser/data/settings.json
Normal file
8
apps/filebrowser/data/settings.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"port": 80,
|
||||
"baseURL": "",
|
||||
"address": "",
|
||||
"log": "stdout",
|
||||
"database": "/database/filebrowser.db",
|
||||
"root": "/srv"
|
||||
}
|
15
apps/filebrowser/docker-compose.yml
Normal file
15
apps/filebrowser/docker-compose.yml
Normal file
|
@ -0,0 +1,15 @@
|
|||
services:
|
||||
filebrowser:
|
||||
container_name: filebrowser
|
||||
image: filebrowser/filebrowser:s6
|
||||
ports:
|
||||
- ${APP_PORT}:80
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- ${ROOT_FOLDER}:/srv
|
||||
- ${APP_DATA_DIR}/data/filebrowser.db:/database/filebrowser.db
|
||||
- ${APP_DATA_DIR}/data/settings.json:/config/settings.json
|
||||
networks:
|
||||
- tipi_main_network
|
11
apps/invidious/config.json
Normal file
11
apps/invidious/config.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "Invidious",
|
||||
"port": 8095,
|
||||
"id": "invidious",
|
||||
"description": "",
|
||||
"short_desc": "",
|
||||
"author": "",
|
||||
"source": "https://github.com/iv-org/invidious",
|
||||
"image": "https://raw.githubusercontent.com/iv-org/invidious/master/assets/invidious-colored-vector.svg",
|
||||
"form_fields": {}
|
||||
}
|
45
apps/invidious/docker-compose.yml
Normal file
45
apps/invidious/docker-compose.yml
Normal file
|
@ -0,0 +1,45 @@
|
|||
version: "3"
|
||||
services:
|
||||
invidious:
|
||||
user: 1000:1000
|
||||
container_name: invidious
|
||||
image: quay.io/invidious/invidious:latest-arm64
|
||||
# image: quay.io/invidious/invidious:latest-arm64 # ARM64/AArch64 devices
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT}:3000"
|
||||
environment:
|
||||
# Please read the following file for a comprehensive list of all available
|
||||
# configuration options and their associated syntax:
|
||||
# https://github.com/iv-org/invidious/blob/master/config/config.example.yml
|
||||
INVIDIOUS_CONFIG: |
|
||||
db:
|
||||
dbname: invidious
|
||||
user: tipi
|
||||
password: tipi
|
||||
host: invidious-db
|
||||
port: 5432
|
||||
check_tables: true
|
||||
healthcheck:
|
||||
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 2
|
||||
depends_on:
|
||||
- invidious-db
|
||||
|
||||
invidious-db:
|
||||
user: 1000:1000
|
||||
container_name: invidious-db
|
||||
image: docker.io/library/postgres:14
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data/postgres:/var/lib/postgresql/data
|
||||
- ${APP_DATA_DIR}/data/sql:/config/sql
|
||||
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
|
||||
environment:
|
||||
POSTGRES_DB: invidious
|
||||
POSTGRES_USER: tipi
|
||||
POSTGRES_PASSWORD: tipi
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
13
apps/jackett/config.json
Normal file
13
apps/jackett/config.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "Jackett",
|
||||
"port": 8097,
|
||||
"id": "jackett",
|
||||
"description": "Jackett works as a proxy server: it translates queries from apps (Sonarr, Radarr, SickRage, CouchPotato, Mylar3, Lidarr, DuckieTV, qBittorrent, Nefarious etc.) into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches.",
|
||||
"short_desc": "API Support for your favorite torrent trackers ",
|
||||
"author": "",
|
||||
"source": "https://github.com/Jackett/Jackett",
|
||||
"image": "https://avatars.githubusercontent.com/u/15383019?s=200&v=4",
|
||||
"form_fields": {
|
||||
|
||||
}
|
||||
}
|
20
apps/jackett/docker-compose.yml
Normal file
20
apps/jackett/docker-compose.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
version: "3.7"
|
||||
services:
|
||||
jackett:
|
||||
image: lscr.io/linuxserver/jackett
|
||||
container_name: jackett
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=${TZ}
|
||||
- AUTO_UPDATE=true
|
||||
dns:
|
||||
- ${DNS_IP}
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data:/config
|
||||
- ${ROOT_FOLDER}/media/torrents:/downloads
|
||||
ports:
|
||||
- ${APP_PORT}:9117
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tipi_main_network
|
|
@ -6,7 +6,7 @@ services:
|
|||
container_name: jellyfin
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data/config:/config
|
||||
- ${ROOT_FOLDER}/media/data/movies:/data/media
|
||||
- ${ROOT_FOLDER}/media/data:/data/media
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
|
|
13
apps/joplin/README.md
Normal file
13
apps/joplin/README.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in Markdown format.
|
||||
Notes exported from Evernote can be imported into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
|
||||
|
||||
The notes can be securely synchronised using end-to-end encryption with various cloud services including Nextcloud, Dropbox, OneDrive and Joplin Cloud.
|
||||
|
||||
Full text search is available on all platforms to quickly find the information you need. The app can be customised using plugins and themes, and you can also easily create your own.
|
||||
|
||||
The application is available for Windows, Linux, macOS, Android and iOS. A Web Clipper, to save web pages and screenshots from your browser, is also available for Firefox and Chrome.
|
||||
|
||||
## Credentials
|
||||
|
||||
Username: admin@localhost
|
||||
Password: admin
|
12
apps/joplin/config.json
Normal file
12
apps/joplin/config.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "Joplin Server",
|
||||
"port": 8099,
|
||||
"id": "joplin",
|
||||
"description": "",
|
||||
"short_desc": "Note taking and to-do application with synchronisation",
|
||||
"author": "https://github.com/laurent22",
|
||||
"source": "https://github.com/laurent22/joplin",
|
||||
"website": "https://joplinapp.org",
|
||||
"image": "https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png",
|
||||
"form_fields": {}
|
||||
}
|
38
apps/joplin/docker-compose.yml
Normal file
38
apps/joplin/docker-compose.yml
Normal file
|
@ -0,0 +1,38 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
db-joplin:
|
||||
container_name: db-joplin
|
||||
image: postgres:14.2
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data/postgres:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=tipi
|
||||
- POSTGRES_USER=tipi
|
||||
- POSTGRES_DB=joplin
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
joplin:
|
||||
container_name: joplin
|
||||
image: florider89/joplin-server:2.7.4
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db-joplin
|
||||
ports:
|
||||
- ${APP_PORT}:22300
|
||||
dns:
|
||||
- ${DNS_IP}
|
||||
environment:
|
||||
- APP_PORT=22300
|
||||
- APP_BASE_URL=http://${INTERNAL_IP}:${APP_PORT}
|
||||
- DB_CLIENT=pg
|
||||
- POSTGRES_PASSWORD=tipi
|
||||
- POSTGRES_USER=tipi
|
||||
- POSTGRES_DATABASE=joplin
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_HOST=db-joplin
|
||||
- MAX_TIME_DRIFT=0
|
||||
networks:
|
||||
- tipi_main_network
|
12
apps/n8n/config.json
Normal file
12
apps/n8n/config.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"port": 8094,
|
||||
"id": "n8n",
|
||||
"description": "n8n is an extendable workflow automation tool. With a fair-code distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.",
|
||||
"short_desc": "Workflow Automation Tool. Alternative to Zapier",
|
||||
"author": "n8n.io",
|
||||
"source": "https://github.com/n8n-io/n8n",
|
||||
"website": "https://n8n.io/",
|
||||
"image": "https://avatars.githubusercontent.com/u/45487711?s=200&v=4",
|
||||
"form_fields": {}
|
||||
}
|
36
apps/n8n/docker-compose.yml
Normal file
36
apps/n8n/docker-compose.yml
Normal file
|
@ -0,0 +1,36 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
db-n8n:
|
||||
container_name: db-n8n
|
||||
image: postgres:14.2
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data/db:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=tipi
|
||||
- POSTGRES_USER=tipi
|
||||
- POSTGRES_DB=n8n
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
n8n:
|
||||
container_name: n8n
|
||||
image: n8nio/n8n:0.174.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${APP_PORT}:5678
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data/n8n:/home/node/.n8n
|
||||
command: /bin/sh -c "sleep 5; n8n start"
|
||||
environment:
|
||||
- DB-TYPE=postgresdb
|
||||
- DB_POSTGRESDB_DATABASE=n8n
|
||||
- DB_POSTGRESDB_HOST=db-n8n
|
||||
- DB_POSTGRESDB_PORT=5432
|
||||
- DB_POSTGRESDB_USER=tipi
|
||||
- DB_POSTGRESDB_PASSWORD=tipi
|
||||
depends_on:
|
||||
- db-n8n
|
||||
networks:
|
||||
- tipi_main_network
|
|
@ -13,7 +13,7 @@ services:
|
|||
|
||||
pihole:
|
||||
depends_on: [unbound]
|
||||
container_name: pihole:2022.04.3
|
||||
container_name: pihole
|
||||
image: pihole/pihole:latest
|
||||
restart: unless-stopped
|
||||
hostname: pihole
|
||||
|
|
|
@ -1,22 +1,5 @@
|
|||
version: "3.7"
|
||||
services:
|
||||
jackett:
|
||||
image: lscr.io/linuxserver/jackett
|
||||
container_name: jackett
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=${TZ}
|
||||
- AUTO_UPDATE=true
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data/jackett:/config
|
||||
- ${ROOT_FOLDER}/media/torrents:/downloads
|
||||
ports:
|
||||
- 9117:9117
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/radarr
|
||||
container_name: radarr
|
||||
|
@ -24,8 +7,10 @@ services:
|
|||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=${TZ}
|
||||
dns:
|
||||
- ${DNS_IP}
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data/radarr:/config
|
||||
- ${APP_DATA_DIR}/data:/config
|
||||
- ${ROOT_FOLDER}/media/data/movies:/movies #optional
|
||||
- ${ROOT_FOLDER}/media/torrents:/downloads #optional
|
||||
ports:
|
||||
|
|
13
apps/sonarr/config.json
Normal file
13
apps/sonarr/config.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "Sonarr",
|
||||
"port": 8098,
|
||||
"id": "sonarr",
|
||||
"description": "",
|
||||
"short_desc": "",
|
||||
"author": "",
|
||||
"source": "",
|
||||
"image": "https://avatars.githubusercontent.com/u/1082903?s=200&v=4",
|
||||
"form_fields": {
|
||||
|
||||
}
|
||||
}
|
20
apps/sonarr/docker-compose.yml
Normal file
20
apps/sonarr/docker-compose.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
version: "3.7"
|
||||
services:
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/sonarr
|
||||
container_name: sonarr
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=${TZ}
|
||||
dns:
|
||||
- ${DNS_IP}
|
||||
volumes:
|
||||
- ${APP_DATA_DIR}/data:/config
|
||||
- ${ROOT_FOLDER}/media/data/tv:/tv #optional
|
||||
- ${ROOT_FOLDER}/media/torrents:/downloads #optional
|
||||
ports:
|
||||
- ${APP_PORT}:8989
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tipi_main_network
|
|
@ -1,7 +1,8 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
server:
|
||||
syncthing:
|
||||
container_name: syncthing
|
||||
image: syncthing/syncthing:1.19
|
||||
stop_grace_period: 1m
|
||||
hostname: tipi
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -13,6 +13,7 @@ interface FormField {
|
|||
label: string;
|
||||
max?: number;
|
||||
min?: number;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
env_variable: string;
|
||||
}
|
||||
|
@ -49,3 +50,8 @@ export enum AppStatus {
|
|||
STOPPING = 'stopping',
|
||||
STARTING = 'starting',
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
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;
|
25
dashboard/src/modules/Dashboard/components/SystemStat.tsx
Normal file
25
dashboard/src/modules/Dashboard/components/SystemStat.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Progress, Stat, StatHelpText, StatLabel, StatNumber } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
interface IProps {
|
||||
icon: IconType;
|
||||
progress: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
metric: string;
|
||||
}
|
||||
|
||||
const SystemStat: React.FC<IProps> = ({ icon: Icon, progress, title, subtitle, metric }) => {
|
||||
return (
|
||||
<Stat className="border-2 px-5 py-3 rounded-lg">
|
||||
<StatLabel>{title}</StatLabel>
|
||||
<StatNumber>{metric}</StatNumber>
|
||||
<StatHelpText>{subtitle}</StatHelpText>
|
||||
<Progress value={progress} size="sm" />
|
||||
<Icon size={30} className="absolute top-3 right-3" />
|
||||
</Stat>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemStat;
|
53
dashboard/src/modules/Dashboard/containers/Dashboard.tsx
Normal file
53
dashboard/src/modules/Dashboard/containers/Dashboard.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { SimpleGrid, Text } from '@chakra-ui/react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { BsCpu } from 'react-icons/bs';
|
||||
import { FaMemory } from 'react-icons/fa';
|
||||
import { FiHardDrive } from 'react-icons/fi';
|
||||
import { useSytemStore } from '../../../state/systemStore';
|
||||
import SystemStat from '../components/SystemStat';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { fetchDiskSpace, fetchCpuLoad, fetchMemoryLoad, disk, cpuLoad, memory } = useSytemStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDiskSpace();
|
||||
fetchCpuLoad();
|
||||
fetchMemoryLoad();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchDiskSpace();
|
||||
fetchCpuLoad();
|
||||
fetchMemoryLoad();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchCpuLoad, fetchDiskSpace, fetchMemoryLoad]);
|
||||
|
||||
// Convert bytes to GB
|
||||
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
|
||||
const diskSize = Math.round(disk.size / 1024 / 1024 / 1024);
|
||||
const diskUsed = diskSize - diskFree;
|
||||
const percentUsed = Math.round((diskUsed / diskSize) * 100);
|
||||
|
||||
const memoryTotal = Math.round(memory?.total / 1024 / 1024 / 1024);
|
||||
const memoryFree = Math.round(memory?.free / 1024 / 1024 / 1024);
|
||||
const percentUsedMemory = Math.round(((memoryTotal - memoryFree) / memoryTotal) * 100);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fontSize="3xl" className="font-bold">
|
||||
Tipi Dashboard
|
||||
</Text>
|
||||
<Text fontSize="xl" color="gray.500">
|
||||
Welcome home!
|
||||
</Text>
|
||||
<SimpleGrid className="mt-5" minChildWidth="180px" spacing="20px">
|
||||
<SystemStat title="Disk space" metric={`${diskUsed} GB`} subtitle={`Used out of ${diskSize} GB`} icon={FiHardDrive} progress={percentUsed} />
|
||||
<SystemStat title="CPU Load" metric={`${cpuLoad.toFixed(2)}%`} subtitle="Uninstall apps if there is to much load" icon={BsCpu} progress={cpuLoad} />
|
||||
<SystemStat title="Memory Used" metric={`${percentUsedMemory}%`} subtitle={`${memoryTotal} GB`} icon={FaMemory} progress={percentUsedMemory} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
|
@ -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,13 +0,0 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import si from 'systeminformation';
|
||||
|
||||
type Data = Awaited<ReturnType<typeof si.currentLoad>>;
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse<Data>) => {
|
||||
const cpuLoad = await si.currentLoad();
|
||||
|
||||
res.status(200).json(cpuLoad);
|
||||
};
|
||||
|
||||
export default handler;
|
|
@ -1,13 +0,0 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import si from 'systeminformation';
|
||||
|
||||
type Data = Awaited<ReturnType<typeof si.fsSize>>;
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse<Data>) => {
|
||||
const disk = await si.fsSize();
|
||||
|
||||
res.status(200).json(disk);
|
||||
};
|
||||
|
||||
export default handler;
|
|
@ -1,13 +0,0 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import si from 'systeminformation';
|
||||
|
||||
type Data = Awaited<ReturnType<typeof si.mem>>;
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse<Data>) => {
|
||||
const memory = await si.mem();
|
||||
|
||||
res.status(200).json(memory);
|
||||
};
|
||||
|
||||
export default handler;
|
|
@ -1,70 +1,11 @@
|
|||
import { Progress, SimpleGrid, Stat, StatHelpText, StatLabel, StatNumber, Text } from '@chakra-ui/react';
|
||||
import type { NextPage } from 'next';
|
||||
import { useEffect } from 'react';
|
||||
import Layout from '../components/Layout';
|
||||
import { useSytemStore } from '../state/systemStore';
|
||||
import { BsCpu } from 'react-icons/bs';
|
||||
import { FiHardDrive } from 'react-icons/fi';
|
||||
import { FaMemory } from 'react-icons/fa';
|
||||
import Dashboard from '../modules/Dashboard/containers/Dashboard';
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const { fetchDiskSpace, fetchCpuLoad, fetchMemoryLoad, disk, cpuLoad, memory } = useSytemStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDiskSpace();
|
||||
fetchCpuLoad();
|
||||
fetchMemoryLoad();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchDiskSpace();
|
||||
fetchCpuLoad();
|
||||
fetchMemoryLoad();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchCpuLoad, fetchDiskSpace]);
|
||||
|
||||
// Convert bytes to GB
|
||||
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
|
||||
const diskSize = Math.round(disk.size / 1024 / 1024 / 1024);
|
||||
const diskUsed = diskSize - diskFree;
|
||||
const percentUsed = Math.round((diskUsed / diskSize) * 100);
|
||||
|
||||
const memoryTotal = Math.round(memory?.total / 1024 / 1024 / 1024);
|
||||
const memoryUsed = Math.round(memory?.used / 1024 / 1024 / 1024);
|
||||
const percentUsedMemory = Math.round((memoryUsed / memoryTotal) * 100);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Text fontSize="3xl" className="font-bold">
|
||||
Tipi Dashboard
|
||||
</Text>
|
||||
<Text fontSize="xl" color="gray.500">
|
||||
Welcome home!
|
||||
</Text>
|
||||
<SimpleGrid className="mt-5" minChildWidth="180px" spacing="20px">
|
||||
<Stat className="border-2 px-5 py-3 rounded-lg">
|
||||
<StatLabel>Disk space</StatLabel>
|
||||
<StatNumber>{diskUsed} GB</StatNumber>
|
||||
<StatHelpText>Used out of {diskSize} GB</StatHelpText>
|
||||
<Progress value={percentUsed} size="sm" />
|
||||
<FiHardDrive size={30} className="absolute top-3 right-3" />
|
||||
</Stat>
|
||||
<Stat className="border-2 px-5 py-3 rounded-lg">
|
||||
<StatLabel>CPU Load</StatLabel>
|
||||
<StatNumber>{cpuLoad.toFixed(2)}%</StatNumber>
|
||||
<StatHelpText>Uninstall apps if there is to much load</StatHelpText>
|
||||
<Progress value={cpuLoad} size="sm" />
|
||||
<BsCpu size={30} className="absolute top-3 right-3" />
|
||||
</Stat>
|
||||
<Stat className="border-2 px-5 py-3 rounded-lg">
|
||||
<StatLabel>Memory Used</StatLabel>
|
||||
<StatNumber>{percentUsedMemory}%</StatNumber>
|
||||
<StatHelpText>{memoryTotal} GB</StatHelpText>
|
||||
<Progress value={percentUsedMemory} size="sm" />
|
||||
<FaMemory size={30} className="absolute top-3 right-3" />
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
|
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"
|
||||
|
|
0
media/data/tv/.gitkeep
Normal file
0
media/data/tv/.gitkeep
Normal file
BIN
screenshots/2.png
Normal file
BIN
screenshots/2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 555 KiB |
|
@ -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}"
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e # Exit immediately if a command exits with a non-zero status.
|
||||
|
||||
# Get field from json file
|
||||
function get_json_field() {
|
||||
local json_file="$1"
|
||||
local field="$2"
|
||||
|
||||
echo $(jq -r ".${field}" "${json_file}")
|
||||
}
|
||||
|
||||
# use greadlink instead of readlink on osx
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
readlink=greadlink
|
||||
|
@ -10,8 +18,17 @@ fi
|
|||
|
||||
ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
STATE_FOLDER="${ROOT_FOLDER}/state"
|
||||
|
||||
INTERNAL_IP="$(hostname -I | awk '{print $1}')"
|
||||
DNS_IP=9.9.9.9
|
||||
|
||||
# Get dns ip if pihole is installed
|
||||
str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
|
||||
|
||||
# if pihole is present in str add it as DNS
|
||||
if [[ $str = *"pihole"* ]]; then
|
||||
DNS_IP=10.21.21.201
|
||||
fi
|
||||
|
||||
PUID="$(id -u)"
|
||||
PGID="$(id -g)"
|
||||
TZ="$(cat /etc/timezone | sed 's/\//\\\//g' || echo "Europe/Berlin")"
|
||||
|
@ -50,6 +67,7 @@ ENV_FILE="$ROOT_FOLDER/templates/.env"
|
|||
[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
|
||||
|
||||
for template in "${ENV_FILE}"; do
|
||||
sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
|
||||
sed -i "s/<internal_ip>/${INTERNAL_IP}/g" "${template}"
|
||||
sed -i "s/<puid>/${PUID}/g" "${template}"
|
||||
sed -i "s/<pgid>/${PGID}/g" "${template}"
|
||||
|
@ -66,20 +84,14 @@ docker-compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --b
|
|||
exit 1
|
||||
}
|
||||
|
||||
# Get field from json file
|
||||
function get_json_field() {
|
||||
local json_file="$1"
|
||||
local field="$2"
|
||||
|
||||
echo $(jq -r ".${field}" "${json_file}")
|
||||
}
|
||||
|
||||
str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
|
||||
apps_to_start=($str)
|
||||
|
||||
for app in "${apps_to_start[@]}"; do
|
||||
"${ROOT_FOLDER}/scripts/app.sh" start $app
|
||||
done
|
||||
# for app in "${apps_to_start[@]}"; do
|
||||
# "${ROOT_FOLDER}/scripts/app.sh" start $app
|
||||
# done
|
||||
|
||||
echo "Tipi is now running"
|
||||
echo ""
|
||||
|
|
15278
system-api/package-lock.json
generated
15278
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",
|
||||
|
|
|
@ -1 +1,18 @@
|
|||
export const appNames = ['nextcloud', 'freshrss', 'anonaddy', 'filerun', 'wg-easy', 'radarr', 'transmission', 'jellyfin', 'pihole', 'tailscale'];
|
||||
export const appNames = [
|
||||
'nextcloud',
|
||||
'syncthing',
|
||||
'freshrss',
|
||||
'anonaddy',
|
||||
'filebrowser',
|
||||
'wg-easy',
|
||||
'jackett',
|
||||
'sonarr',
|
||||
'radarr',
|
||||
'transmission',
|
||||
'jellyfin',
|
||||
'pihole',
|
||||
'tailscale',
|
||||
'n8n',
|
||||
'invidious',
|
||||
'joplin',
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
4
system-api/src/declarations.d.ts
vendored
Normal file
4
system-api/src/declarations.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
declare module 'su-exec' {
|
||||
export function execFile(path: string, args: string[], options: {}, callback?: any): void;
|
||||
export function init(): void;
|
||||
}
|
|
@ -147,6 +147,10 @@ const startApp = async (req: Request, res: Response, next: NextFunction) => {
|
|||
checkAppExists(appName);
|
||||
checkEnvFile(appName);
|
||||
|
||||
// Regenerate env file
|
||||
const form = getInitalFormValues(appName);
|
||||
generateEnvFile(appName, form);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['start', appName]);
|
||||
|
||||
|
|
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,16 +1,23 @@
|
|||
/* eslint-disable no-unused-vars */
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import compression from 'compression';
|
||||
// import suExec from 'su-exec';
|
||||
import helmet from 'helmet';
|
||||
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();
|
||||
|
||||
const app = express();
|
||||
const port = 3001;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
if (isProd) {
|
||||
app.use(compression());
|
||||
|
@ -19,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 });
|
||||
});
|
||||
|
||||
|
|
|
@ -5,5 +5,6 @@ TZ=<tz>
|
|||
PUID=<puid>
|
||||
PGID=<pgid>
|
||||
INTERNAL_IP=<internal_ip>
|
||||
DNS_IP=<dns_ip>
|
||||
|
||||
|
||||
|
|
1
templates/users-sample.json
Normal file
1
templates/users-sample.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"main": {
|
||||
"domain": "mydomain.com"
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue