Merge pull request #763 from runtipi/release/2.0.6

Release/2.0.6
This commit is contained in:
Nicolas Meienberger 2023-10-17 08:45:29 +02:00 committed by GitHub
commit 27f5ccda82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
311 changed files with 7573 additions and 7985 deletions

View file

@ -315,7 +315,9 @@
"avatar_url": "https://avatars.githubusercontent.com/u/106091011?v=4",
"profile": "https://github.com/steveiliop56",
"contributions": [
"translation"
"translation",
"code",
"test"
]
},
{
@ -353,6 +355,15 @@
"contributions": [
"translation"
]
},
{
"login": "itsrllyhim",
"name": "him",
"avatar_url": "https://avatars.githubusercontent.com/u/143047010?v=4",
"profile": "https://github.com/itsrllyhim",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
echo '{
"appsRepoUrl": "https://github.com/meienberger/runtipi-appstore.git/"
"appsRepoUrl": "https://github.com/runtipi/runtipi-appstore.git/"
}' > state/settings.json
npm i -g pnpm
pnpm i

View file

@ -1,13 +1,13 @@
APPS_REPO_ID=7a92c8307e0a8074763c80be1fcfa4f87da6641daea9211aea6743b0116aba3b
APPS_REPO_URL=https://github.com/meienberger/runtipi-appstore
APPS_REPO_URL=https://github.com/runtipi/runtipi-appstore
TZ=Etc/UTC
INTERNAL_IP=localhost
DNS_IP=9.9.9.9
ARCHITECTURE=arm64 # arm64 or amd64
ARCHITECTURE=arm64
TIPI_VERSION=1.5.2
JWT_SECRET=secret
ROOT_FOLDER_HOST=/path/to/runtipi # absolute path to the root folder of the runtipi installation
STORAGE_PATH=/path/to/runtipi # absolute path to the root folder of the runtipi installation
ROOT_FOLDER_HOST=/path/to/runtipi
STORAGE_PATH=/path/to/runtipi
NGINX_PORT=7000
NGINX_PORT_SSL=443
DOMAIN=tipi.localhost

View file

@ -1,5 +1,5 @@
module.exports = {
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'jsx-a11y', 'testing-library', 'jest-dom'],
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsx-a11y', 'testing-library', 'jest-dom'],
extends: [
'plugin:@typescript-eslint/recommended',
'next/core-web-vitals',
@ -10,7 +10,6 @@ module.exports = {
'plugin:import/typescript',
'prettier',
'plugin:react/recommended',
'plugin:jsdoc/recommended',
'plugin:jsx-a11y/recommended',
],
parser: '@typescript-eslint/parser',
@ -53,8 +52,6 @@ module.exports = {
'no-underscore-dangle': 0,
'arrow-body-style': 0,
'class-methods-use-this': 0,
'jsdoc/require-returns': 0,
'jsdoc/tag-lines': 0,
'import/extensions': [
'error',
'ignorePackages',

View file

@ -11,7 +11,7 @@ assignees: meienberger
Before opening your issue be sure to have completed all those tasks.
- [ ] I have searched for an already existing issue with similar context and errors. My issue has not yet been reported.
- [ ] I have included a clear description and steps to reproduce.
- [ ] I have included my OS information
- [ ] I have included logs from the file `runtipi/logs/error.log` if relevant
**Describe the bug**
A clear and concise description of what the bug is.
@ -29,11 +29,10 @@ A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Server (please complete the following information):**
- OS: [e.g. Ubuntu 20.04]
- Tipi Version [e.g. 2.0.5] (can be found in settings page)
**Additional context**
Add any other context about the problem here. Like results of the `start` script or container logs
Please include logs here `runtipi/logs/error.log` and add any other context about the problem here. Like results of the `start` script or container logs `docker logs ...`

View file

@ -90,7 +90,7 @@ jobs:
with:
command: |
echo 'Downloading install script from GitHub'
curl -s https://raw.githubusercontent.com/meienberger/runtipi/${{ inputs.version }}/scripts/install.sh > install.sh
curl -s https://raw.githubusercontent.com/runtipi/runtipi/${{ inputs.version }}/scripts/install.sh > install.sh
chmod +x install.sh
echo 'Running install script'
./install.sh --version ${{ inputs.version }}

View file

@ -23,7 +23,7 @@ jobs:
echo "tag=v${VERSION}" >> $GITHUB_OUTPUT
build-images:
if: github.repository == 'meienberger/runtipi'
if: github.repository == 'runtipi/runtipi'
needs: get-tag
runs-on: ubuntu-latest
steps:

View file

@ -14,6 +14,7 @@ WORKDIR /app
COPY ./pnpm-lock.yaml ./
COPY ./pnpm-workspace.yaml ./
COPY ./patches ./patches
RUN pnpm fetch --no-scripts
COPY ./package*.json ./

View file

@ -8,6 +8,7 @@ RUN npm install pnpm -g
WORKDIR /app
COPY ./pnpm-lock.yaml ./
COPY ./patches ./patches
RUN pnpm fetch --ignore-scripts
COPY ./package*.json ./

View file

@ -1,16 +1,17 @@
# Tipi — A personal homeserver for everyone
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-37-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-38-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![License](https://img.shields.io/github/license/meienberger/runtipi)](https://github.com/meienberger/runtipi/blob/master/LICENSE)
[![Version](https://img.shields.io/github/v/release/meienberger/runtipi?color=%235351FB&label=version)](https://github.com/meienberger/runtipi/releases)
![Issues](https://img.shields.io/github/issues/meienberger/runtipi)
[![License](https://img.shields.io/github/license/runtipi/runtipi)](https://github.com/runtipi/runtipi/blob/master/LICENSE)
[![Version](https://img.shields.io/github/v/release/runtipi/runtipi?color=%235351FB&label=version)](https://github.com/runtipi/runtipi/releases)
![Issues](https://img.shields.io/github/issues/runtipi/runtipi)
[![Docker Pulls](https://badgen.net/docker/pulls/meienberger/runtipi?icon=docker&label=pulls)](https://hub.docker.com/r/meienberger/runtipi/)
[![Docker Image Size](https://badgen.net/docker/size/meienberger/runtipi?icon=docker&label=image%20size)](https://hub.docker.com/r/meienberger/runtipi/)
![Build](https://github.com/meienberger/runtipi/workflows/Tipi%20CI/badge.svg)
[![codecov](https://codecov.io/gh/meienberger/runtipi/branch/master/graph/badge.svg?token=FZGO7ZOPSF)](https://codecov.io/gh/meienberger/runtipi)
![Build](https://github.com/runtipi/runtipi/workflows/Tipi%20CI/badge.svg)
[![Crowdin](https://badges.crowdin.net/runtipi/localized.svg)](https://crowdin.com/project/runtipi)
#### Join the discussion
@ -18,11 +19,11 @@
[![Discord](https://img.shields.io/discord/976934649643294750?label=discord&logo=discord)](https://discord.gg/Bu9qEPnHsc)
[![Matrix](https://img.shields.io/matrix/runtipi:matrix.org?label=matrix&logo=matrix)](https://matrix.to/#/#runtipi:matrix.org)
![Preview](https://raw.githubusercontent.com/meienberger/runtipi/develop/screenshots/appstore.png)
![Preview](https://raw.githubusercontent.com/runtipi/runtipi/develop/screenshots/appstore.png)
> ⚠️ Tipi is still at an early stage of development and issues are to be expected. Feel free to open an issue or pull request if you find a bug.
Tipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Tipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Tipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/meienberger/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below.
Tipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Tipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Tipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/runtipi/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below.
## Getting started
@ -51,7 +52,7 @@ We are looking for contributions of all kinds. If you know design, development,
## 📜 License
[![License](https://img.shields.io/github/license/meienberger/runtipi)](https://github.com/meienberger/runtipi/blob/master/LICENSE)
[![License](https://img.shields.io/github/license/runtipi/runtipi)](https://github.com/runtipi/runtipi/blob/master/LICENSE)
Tipi is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
@ -112,13 +113,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://micro.nghialele.com"><img src="https://avatars.githubusercontent.com/u/129353223?v=4?s=100" width="100px;" alt="Nghia Lele"/><br /><sub><b>Nghia Lele</b></sub></a><br /><a href="#translation-nghialele" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/amusingimpala75"><img src="https://avatars.githubusercontent.com/u/69653100?v=4?s=100" width="100px;" alt="amusingimpala75"/><br /><sub><b>amusingimpala75</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=amusingimpala75" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://m1n.omg.lol"><img src="https://avatars.githubusercontent.com/u/54779580?v=4?s=100" width="100px;" alt="David"/><br /><sub><b>David</b></sub></a><br /><a href="#translation-M1n-4d316e" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/steveiliop56"><img src="https://avatars.githubusercontent.com/u/106091011?v=4?s=100" width="100px;" alt="Stavros Iliopoulos"/><br /><sub><b>Stavros Iliopoulos</b></sub></a><br /><a href="#translation-steveiliop56" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/steveiliop56"><img src="https://avatars.githubusercontent.com/u/106091011?v=4?s=100" width="100px;" alt="Stavros"/><br /><sub><b>Stavros</b></sub></a><br /><a href="#translation-steveiliop56" title="Translation">🌍</a> <a href="https://github.com/meienberger/runtipi/commits?author=steveiliop56" title="Code">💻</a> <a href="https://github.com/meienberger/runtipi/commits?author=steveiliop56" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/loxiry"><img src="https://avatars.githubusercontent.com/u/86959495?v=4?s=100" width="100px;" alt="loxiry"/><br /><sub><b>loxiry</b></sub></a><br /><a href="#translation-loxiry" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JigSawFr"><img src="https://avatars.githubusercontent.com/u/5781907?v=4?s=100" width="100px;" alt="JigSaw"/><br /><sub><b>JigSaw</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=JigSawFr" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DireMunchkin"><img src="https://avatars.githubusercontent.com/u/1665676?v=4?s=100" width="100px;" alt="DireMunchkin"/><br /><sub><b>DireMunchkin</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=DireMunchkin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/FabioCingottini"><img src="https://avatars.githubusercontent.com/u/32102735?v=4?s=100" width="100px;" alt="Fabio Cingottini"/><br /><sub><b>Fabio Cingottini</b></sub></a><br /><a href="#translation-FabioCingottini" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/itsrllyhim"><img src="https://avatars.githubusercontent.com/u/143047010?v=4?s=100" width="100px;" alt="him"/><br /><sub><b>him</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=itsrllyhim" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View file

@ -40,13 +40,13 @@ services:
tipi-redis:
container_name: tipi-redis
image: redis:alpine
image: redis:7.2.0
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- 6379:6379
volumes:
- ./data/redis:/data
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
@ -103,9 +103,8 @@ services:
networks:
tipi_main_network:
driver: bridge
ipam:
driver: default
config:
- subnet: 10.21.21.0/24
name: runtipi_tipi_main_network
volumes:
pgdata:
redisdata:

110
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,110 @@
version: '3.7'
services:
tipi-reverse-proxy:
container_name: tipi-reverse-proxy
image: traefik:v2.8
restart: on-failure
ports:
- 80:80
- 443:443
- 8080:8080
command: --providers.docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}/traefik:/root/.config
- ${PWD}/traefik/shared:/shared
networks:
- tipi_main_network
tipi-db:
container_name: tipi-db
image: postgres:14
restart: unless-stopped
stop_grace_period: 1m
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: tipi
POSTGRES_DB: tipi
healthcheck:
test: ['CMD-SHELL', 'pg_isready -d tipi -U tipi']
interval: 5s
timeout: 10s
retries: 120
networks:
- tipi_main_network
tipi-redis:
container_name: tipi-redis
image: redis:7.2.0
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- 6379:6379
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 10s
retries: 120
networks:
- tipi_main_network
tipi-dashboard:
build:
context: .
dockerfile: Dockerfile
container_name: tipi-dashboard
depends_on:
tipi-db:
condition: service_healthy
tipi-redis:
condition: service_healthy
env_file:
- .env
environment:
NODE_ENV: development
networks:
- tipi_main_network
ports:
- 3000:3000
volumes:
- ${PWD}/.env:/runtipi/.env
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/runtipi/traefik
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
traefik.http.services.dashboard.loadbalancer.server.port: 3000
traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
# Local ip
traefik.http.routers.dashboard.rule: PathPrefix("/")
traefik.http.routers.dashboard.service: dashboard
traefik.http.routers.dashboard.entrypoints: web
# Local domain
traefik.http.routers.dashboard-local-insecure.rule: Host(`${LOCAL_DOMAIN}`)
traefik.http.routers.dashboard-local-insecure.entrypoints: web
traefik.http.routers.dashboard-local-insecure.service: dashboard
traefik.http.routers.dashboard-local-insecure.middlewares: redirect-to-https
# secure
traefik.http.routers.dashboard-local.rule: Host(`${LOCAL_DOMAIN}`)
traefik.http.routers.dashboard-local.entrypoints: websecure
traefik.http.routers.dashboard-local.tls: true
traefik.http.routers.dashboard-local.service: dashboard
networks:
tipi_main_network:
driver: bridge
name: runtipi_tipi_main_network
volumes:
pgdata:
redisdata:

View file

@ -1,8 +1,6 @@
import { test, expect } from '@playwright/test';
import { eq } from 'drizzle-orm';
import { userTable } from '@/server/db/schema';
import { loginUser } from './fixtures/fixtures';
import { clearDatabase, db } from './helpers/db';
import { clearDatabase } from './helpers/db';
import { testUser } from './helpers/constants';
test.beforeEach(async ({ page }) => {
@ -33,15 +31,3 @@ test('user can change their password', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('user can change their language and it is persisted in database', async ({ page }) => {
await page.getByRole('tab', { name: 'Settings' }).click();
await page.getByRole('combobox', { name: 'Language Help translate Tipi' }).click();
await page.getByRole('option', { name: 'Français' }).click();
await expect(page.getByText('Paramètres utilisateur')).toBeVisible();
const dbUser = await db.query.userTable.findFirst({ where: eq(userTable.username, testUser.email) });
expect(dbUser?.locale).toEqual('fr-FR');
});

1
next-env.d.ts vendored
View file

@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -4,6 +4,10 @@ const nextConfig = {
output: 'standalone',
reactStrictMode: true,
transpilePackages: ['@runtipi/shared'],
experimental: {
serverComponentsExternalPackages: ['bullmq'],
serverActions: true,
},
serverRuntimeConfig: {
INTERNAL_IP: process.env.INTERNAL_IP,
TIPI_VERSION: process.env.TIPI_VERSION,
@ -19,6 +23,14 @@ const nextConfig = {
NODE_ENV: process.env.NODE_ENV,
REDIS_HOST: process.env.REDIS_HOST,
},
async rewrites() {
return [
{
source: '/apps/:id',
destination: '/app-store/:id',
},
];
},
};
export default nextConfig;

View file

@ -1,6 +1,6 @@
{
"name": "runtipi",
"version": "2.0.5",
"version": "2.0.6",
"description": "A homeserver for everyone",
"scripts": {
"knip": "knip",
@ -13,7 +13,7 @@
"test:vite": "dotenv -e .env.test -- vitest run --coverage",
"dev": "npm run db:migrate && next dev",
"dev:watcher": "pnpm -r --filter cli dev",
"db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts",
"db:migrate": "NODE_ENV=development dotenv -e .env.local -- tsx ./src/server/run-migrations-dev.ts",
"lint": "next lint",
"lint:fix": "next lint --fix",
"build": "next build",
@ -21,7 +21,7 @@
"start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
"start:rc": "docker compose -f docker-compose.rc.yml --env-file .env up --build",
"start:dev": "npm run prepare && docker compose -f docker-compose.dev.yml up --build",
"start:e2e": "./scripts/start-e2e.sh latest",
"start:prod": "npm run prepare && docker compose --env-file ./.env -f docker-compose.prod.yml up --build",
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14",
"version": "echo $npm_package_version",
"release:rc": "./scripts/deploy/release-rc.sh",
@ -32,119 +32,106 @@
"tsc": "tsc"
},
"dependencies": {
"@hookform/resolvers": "^3.1.1",
"@hookform/resolvers": "^3.3.1",
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@runtipi/postgres-migrations": "^5.3.0",
"@runtipi/shared": "workspace:^",
"@tabler/core": "1.0.0-beta19",
"@tabler/icons-react": "^2.23.0",
"@tanstack/react-query": "^4.29.7",
"@tanstack/react-query-devtools": "^4.29.7",
"@trpc/client": "^10.27.1",
"@trpc/next": "^10.27.1",
"@trpc/react-query": "^10.27.1",
"@trpc/server": "^10.27.1",
"argon2": "^0.30.3",
"bullmq": "^4.5.0",
"clsx": "^1.1.1",
"@tabler/icons-react": "^2.38.0",
"argon2": "^0.31.1",
"bullmq": "^4.12.3",
"clsx": "^2.0.0",
"connect-redis": "^7.1.0",
"cookies-next": "^2.1.2",
"drizzle-orm": "^0.27.0",
"drizzle-orm": "^0.28.6",
"fs-extra": "^11.1.1",
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"next": "13.4.7",
"next-intl": "^2.15.1",
"pg": "^8.11.1",
"next": "13.5.4",
"next-client-cookies": "^1.0.5",
"next-intl": "^2.20.2",
"next-safe-action": "^3.4.0",
"pg": "^8.11.3",
"qrcode.react": "^3.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.1",
"react-hook-form": "^7.47.0",
"react-hot-toast": "^2.4.1",
"react-markdown": "^8.0.7",
"react-select": "^5.7.3",
"react-tooltip": "^5.16.1",
"react-markdown": "^9.0.0",
"react-select": "^5.7.7",
"react-tooltip": "^5.21.5",
"redaxios": "^0.5.1",
"redis": "^4.6.7",
"redis": "^4.6.10",
"rehype-raw": "^7.0.0",
"remark-breaks": "^3.0.3",
"remark-gfm": "^3.0.1",
"sass": "^1.63.6",
"semver": "^7.5.3",
"sharp": "0.32.1",
"superjson": "^1.12.3",
"tslib": "^2.5.3",
"uuid": "^9.0.0",
"validator": "^13.7.0",
"winston": "^3.9.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"sass": "^1.69.2",
"semver": "^7.5.4",
"sharp": "0.32.6",
"swr": "^2.2.4",
"tslib": "^2.6.2",
"uuid": "^9.0.1",
"validator": "^13.11.0",
"winston": "^3.11.0",
"zod": "^3.21.4",
"zustand": "^4.3.8"
"zustand": "^4.4.3"
},
"devDependencies": {
"@babel/core": "^7.22.5",
"@faker-js/faker": "^8.0.2",
"@playwright/test": "^1.35.1",
"@testing-library/dom": "^9.3.1",
"@testing-library/jest-dom": "^5.16.5",
"@babel/core": "^7.23.0",
"@faker-js/faker": "^8.1.0",
"@playwright/test": "^1.38.1",
"@testing-library/dom": "^9.3.3",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@testing-library/user-event": "^14.5.1",
"@total-typescript/shoehorn": "^0.1.1",
"@total-typescript/ts-reset": "^0.4.2",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.7",
"@types/fs-extra": "^11.0.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.5.2",
"@total-typescript/ts-reset": "^0.5.1",
"@types/fs-extra": "^11.0.2",
"@types/jest": "^29.5.5",
"@types/lodash.merge": "^4.6.7",
"@types/node": "20.3.2",
"@types/pg": "^8.10.2",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"@types/semver": "^7.5.0",
"@types/supertest": "^2.0.12",
"@types/testing-library__jest-dom": "^5.14.6",
"@types/uuid": "^9.0.2",
"@types/validator": "^13.7.17",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.60.1",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^0.32.2",
"dotenv-cli": "^7.2.1",
"eslint": "8.43.0",
"@types/node": "20.8.4",
"@types/pg": "^8.10.5",
"@types/react": "18.2.28",
"@types/react-dom": "18.2.13",
"@types/semver": "^7.5.3",
"@types/uuid": "^9.0.5",
"@types/validator": "^13.11.2",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"@vitejs/plugin-react": "^4.1.0",
"@vitest/coverage-v8": "^0.34.6",
"dotenv-cli": "^7.3.0",
"eslint": "8.51.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-next": "13.4.7",
"eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-jest-dom": "^5.0.1",
"eslint-plugin-jsdoc": "^46.3.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.31.10",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-next": "13.5.4",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jest": "^27.4.2",
"eslint-plugin-jest-dom": "^5.1.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-testing-library": "^5.11.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"knip": "^2.19.4",
"memfs": "^4.2.0",
"msw": "^1.2.2",
"next-router-mock": "^0.9.7",
"prettier": "^2.8.8",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"eslint-plugin-testing-library": "^6.0.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"knip": "^2.33.1",
"memfs": "^4.6.0",
"msw": "^1.3.2",
"next-router-mock": "^0.9.10",
"prettier": "^3.0.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"tsx": "^3.12.7",
"typescript": "5.1.5",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.32.2",
"tsx": "^3.13.0",
"typescript": "5.2.2",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^0.34.6",
"wait-for-expect": "^3.0.2"
},
"msw": {
@ -152,12 +139,17 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/meienberger/runtipi.git"
"url": "git+https://github.com/runtipi/runtipi.git"
},
"author": "",
"license": "GNU General Public License v3.0",
"bugs": {
"url": "https://github.com/meienberger/runtipi/issues"
"url": "https://github.com/runtipi/runtipi/issues"
},
"homepage": "https://github.com/meienberger/runtipi#readme"
"homepage": "https://github.com/runtipi/runtipi#readme",
"pnpm": {
"patchedDependencies": {
"next-safe-action@3.4.0": "patches/next-safe-action@3.4.0.patch"
}
}
}

View file

@ -6,3 +6,8 @@ ROOT_FOLDER_HOST=/runtipi
STORAGE_PATH=/runtipi
TIPI_VERSION=1
REDIS_PASSWORD=redis
POSTGRES_HOST=localhost
POSTGRES_DBNAME=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433

View file

@ -1,6 +1,6 @@
{
"name": "@runtipi/cli",
"version": "2.0.5",
"version": "2.0.6",
"description": "",
"main": "index.js",
"bin": "dist/index.js",
@ -28,36 +28,37 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/cli-progress": "^3.11.0",
"@types/node": "20.3.2",
"@types/web-push": "^3.3.2",
"dotenv-cli": "^7.2.1",
"esbuild": "^0.16.17",
"eslint-config-prettier": "^8.8.0",
"memfs": "^4.2.0",
"nodemon": "^2.0.22",
"@faker-js/faker": "^8.1.0",
"@types/cli-progress": "^3.11.3",
"@types/node": "20.8.4",
"@types/web-push": "^3.6.1",
"dotenv-cli": "^7.3.0",
"esbuild": "^0.19.4",
"eslint-config-prettier": "^9.0.0",
"memfs": "^4.6.0",
"nodemon": "^3.0.1",
"pkg": "^5.8.1",
"vite": "^4.4.7",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.32.2"
"vite": "^4.4.11",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^0.34.6"
},
"dependencies": {
"@runtipi/postgres-migrations": "^5.3.0",
"@runtipi/shared": "workspace:^",
"axios": "^1.4.0",
"axios": "^1.5.1",
"boxen": "^7.1.1",
"bullmq": "^4.5.0",
"bullmq": "^4.12.3",
"chalk": "^5.3.0",
"cli-progress": "^3.12.0",
"cli-spinners": "^2.9.0",
"cli-spinners": "^2.9.1",
"commander": "^11.0.0",
"dotenv": "^16.3.1",
"ioredis": "^5.3.2",
"log-update": "^5.0.1",
"pg": "^8.11.1",
"semver": "^7.5.3",
"systeminformation": "^5.18.7",
"web-push": "^3.6.3",
"pg": "^8.11.3",
"semver": "^7.5.4",
"systeminformation": "^5.21.11",
"web-push": "^3.6.6",
"zod": "^3.21.4"
}
}

View file

@ -176,7 +176,7 @@ export class AppExecutors {
this.logger.info(`Regenerating app.env file for app ${appId}`);
await this.ensureAppDir(appId);
await generateEnvFile(appId, config);
await compose(appId, 'up --detach --force-recreate --remove-orphans');
await compose(appId, 'up --detach --force-recreate --remove-orphans --pull always');
this.logger.info(`App ${appId} started`);

View file

@ -1,6 +1,7 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-await-in-loop */
import { Queue } from 'bullmq';
import { Redis } from 'ioredis';
import fs from 'fs';
import cliProgress from 'cli-progress';
import semver from 'semver';
@ -66,7 +67,6 @@ export class SystemExecutors {
const filesAndFolders = [
path.join(rootFolderHost, 'apps'),
path.join(rootFolderHost, 'logs'),
path.join(rootFolderHost, 'media'),
path.join(rootFolderHost, 'repos'),
path.join(rootFolderHost, 'state'),
path.join(rootFolderHost, 'traefik'),
@ -258,6 +258,13 @@ export class SystemExecutors {
spinner.done('Watcher started');
// Flush redis cache
this.logger.info('Flushing redis cache...');
const cache = new Redis({ host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD'), lazyConnect: true });
await cache.connect();
await cache.flushdb();
await cache.quit();
this.logger.info('Starting queue...');
const queue = new Queue('events', { connection: { host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD') } });
this.logger.info('Obliterating queue...');
@ -296,16 +303,21 @@ export class SystemExecutors {
await appExecutor.startAllApps();
console.log(
boxen(`Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get('NGINX_PORT')} to access the dashboard\n\nFind documentation and guides at: https://runtipi.io`, {
title: 'Tipi successfully started 🎉',
titleAlignment: 'center',
textAlignment: 'center',
padding: 1,
borderStyle: 'double',
borderColor: 'green',
width: 80,
margin: { top: 1 },
}),
boxen(
`Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get(
'NGINX_PORT',
)} to access the dashboard\n\nFind documentation and guides at: https://runtipi.io\n\nTipi is entierly written in TypeScript and we are looking for contributors!`,
{
title: 'Tipi successfully started 🎉',
titleAlignment: 'center',
textAlignment: 'center',
padding: 1,
borderStyle: 'double',
borderColor: 'green',
width: 80,
margin: { top: 1 },
},
),
);
return { success: true, message: 'Tipi started' };
@ -355,7 +367,7 @@ export class SystemExecutors {
if (!targetVersion || targetVersion === 'latest') {
spinner.setMessage('Fetching latest version...');
const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/runtipi/runtipi/releases/latest');
this.logger.info(`Getting latest version from GitHub: ${data.tag_name}`);
targetVersion = data.tag_name;
}
@ -375,7 +387,7 @@ export class SystemExecutors {
const fileName = `runtipi-cli-${targetVersion}`;
const savePath = path.join(rootFolderHost, fileName);
const fileUrl = `https://github.com/meienberger/runtipi/releases/download/${targetVersion}/${assetName}`;
const fileUrl = `https://github.com/runtipi/runtipi/releases/download/${targetVersion}/${assetName}`;
this.logger.info(`Downloading Tipi ${targetVersion} from ${fileUrl}`);
spinner.done(`Target version: ${targetVersion}`);

View file

@ -37,7 +37,8 @@ type EnvKeys =
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {});
const DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore';
const OLD_DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore';
const DEFAULT_REPO_URL = 'https://github.com/runtipi/runtipi-appstore';
/**
* Reads and returns the generated seed
@ -145,6 +146,10 @@ export const generateSystemEnvFile = async () => {
const { data } = settings;
if (data.appsRepoUrl === OLD_DEFAULT_REPO_URL) {
data.appsRepoUrl = DEFAULT_REPO_URL;
}
const jwtSecret = envMap.get('JWT_SECRET') || (await deriveEntropy('jwt_secret'));
const repoId = getRepoHash(data.appsRepoUrl || DEFAULT_REPO_URL);
const postgresPassword = envMap.get('POSTGRES_PASSWORD') || (await deriveEntropy('postgres_password'));

View file

@ -105,7 +105,7 @@ export const startWorker = async () => {
return { success, stdout: message };
},
{ connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 } },
{ connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 }, removeOnComplete: { count: 200 }, removeOnFail: { count: 500 } },
);
worker.on('ready', () => {
@ -121,6 +121,6 @@ export const startWorker = async () => {
});
worker.on('error', async (e) => {
fileLogger.error(`Worker error: ${e}`);
fileLogger.debug(`Worker error: ${e}`);
});
};

View file

@ -5,12 +5,9 @@ import { fileLogger } from '../logger/file-logger';
import { execAsync } from '../exec-async/execAsync';
const composeUp = async (args: string[]) => {
fileLogger.info(`Running docker compose with args ${args.join(' ')}`);
const { stdout, stderr } = await execAsync(`docker compose ${args.join(' ')}`);
if (stderr) {
fileLogger.error(stderr);
}
return { stdout, stderr };
};

View file

@ -7,7 +7,7 @@ export class TerminalSpinner {
frame = 0;
interval: NodeJS.Timer | null = null;
interval: NodeJS.Timeout | null = null;
start() {
this.interval = setInterval(() => {

View file

@ -34,6 +34,10 @@ export const newLogger = (id: string, logsFolder: string) => {
);
exceptionHandlers = [new transports.File({ filename: path.join(logsFolder, 'error.log') })];
if (process.env.NODE_ENV !== 'production') {
tr.push(new transports.Console({ level: 'debug' }));
}
return createLogger({
level: 'debug',
format: combine(

0
patches/.gitkeep Normal file
View file

View file

@ -0,0 +1,113 @@
diff --git a/dist/hook.mjs b/dist/hook.mjs
index 4f2ea0f6818194b906590f2467f788e66d3524d9..fcec224f19be119a922734e8a6fb7d1916921d8a 100644
--- a/dist/hook.mjs
+++ b/dist/hook.mjs
@@ -7,7 +7,7 @@ import {
useEffect,
useRef,
useState,
- useTransition
+ useTransition,
} from "react";
// src/utils.ts
@@ -17,8 +17,12 @@ var isNextNotFoundError = (e) => isError(e) && e.message === "NEXT_NOT_FOUND";
// src/hook.ts
var getActionStatus = (res) => {
- const hasSucceded = typeof res.data !== "undefined";
- const hasErrored = typeof res.validationError !== "undefined" || typeof res.serverError !== "undefined" || typeof res.fetchError !== "undefined";
+ const hasSucceded = typeof res?.data !== "undefined";
+ const hasErrored =
+ typeof res === "undefined" ||
+ typeof res.validationError !== "undefined" ||
+ typeof res.serverError !== "undefined" ||
+ typeof res.fetchError !== "undefined";
const hasExecuted = hasSucceded || hasErrored;
return { hasExecuted, hasSucceded, hasErrored };
};
@@ -49,12 +53,15 @@ var useAction = (clientCaller, cb) => {
onExecute(input2);
}
return startTransition(() => {
- return executor.current(input2).then((res2) => setRes(res2)).catch((e) => {
- if (isNextRedirectError(e) || isNextNotFoundError(e)) {
- throw e;
- }
- setRes({ fetchError: e });
- });
+ return executor
+ .current(input2)
+ .then((res2) => setRes(res2))
+ .catch((e) => {
+ if (isNextRedirectError(e) || isNextNotFoundError(e)) {
+ throw e;
+ }
+ setRes({ fetchError: e });
+ });
});
}, []);
const reset = useCallback(() => {
@@ -68,17 +75,20 @@ var useAction = (clientCaller, cb) => {
reset,
hasExecuted,
hasSucceded,
- hasErrored
+ hasErrored,
};
};
var useOptimisticAction = (clientCaller, initialOptData, cb) => {
const [res, setRes] = useState({});
const [input, setInput] = useState();
- const [optState, syncState] = experimental_useOptimistic({ ...initialOptData, ...res.data, __isExecuting__: false }, (state, newState) => ({
- ...state,
- ...newState,
- __isExecuting__: true
- }));
+ const [optState, syncState] = experimental_useOptimistic(
+ { ...initialOptData, ...res.data, __isExecuting__: false },
+ (state, newState) => ({
+ ...state,
+ ...newState,
+ __isExecuting__: true,
+ })
+ );
const executor = useRef(clientCaller);
const onExecuteRef = useRef(cb?.onExecute);
const { hasExecuted, hasSucceded, hasErrored } = getActionStatus(res);
@@ -90,12 +100,15 @@ var useOptimisticAction = (clientCaller, initialOptData, cb) => {
if (onExecute) {
onExecute(input2);
}
- return executor.current(input2).then((res2) => setRes(res2)).catch((e) => {
- if (isNextRedirectError(e) || isNextNotFoundError(e)) {
- throw e;
- }
- setRes({ fetchError: e });
- });
+ return executor
+ .current(input2)
+ .then((res2) => setRes(res2))
+ .catch((e) => {
+ if (isNextRedirectError(e) || isNextNotFoundError(e)) {
+ throw e;
+ }
+ setRes({ fetchError: e });
+ });
},
[syncState]
);
@@ -113,11 +126,8 @@ var useOptimisticAction = (clientCaller, initialOptData, cb) => {
reset,
hasExecuted,
hasSucceded,
- hasErrored
+ hasErrored,
};
};
-export {
- useAction,
- useOptimisticAction
-};
+export { useAction, useOptimisticAction };
//# sourceMappingURL=hook.mjs.map

7787
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -2,7 +2,7 @@
/* tslint:disable */
/**
* Mock Service Worker (1.2.2).
* Mock Service Worker (1.3.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="682.667" height="682.667" preserveAspectRatio="xMidYMid meet" version="1.0" viewBox="0 0 512 512"><metadata>Created by potrace 1.14, written by Peter Selinger 2001-2017</metadata><g fill="#000" stroke="none"><path d="M2263 5057 c-67 -34 -125 -65 -128 -68 -3 -4 53 -126 125 -271 l132 -263 -122 -275 c-66 -151 -130 -295 -142 -320 -11 -25 -37 -83 -58 -130 -21 -47 -347 -706 -725 -1465 -378 -759 -786 -1578 -906 -1820 l-219 -440 2337 -3 c1285 -1 2338 0 2340 2 2 2 -397 809 -888 1792 -706 1417 -931 1879 -1085 2224 l-194 434 134 267 133 267 -135 66 c-103 51 -137 64 -143 54 -5 -7 -41 -79 -81 -160 -40 -81 -75 -147 -78 -148 -3 0 -27 44 -54 98 -101 203 -111 222 -116 221 -3 0 -60 -29 -127 -62z" transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"/></g></svg>

Before

Width:  |  Height:  |  Size: 815 B

View file

@ -1,19 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -166,7 +166,7 @@ function check_dependency_and_install() {
# If version was not given it will install the latest version
if [[ "${VERSION}" == "latest" ]]; then
LATEST_VERSION=$(curl -s https://api.github.com/repos/meienberger/runtipi/releases/latest | grep tag_name | cut -d '"' -f4)
LATEST_VERSION=$(curl -sL https://api.github.com/repos/runtipi/runtipi/releases/latest | grep tag_name | cut -d '"' -f4)
VERSION="${LATEST_VERSION}"
fi
@ -175,7 +175,7 @@ if [ "$ARCHITECTURE" == "arm64" ] || [ "$ARCHITECTURE" == "aarch64" ]; then
ASSET="runtipi-cli-linux-arm64"
fi
URL="https://github.com/meienberger/runtipi/releases/download/$VERSION/$ASSET"
URL="https://github.com/runtipi/runtipi/releases/download/$VERSION/$ASSET"
if [[ "${UPDATE}" == "false" ]]; then
mkdir -p runtipi

33
src/app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,33 @@
import React from 'react';
import Image from 'next/image';
import { getCurrentLocale } from 'src/utils/getCurrentLocale';
import { LanguageSelector } from '../components/LanguageSelector';
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const locale = getCurrentLocale();
return (
<div className="page page-center">
<div className="position-absolute top-0 mt-3 end-0 me-1 pb-4">
<LanguageSelector locale={locale} />
</div>
<div className="container container-tight py-4">
<div className="text-center mb-4">
<Image
alt="Tipi logo"
src="/tipi.png"
height={50}
width={50}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
</div>
<div className="card card-md">
<div className="card-body">{children}</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
'use client';
import { useAction } from 'next-safe-action/hook';
import React, { useState } from 'react';
import { toast } from 'react-hot-toast';
import { loginAction } from '@/actions/login/login-action';
import { verifyTotpAction } from '@/actions/verify-totp/verify-totp-action';
import { useRouter } from 'next/navigation';
import { LoginForm } from '../LoginForm';
import { TotpForm } from '../TotpForm';
export function LoginContainer() {
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const router = useRouter();
const loginMutation = useAction(loginAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else if (data.success && data.totpSessionId) {
setTotpSessionId(data.totpSessionId);
} else {
router.push('/dashboard');
}
},
});
const verifyTotpMutation = useAction(verifyTotpAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
router.push('/dashboard');
}
},
});
if (totpSessionId) {
return <TotpForm loading={verifyTotpMutation.isExecuting} onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })} />;
}
return <LoginForm loading={loginMutation.isExecuting} onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })} />;
}

View file

@ -4,8 +4,8 @@ import z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
type FormValues = { email: string; password: string };

View file

@ -0,0 +1,23 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { db } from '@/server/db';
import { LoginContainer } from './components/LoginContainer';
export default async function LoginPage() {
const authQueries = new AuthQueries(db);
const isConfigured = await authQueries.getFirstOperator();
if (!isConfigured) {
redirect('/register');
}
const user = await getUserFromCookie();
if (user) {
redirect('/dashboard');
}
return <LoginContainer />;
}

View file

@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { useAction } from 'next-safe-action/hook';
import { toast } from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { registerAction } from '@/actions/register/register-action';
import { RegisterForm } from '../RegisterForm';
export const RegisterContainer: React.FC = () => {
const router = useRouter();
const registerMutation = useAction(registerAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
router.push('/dashboard');
}
},
});
return <RegisterForm onSubmit={({ email, password }) => registerMutation.execute({ username: email, password })} loading={registerMutation.isExecuting} />;
};

View file

@ -3,8 +3,8 @@ import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
interface IProps {
onSubmit: (values: FormValues) => void;

View file

@ -0,0 +1,22 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { db } from '@/server/db';
import { RegisterContainer } from './components/RegisterContainer';
export default async function LoginPage() {
const user = await getUserFromCookie();
if (user) {
redirect('/dashboard');
}
const authQueries = new AuthQueries(db);
const isConfigured = await authQueries.getFirstOperator();
if (isConfigured) {
redirect('/login');
}
return <RegisterContainer />;
}

View file

@ -0,0 +1,52 @@
'use client';
import React from 'react';
import { useAction } from 'next-safe-action/hook';
import { toast } from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/Button';
import { resetPasswordAction } from '@/actions/reset-password/reset-password-action';
import { cancelResetPasswordAction } from '@/actions/cancel-reset-password/cancel-reset-password-action';
import { ResetPasswordForm } from '../ResetPasswordForm';
export const ResetPasswordContainer: React.FC = () => {
const t = useTranslations();
const router = useRouter();
const resetPasswordMutation = useAction(resetPasswordAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
}
},
});
const cancelRequestMutation = useAction(cancelResetPasswordAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
}
},
});
if (resetPasswordMutation.res.data?.success && resetPasswordMutation.res.data?.email) {
return (
<>
<h2 className="h2 text-center mb-3">{t('auth.reset-password.success-title')}</h2>
<p>{t('auth.reset-password.success', { email: resetPasswordMutation.res.data.email })}</p>
<Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
{t('auth.reset-password.back-to-login')}
</Button>
</>
);
}
return (
<ResetPasswordForm
loading={resetPasswordMutation.isExecuting}
onCancel={() => cancelRequestMutation.execute()}
onSubmit={({ password }) => resetPasswordMutation.execute({ newPassword: password })}
/>
);
};

View file

@ -3,8 +3,8 @@ import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
interface IProps {
onSubmit: (values: FormValues) => void;

View file

@ -0,0 +1,23 @@
import React from 'react';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { ResetPasswordContainer } from './components/ResetPasswordContainer';
export default async function ResetPasswordPage() {
const isRequested = AuthServiceClass.checkPasswordChangeRequest();
const translator = await getTranslatorFromCookie();
if (isRequested) {
return <ResetPasswordContainer />;
}
return (
<>
<h2 className="h2 text-center mb-3">{translator('auth.reset-password.title')}</h2>
<p>{translator('auth.reset-password.instructions')}</p>
<pre>
<code>./runtipi-cli reset-password</code>
</pre>
</>
);
}

View file

@ -1,7 +1,7 @@
import React from 'react';
import { AppInfo } from '@runtipi/shared';
import { AppActions } from './AppActions';
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../tests/test-utils';
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../../tests/test-utils';
afterEach(cleanup);
@ -136,14 +136,14 @@ describe('Test: AppActions', () => {
// act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/myapp.example.com/)).toBeInTheDocument();
});
const domainButton = screen.getByText(/myapp.example.com/);
// assert
userEvent.click(domainButton);
await userEvent.click(domainButton);
await waitFor(() => {
expect(openFn).toHaveBeenCalledWith('domain');
});
@ -157,14 +157,14 @@ describe('Test: AppActions', () => {
// act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/test.tipi.lan/)).toBeInTheDocument();
});
const localButton = screen.getByText(/test.tipi.lan/);
// assert
userEvent.click(localButton);
await userEvent.click(localButton);
await waitFor(() => {
expect(openFn).toHaveBeenCalledWith('local_domain');
});
@ -178,14 +178,14 @@ describe('Test: AppActions', () => {
// act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/localhost:3000/)).toBeInTheDocument();
});
const localButton = screen.getByText(/localhost:3000/);
// assert
userEvent.click(localButton);
await userEvent.click(localButton);
await waitFor(() => {
expect(openFn).toHaveBeenCalledWith('local');
});

View file

@ -5,11 +5,11 @@ import type { AppStatus } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
import { Button } from '../../../../components/ui/Button';
import { AppWithInfo } from '../../../../core/types';
import { Button } from '@/components/ui/Button';
import type { AppService } from '@/server/services/apps/apps.service';
interface IProps {
app: AppWithInfo;
app: Awaited<ReturnType<AppService['getApp']>>;
status?: AppStatus;
updateAvailable: boolean;
localDomain?: string;
@ -52,6 +52,8 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
const t = useTranslations('apps.app-details');
const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
const hostname = typeof window !== 'undefined' ? window.location.hostname : '';
const buttons: JSX.Element[] = [];
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
@ -87,7 +89,7 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
{!app.info.force_expose && (
<DropdownMenuItem onClick={() => onOpen('local')}>
<IconLockOff className="text-muted me-2" size={16} />
{window.location.hostname}:{app.info.port}
{hostname}:{app.info.port}
</DropdownMenuItem>
)}
</DropdownMenuGroup>

View file

@ -0,0 +1,219 @@
'use client';
import React from 'react';
import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl';
import { useDisclosure } from '@/client/hooks/useDisclosure';
import { useAction } from 'next-safe-action/hook';
import { installAppAction } from '@/actions/app-actions/install-app-action';
import { uninstallAppAction } from '@/actions/app-actions/uninstall-app-action';
import { stopAppAction } from '@/actions/app-actions/stop-app-action';
import { startAppAction } from '@/actions/app-actions/start-app-action';
import { updateAppAction } from '@/actions/app-actions/update-app-action';
import { updateAppConfigAction } from '@/actions/app-actions/update-app-config-action';
import { AppLogo } from '@/components/AppLogo';
import { AppStatus } from '@/components/AppStatus';
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
import { AppService } from '@/server/services/apps/apps.service';
import { InstallModal } from '../InstallModal';
import { StopModal } from '../StopModal';
import { UninstallModal } from '../UninstallModal';
import { UpdateModal } from '../UpdateModal';
import { UpdateSettingsModal } from '../UpdateSettingsModal/UpdateSettingsModal';
import { AppActions } from '../AppActions';
import { AppDetailsTabs } from '../AppDetailsTabs';
import { FormValues } from '../InstallForm';
interface IProps {
app: Awaited<ReturnType<AppService['getApp']>>;
localDomain?: string;
}
type OpenType = 'local' | 'domain' | 'local_domain';
export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
const [customStatus, setCustomStatus] = React.useState<AppStatusEnum>(app.status);
const t = useTranslations();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const updateSettingsDisclosure = useDisclosure();
const installMutation = useAction(installAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('running');
toast.success(t('apps.app-details.install-success'));
}
},
});
const uninstallMutation = useAction(uninstallAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('missing');
toast.success(t('apps.app-details.uninstall-success'));
}
},
});
const stopMutation = useAction(stopAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('stopped');
toast.success(t('apps.app-details.stop-success'));
}
},
});
const startMutation = useAction(startAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('running');
toast.success(t('apps.app-details.start-success'));
}
},
});
const updateMutation = useAction(updateAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('stopped');
toast.success(t('apps.app-details.update-success'));
}
},
});
const updateConfigMutation = useAction(updateAppConfigAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('apps.app-details.update-config-success'));
}
},
});
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
const handleInstallSubmit = async (values: FormValues) => {
setCustomStatus('installing');
installDisclosure.close();
const { exposed, domain } = values;
installMutation.execute({ id: app.id, form: values, exposed, domain });
};
const handleUnistallSubmit = () => {
setCustomStatus('uninstalling');
uninstallDisclosure.close();
uninstallMutation.execute({ id: app.id });
};
const handleStopSubmit = () => {
setCustomStatus('stopping');
stopDisclosure.close();
stopMutation.execute({ id: app.id });
};
const handleStartSubmit = async () => {
setCustomStatus('starting');
startMutation.execute({ id: app.id });
};
const handleUpdateSettingsSubmit = async (values: FormValues) => {
updateSettingsDisclosure.close();
const { exposed, domain } = values;
updateConfigMutation.execute({ id: app.id, form: values, exposed, domain });
};
const handleUpdateSubmit = async () => {
setCustomStatus('updating');
updateDisclosure.close();
updateMutation.execute({ id: app.id });
};
const handleOpen = (type: OpenType) => {
let url = '';
const { https } = app.info;
const protocol = https ? 'https' : 'http';
if (typeof window !== 'undefined') {
// Current domain
const domain = window.location.hostname;
url = `${protocol}://${domain}:${app.info.port}${app.info.url_suffix || ''}`;
}
if (type === 'domain' && app.domain) {
url = `https://${app.domain}${app.info.url_suffix || ''}`;
}
if (type === 'local_domain') {
url = `https://${app.id}.${localDomain}`;
}
window.open(url, '_blank', 'noreferrer');
};
const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
return (
<div className="card" data-testid="app-details">
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
<UpdateSettingsModal
onSubmit={handleUpdateSettingsSubmit}
isOpen={updateSettingsDisclosure.isOpen}
onClose={updateSettingsDisclosure.close}
info={app.info}
config={castAppConfig(app?.config)}
exposed={app?.exposed}
domain={app?.domain || ''}
/>
<div className="card-header d-flex flex-column flex-md-row">
<AppLogo id={app.id} size={130} alt={app.info.name} />
<div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
<div>
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
<span className="badge bg-gray mt-2">{app.info.version}</span>
</div>
<span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
<div className="mb-1">{customStatus !== 'missing' && <AppStatus status={customStatus} />}</div>
<AppActions
localDomain={localDomain}
updateAvailable={updateAvailable}
onUpdate={updateDisclosure.open}
onUpdateSettings={updateSettingsDisclosure.open}
onStop={stopDisclosure.open}
onCancel={stopDisclosure.open}
onUninstall={uninstallDisclosure.open}
onInstall={installDisclosure.open}
onOpen={handleOpen}
onStart={handleStartSubmit}
app={app}
status={customStatus}
/>
</div>
</div>
<AppDetailsTabs info={app.info} />
</div>
);
};

View file

@ -3,8 +3,8 @@ import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
import Markdown from '../../../components/Markdown/Markdown';
import Markdown from '@/components/Markdown/Markdown';
import { DataGrid, DataGridItem } from '@/components/ui/DataGrid';
interface IProps {
info: AppInfo;

View file

@ -0,0 +1 @@
export { AppDetailsTabs } from './AppDetailsTabs';

View file

@ -2,7 +2,7 @@ import React from 'react';
import { faker } from '@faker-js/faker';
import { fromPartial } from '@total-typescript/shoehorn';
import { FormField } from '@runtipi/shared';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
import { InstallForm } from './InstallForm';
describe('Test: InstallForm', () => {

View file

@ -6,9 +6,9 @@ import { Tooltip } from 'react-tooltip';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';
import { type FormField, type AppInfo } from '@runtipi/shared';
import { Button } from '../../../../components/ui/Button';
import { Switch } from '../../../../components/ui/Switch';
import { Input } from '../../../../components/ui/Input';
import { Switch } from '@/components/ui/Switch';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { validateAppConfig } from '../../utils/validators';
interface IProps {

View file

@ -0,0 +1 @@
export { InstallForm, type FormValues } from './InstallForm';

View file

@ -1,7 +1,7 @@
import React from 'react';
import { AppInfo } from '@runtipi/shared';
import { InstallModal } from './InstallModal';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
describe('InstallModal', () => {
const app = {

View file

@ -2,8 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { InstallForm } from '../InstallForm';
import { FormValues } from '../InstallForm/InstallForm';
import { InstallForm, FormValues } from '../InstallForm';
interface IProps {
info: AppInfo;

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../components/ui/Button';
import { Button } from '@/components/ui/Button';
interface IProps {
info: AppInfo;

View file

@ -0,0 +1 @@
export { StopModal } from './StopModal';

View file

@ -3,7 +3,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../components/ui/Button';
import { Button } from '@/components/ui/Button';
interface IProps {
info: AppInfo;

View file

@ -0,0 +1 @@
export { UninstallModal } from './UninstallModal';

View file

@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen } from '../../../../../../../tests/test-utils';
import { UpdateModal } from './UpdateModal';
describe('UpdateModal', () => {

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../../components/ui/Button';
import { Button } from '@/components/ui/Button';
interface IProps {
newVersion: string;

View file

@ -2,8 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { InstallForm } from './InstallForm';
import { FormValues } from './InstallForm/InstallForm';
import { InstallForm, type FormValues } from '../InstallForm';
interface IProps {
info: AppInfo;

View file

@ -0,0 +1,23 @@
import { AppServiceClass } from '@/server/services/apps/apps.service';
import React from 'react';
import { Metadata } from 'next';
import { db } from '@/server/db';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { getSettings } from '@/server/core/TipiConfig';
import { AppDetailsContainer } from './components/AppDetailsContainer/AppDetailsContainer';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('apps.app-store.title')} - Tipi`,
};
}
export default async function AppDetailsPage({ params }: { params: { id: string } }) {
const appsService = new AppServiceClass(db);
const app = await appsService.getApp(params.id);
const settings = getSettings();
return <AppDetailsContainer app={app} localDomain={settings.localDomain} />;
}

View file

@ -0,0 +1,25 @@
'use client';
import React from 'react';
import { AppStoreTile } from '../AppStoreTile';
import { AppTableData } from '../../helpers/table.types';
import { useAppStoreState } from '../../state/appStoreState';
import { sortTable } from '../../helpers/table.helpers';
interface IProps {
data: AppTableData;
}
export const AppStoreTable: React.FC<IProps> = ({ data }) => {
const { category, search, sort, sortDirection } = useAppStoreState();
const tableData = React.useMemo(() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }), [data, sort, sortDirection, category, search]);
return (
<div className="row row-cards">
{tableData.map((app) => (
<AppStoreTile key={app.id} app={app} />
))}
</div>
);
};

View file

@ -0,0 +1 @@
export { AppStoreTable } from './AppStoreTable';

View file

@ -0,0 +1,21 @@
'use client';
import clsx from 'clsx';
import React from 'react';
import { Input } from '@/components/ui/Input';
import { useTranslations } from 'next-intl';
import styles from './AppStoreTableActions.module.scss';
import { useAppStoreState } from '../../state/appStoreState';
import { CategorySelector } from '../CategorySelector';
export const AppStoreTableActions = () => {
const { setCategory, category, search, setSearch } = useAppStoreState();
const t = useTranslations('apps.app-store');
return (
<div className="d-flex align-items-stretch align-items-md-center flex-column flex-md-row justify-content-end">
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('search-placeholder')} className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)} />
<CategorySelector initialValue={category} className={clsx('flex-fill mt-2 mt-md-0', styles.selector)} onSelect={setCategory} />
</div>
);
};

View file

@ -1,11 +1,13 @@
'use client';
import clsx from 'clsx';
import Link from 'next/link';
import React from 'react';
import { useTranslations } from 'next-intl';
import { AppCategory } from '@runtipi/shared';
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
import { AppLogo } from '@/components/AppLogo';
import styles from './AppStoreTile.module.scss';
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
type App = {
id: string;
@ -14,7 +16,7 @@ type App = {
short_desc: string;
};
const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
export const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
const t = useTranslations('apps.app-details');
return (
@ -34,5 +36,3 @@ const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
</Link>
);
};
export default AppStoreTile;

View file

@ -0,0 +1 @@
export { AppStoreTile } from './AppStoreTile';

View file

@ -1,5 +1,5 @@
import React from 'react';
import CategorySelector from './CategorySelector';
import { CategorySelector } from './CategorySelector';
import { fireEvent, render, screen } from '../../../../../../tests/test-utils';
describe('Test: CategorySelector', () => {

View file

@ -1,10 +1,10 @@
import { Icon } from '@tabler/icons-react';
import React from 'react';
import Select, { SingleValue, OptionProps, ControlProps, components } from 'react-select';
import { Icon } from '@tabler/icons-react';
import { useTranslations } from 'next-intl';
import { AppCategory } from '@runtipi/shared';
import { APP_CATEGORIES } from '../../../../core/constants';
import { useUIStore } from '../../../../state/uiStore';
import { useUIStore } from '@/client/state/uiStore';
import { iconForCategory } from '../../helpers/table.helpers';
const { Option, Control } = components;
@ -47,10 +47,10 @@ const ControlComponent = (props: ControlProps<OptionsType>) => {
return <Control {...rest}> {children}</Control>;
};
const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
export const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
const t = useTranslations('apps');
const { darkMode } = useUIStore();
const options: OptionsType[] = APP_CATEGORIES.map((category) => ({
const options: OptionsType[] = iconForCategory.map((category) => ({
value: category.id,
label: t(`app-details.categories.${category.id}`),
icon: category.icon,
@ -112,5 +112,3 @@ const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue
/>
);
};
export default CategorySelector;

View file

@ -0,0 +1 @@
export { CategorySelector } from './CategorySelector';

View file

@ -1,4 +1,22 @@
import { AppCategory, AppInfo } from '@runtipi/shared';
import {
Icon,
IconBook,
IconBrain,
IconBroadcast,
IconCamera,
IconCode,
IconDatabase,
IconDeviceGamepad2,
IconMovie,
IconMusic,
IconPigMoney,
IconRobot,
IconShieldLock,
IconStar,
IconTool,
IconUsers,
} from '@tabler/icons-react';
import { AppTableData } from './table.types';
type SortParams = {
@ -47,3 +65,26 @@ export const colorSchemeForCategory: Record<AppCategory, string> = {
gaming: 'pink',
ai: 'gray',
};
type AppCategoryEntry = {
id: AppCategory;
icon: Icon;
};
export const iconForCategory: AppCategoryEntry[] = [
{ id: 'network', icon: IconBroadcast },
{ id: 'media', icon: IconMovie },
{ id: 'development', icon: IconCode },
{ id: 'automation', icon: IconRobot },
{ id: 'social', icon: IconUsers },
{ id: 'utilities', icon: IconTool },
{ id: 'photography', icon: IconCamera },
{ id: 'security', icon: IconShieldLock },
{ id: 'featured', icon: IconStar },
{ id: 'books', icon: IconBook },
{ id: 'data', icon: IconDatabase },
{ id: 'music', icon: IconMusic },
{ id: 'finance', icon: IconPigMoney },
{ id: 'gaming', icon: IconDeviceGamepad2 },
{ id: 'ai', icon: IconBrain },
];

View file

@ -0,0 +1,23 @@
import { AppServiceClass } from '@/server/services/apps/apps.service';
import React from 'react';
import { Metadata } from 'next';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { AppStoreTable } from './components/AppStoreTable';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('apps.app-store.title')} - Tipi`,
};
}
export default async function AppStorePage() {
const { apps } = await AppServiceClass.listApps();
return (
<div className="card px-3 pb-3">
<AppStoreTable data={apps} />
</div>
);
}

View file

@ -1,3 +1,5 @@
'use client';
import Link from 'next/link';
import React from 'react';
import { IconDownload } from '@tabler/icons-react';
@ -5,10 +7,10 @@ import { Tooltip } from 'react-tooltip';
import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import type { AppInfo } from '@runtipi/shared';
import { AppStatus } from '../AppStatus';
import { AppLogo } from '../AppLogo/AppLogo';
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
import { AppLogo } from '@/components/AppLogo';
import { AppStatus } from '@/components/AppStatus';
import styles from './AppTile.module.scss';
import { limitText } from '../../app-store/helpers/table.helpers';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;

View file

@ -0,0 +1,37 @@
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { db } from '@/server/db';
import React from 'react';
import { Metadata } from 'next';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { AppTile } from './components/AppTile';
import { EmptyPage } from '../../components/EmptyPage';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('apps.my-apps.title')} - Tipi`,
};
}
export default async function Page() {
const appsService = new AppServiceClass(db);
const installedApps = await appsService.installedApps();
const renderApp = (app: (typeof installedApps)[number]) => {
const updateAvailable = Number(app.version) < Number(app.latestVersion);
if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
return null;
};
return (
<>
{installedApps.length === 0 && <EmptyPage title="apps.my-apps.empty-title" subtitle="apps.my-apps.empty-subtitle" redirectPath="/app-store" actionLabel="apps.my-apps.empty-action" />}
<div className="row row-cards " data-testid="apps-list">
{installedApps?.map(renderApp)}
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show more