Преглед на файлове

Merge pull request #763 from runtipi/release/2.0.6

Release/2.0.6
Nicolas Meienberger преди 1 година
родител
ревизия
27f5ccda82
променени са 100 файла, в които са добавени 1483 реда и са изтрити 604 реда
  1. 12 1
      .all-contributorsrc
  2. 1 1
      .devcontainer/postCreateCommand.sh
  3. 4 4
      .env.example
  4. 1 4
      .eslintrc.js
  5. 5 6
      .github/ISSUE_TEMPLATE/bug_report.md
  6. 1 1
      .github/workflows/e2e.yml
  7. 1 1
      .github/workflows/release.yml
  8. 1 0
      Dockerfile
  9. 1 0
      Dockerfile.dev
  10. 12 10
      README.md
  11. 5 6
      docker-compose.dev.yml
  12. 110 0
      docker-compose.prod.yml
  13. 1 15
      e2e/0004-user-settings.spec.ts
  14. 1 0
      next-env.d.ts
  15. 12 0
      next.config.mjs
  16. 85 93
      package.json
  17. 5 0
      packages/cli/.env.test
  18. 21 20
      packages/cli/package.json
  19. 1 1
      packages/cli/src/executors/app/app.executors.ts
  20. 25 13
      packages/cli/src/executors/system/system.executors.ts
  21. 6 1
      packages/cli/src/executors/system/system.helpers.ts
  22. 2 2
      packages/cli/src/services/watcher/watcher.ts
  23. 1 4
      packages/cli/src/utils/docker-helpers/docker-helpers.ts
  24. 1 1
      packages/cli/src/utils/logger/terminal-spinner.ts
  25. 4 0
      packages/shared/src/utils/logger/Logger.ts
  26. 0 0
      patches/.gitkeep
  27. 113 0
      patches/next-safe-action@3.4.0.patch
  28. 384 340
      pnpm-lock.yaml
  29. BIN
      public/android-chrome-192x192.png
  30. BIN
      public/android-chrome-512x512.png
  31. 0 9
      public/browserconfig.xml
  32. BIN
      public/favicon-16x16.png
  33. 1 1
      public/mockServiceWorker.js
  34. BIN
      public/mstile-150x150.png
  35. 0 1
      public/safari-pinned-tab.svg
  36. 0 19
      public/site.webmanifest
  37. 2 2
      scripts/install.sh
  38. 33 0
      src/app/(auth)/layout.tsx
  39. 43 0
      src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx
  40. 0 0
      src/app/(auth)/login/components/LoginContainer/index.ts
  41. 2 2
      src/app/(auth)/login/components/LoginForm/LoginForm.tsx
  42. 0 0
      src/app/(auth)/login/components/LoginForm/index.ts
  43. 0 0
      src/app/(auth)/login/components/TotpForm/TotpForm.tsx
  44. 0 0
      src/app/(auth)/login/components/TotpForm/index.ts
  45. 23 0
      src/app/(auth)/login/page.tsx
  46. 24 0
      src/app/(auth)/register/components/RegisterContainer/RegisterContainer.tsx
  47. 0 0
      src/app/(auth)/register/components/RegisterContainer/index.ts
  48. 2 2
      src/app/(auth)/register/components/RegisterForm/RegisterForm.tsx
  49. 0 0
      src/app/(auth)/register/components/RegisterForm/index.ts
  50. 22 0
      src/app/(auth)/register/page.tsx
  51. 52 0
      src/app/(auth)/reset-password/components/ResetPasswordContainer/ResetPasswordContainer.tsx
  52. 0 0
      src/app/(auth)/reset-password/components/ResetPasswordContainer/index.ts
  53. 2 2
      src/app/(auth)/reset-password/components/ResetPasswordForm/ResetPasswordForm.tsx
  54. 0 0
      src/app/(auth)/reset-password/components/ResetPasswordForm/index.ts
  55. 23 0
      src/app/(auth)/reset-password/page.tsx
  56. 7 7
      src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.test.tsx
  57. 6 4
      src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.tsx
  58. 0 0
      src/app/(dashboard)/app-store/[id]/components/AppActions/index.ts
  59. 219 0
      src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx
  60. 0 0
      src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts
  61. 2 2
      src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/AppDetailsTabs.tsx
  62. 1 0
      src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/index.ts
  63. 1 1
      src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.test.tsx
  64. 3 3
      src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx
  65. 1 0
      src/app/(dashboard)/app-store/[id]/components/InstallForm/index.ts
  66. 1 1
      src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.test.tsx
  67. 1 2
      src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.tsx
  68. 0 0
      src/app/(dashboard)/app-store/[id]/components/InstallModal/index.ts
  69. 1 1
      src/app/(dashboard)/app-store/[id]/components/StopModal/StopModal.tsx
  70. 1 0
      src/app/(dashboard)/app-store/[id]/components/StopModal/index.ts
  71. 1 1
      src/app/(dashboard)/app-store/[id]/components/UninstallModal/UninstallModal.tsx
  72. 1 0
      src/app/(dashboard)/app-store/[id]/components/UninstallModal/index.ts
  73. 1 1
      src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.test.tsx
  74. 1 1
      src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.tsx
  75. 0 0
      src/app/(dashboard)/app-store/[id]/components/UpdateModal/index.ts
  76. 1 2
      src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx
  77. 0 0
      src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/index.ts
  78. 23 0
      src/app/(dashboard)/app-store/[id]/page.tsx
  79. 0 0
      src/app/(dashboard)/app-store/[id]/utils/validators/index.ts
  80. 0 0
      src/app/(dashboard)/app-store/[id]/utils/validators/validators.test.tsx
  81. 0 0
      src/app/(dashboard)/app-store/[id]/utils/validators/validators.ts
  82. 25 0
      src/app/(dashboard)/app-store/components/AppStoreTable/AppStoreTable.tsx
  83. 1 0
      src/app/(dashboard)/app-store/components/AppStoreTable/index.ts
  84. 0 0
      src/app/(dashboard)/app-store/components/AppStoreTableActions/AppStoreTableActions.module.scss
  85. 21 0
      src/app/(dashboard)/app-store/components/AppStoreTableActions/AppStoreTableActions.tsx
  86. 0 0
      src/app/(dashboard)/app-store/components/AppStoreTile/AppStoreTile.module.scss
  87. 5 5
      src/app/(dashboard)/app-store/components/AppStoreTile/AppStoreTile.tsx
  88. 1 0
      src/app/(dashboard)/app-store/components/AppStoreTile/index.ts
  89. 1 1
      src/app/(dashboard)/app-store/components/CategorySelector/CategorySelecte.test.tsx
  90. 5 7
      src/app/(dashboard)/app-store/components/CategorySelector/CategorySelector.tsx
  91. 1 0
      src/app/(dashboard)/app-store/components/CategorySelector/index.ts
  92. 0 0
      src/app/(dashboard)/app-store/helpers/__tests__/table.helpers.test.ts
  93. 41 0
      src/app/(dashboard)/app-store/helpers/table.helpers.ts
  94. 0 0
      src/app/(dashboard)/app-store/helpers/table.types.ts
  95. 23 0
      src/app/(dashboard)/app-store/page.tsx
  96. 0 0
      src/app/(dashboard)/app-store/state/appStoreState.ts
  97. 0 0
      src/app/(dashboard)/apps/components/AppTile.module.scss
  98. 5 3
      src/app/(dashboard)/apps/components/AppTile.tsx
  99. 0 0
      src/app/(dashboard)/apps/components/index.tsx
  100. 37 0
      src/app/(dashboard)/apps/page.tsx

+ 12 - 1
.all-contributorsrc

@@ -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,

+ 1 - 1
.devcontainer/postCreateCommand.sh

@@ -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

+ 4 - 4
.env.example

@@ -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

+ 1 - 4
.eslintrc.js

@@ -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',

+ 5 - 6
.github/ISSUE_TEMPLATE/bug_report.md

@@ -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 ...`
 

+ 1 - 1
.github/workflows/e2e.yml

@@ -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 }}

+ 1 - 1
.github/workflows/release.yml

@@ -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:

+ 1 - 0
Dockerfile

@@ -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 ./

+ 1 - 0
Dockerfile.dev

@@ -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 ./

+ 12 - 10
README.md

@@ -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>

+ 5 - 6
docker-compose.dev.yml

@@ -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 - 0
docker-compose.prod.yml

@@ -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:

+ 1 - 15
e2e/0004-user-settings.spec.ts

@@ -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 - 0
next-env.d.ts

@@ -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.

+ 12 - 0
next.config.mjs

@@ -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;

+ 85 - 93
package.json

@@ -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"
+    }
+  }
 }

+ 5 - 0
packages/cli/.env.test

@@ -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

+ 21 - 20
packages/cli/package.json

@@ -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"
   }
 }

+ 1 - 1
packages/cli/src/executors/app/app.executors.ts

@@ -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`);
 

+ 25 - 13
packages/cli/src/executors/system/system.executors.ts

@@ -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}`);

+ 6 - 1
packages/cli/src/executors/system/system.helpers.ts

@@ -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'));

+ 2 - 2
packages/cli/src/services/watcher/watcher.ts

@@ -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}`);
   });
 };

+ 1 - 4
packages/cli/src/utils/docker-helpers/docker-helpers.ts

@@ -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 };
 };
 

+ 1 - 1
packages/cli/src/utils/logger/terminal-spinner.ts

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

+ 4 - 0
packages/shared/src/utils/logger/Logger.ts

@@ -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 - 0
patches/.gitkeep


+ 113 - 0
patches/next-safe-action@3.4.0.patch

@@ -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

Файловите разлики са ограничени, защото са твърде много
+ 384 - 340
pnpm-lock.yaml


BIN
public/android-chrome-192x192.png


BIN
public/android-chrome-512x512.png


+ 0 - 9
public/browserconfig.xml

@@ -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>

BIN
public/favicon-16x16.png


+ 1 - 1
public/mockServiceWorker.js

@@ -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.

BIN
public/mstile-150x150.png


+ 0 - 1
public/safari-pinned-tab.svg

@@ -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>

+ 0 - 19
public/site.webmanifest

@@ -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"
-}

+ 2 - 2
scripts/install.sh

@@ -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 - 0
src/app/(auth)/layout.tsx

@@ -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>
+  );
+}

+ 43 - 0
src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx

@@ -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 })} />;
+}

+ 0 - 0
src/client/modules/Auth/containers/LoginContainer/index.ts → src/app/(auth)/login/components/LoginContainer/index.ts


+ 2 - 2
src/client/modules/Auth/components/LoginForm/LoginForm.tsx → src/app/(auth)/login/components/LoginForm/LoginForm.tsx

@@ -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 };
 

+ 0 - 0
src/client/modules/Auth/components/LoginForm/index.ts → src/app/(auth)/login/components/LoginForm/index.ts


+ 0 - 0
src/client/modules/Auth/components/TotpForm/TotpForm.tsx → src/app/(auth)/login/components/TotpForm/TotpForm.tsx


+ 0 - 0
src/client/modules/Auth/components/TotpForm/index.ts → src/app/(auth)/login/components/TotpForm/index.ts


+ 23 - 0
src/app/(auth)/login/page.tsx

@@ -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 />;
+}

+ 24 - 0
src/app/(auth)/register/components/RegisterContainer/RegisterContainer.tsx

@@ -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} />;
+};

+ 0 - 0
src/client/modules/Auth/containers/RegisterContainer/index.ts → src/app/(auth)/register/components/RegisterContainer/index.ts


+ 2 - 2
src/client/modules/Auth/components/RegisterForm/RegisterForm.tsx → src/app/(auth)/register/components/RegisterForm/RegisterForm.tsx

@@ -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;

+ 0 - 0
src/client/modules/Auth/components/RegisterForm/index.ts → src/app/(auth)/register/components/RegisterForm/index.ts


+ 22 - 0
src/app/(auth)/register/page.tsx

@@ -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 />;
+}

+ 52 - 0
src/app/(auth)/reset-password/components/ResetPasswordContainer/ResetPasswordContainer.tsx

@@ -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 })}
+    />
+  );
+};

+ 0 - 0
src/client/modules/Auth/containers/ResetPasswordContainer/index.ts → src/app/(auth)/reset-password/components/ResetPasswordContainer/index.ts


+ 2 - 2
src/client/modules/Auth/components/ResetPasswordForm/ResetPasswordForm.tsx → src/app/(auth)/reset-password/components/ResetPasswordForm/ResetPasswordForm.tsx

@@ -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;

+ 0 - 0
src/client/modules/Auth/components/ResetPasswordForm/index.ts → src/app/(auth)/reset-password/components/ResetPasswordForm/index.ts


+ 23 - 0
src/app/(auth)/reset-password/page.tsx

@@ -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>
+    </>
+  );
+}

+ 7 - 7
src/client/modules/Apps/components/AppActions/AppActions.test.tsx → src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.test.tsx

@@ -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');
     });

+ 6 - 4
src/client/modules/Apps/components/AppActions/AppActions.tsx → src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.tsx

@@ -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>

+ 0 - 0
src/client/modules/Apps/components/AppActions/index.ts → src/app/(dashboard)/app-store/[id]/components/AppActions/index.ts


+ 219 - 0
src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx

@@ -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>
+  );
+};

+ 0 - 0
src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts


+ 2 - 2
src/client/modules/Apps/components/AppDetailsTabs.tsx → src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/AppDetailsTabs.tsx

@@ -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;

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/index.ts

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

+ 1 - 1
src/client/modules/Apps/components/InstallForm/InstallForm.test.tsx → src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.test.tsx

@@ -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', () => {

+ 3 - 3
src/client/modules/Apps/components/InstallForm/InstallForm.tsx → src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx

@@ -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 {

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/InstallForm/index.ts

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

+ 1 - 1
src/client/modules/Apps/components/InstallModal/InstallModal.test.tsx → src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.test.tsx

@@ -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 = {

+ 1 - 2
src/client/modules/Apps/components/InstallModal/InstallModal.tsx → src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.tsx

@@ -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;

+ 0 - 0
src/client/modules/Apps/components/InstallModal/index.ts → src/app/(dashboard)/app-store/[id]/components/InstallModal/index.ts


+ 1 - 1
src/client/modules/Apps/components/StopModal.tsx → src/app/(dashboard)/app-store/[id]/components/StopModal/StopModal.tsx

@@ -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;

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/StopModal/index.ts

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

+ 1 - 1
src/client/modules/Apps/components/UninstallModal.tsx → src/app/(dashboard)/app-store/[id]/components/UninstallModal/UninstallModal.tsx

@@ -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;

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/UninstallModal/index.ts

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

+ 1 - 1
src/client/modules/Apps/components/UpdateModal/UpdateModal.test.tsx → src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.test.tsx

@@ -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', () => {

+ 1 - 1
src/client/modules/Apps/components/UpdateModal/UpdateModal.tsx → src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.tsx

@@ -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;

+ 0 - 0
src/client/modules/Apps/components/UpdateModal/index.ts → src/app/(dashboard)/app-store/[id]/components/UpdateModal/index.ts


+ 1 - 2
src/client/modules/Apps/components/UpdateSettingsModal.tsx → src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx

@@ -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;

+ 0 - 0
src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/index.ts


+ 23 - 0
src/app/(dashboard)/app-store/[id]/page.tsx

@@ -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} />;
+}

+ 0 - 0
src/client/modules/Apps/utils/validators/index.ts → src/app/(dashboard)/app-store/[id]/utils/validators/index.ts


+ 0 - 0
src/client/modules/Apps/utils/validators/validators.test.tsx → src/app/(dashboard)/app-store/[id]/utils/validators/validators.test.tsx


+ 0 - 0
src/client/modules/Apps/utils/validators/validators.ts → src/app/(dashboard)/app-store/[id]/utils/validators/validators.ts


+ 25 - 0
src/app/(dashboard)/app-store/components/AppStoreTable/AppStoreTable.tsx

@@ -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>
+  );
+};

+ 1 - 0
src/app/(dashboard)/app-store/components/AppStoreTable/index.ts

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

+ 0 - 0
src/client/modules/AppStore/pages/AppStorePage/AppStorePage.module.scss → src/app/(dashboard)/app-store/components/AppStoreTableActions/AppStoreTableActions.module.scss


+ 21 - 0
src/app/(dashboard)/app-store/components/AppStoreTableActions/AppStoreTableActions.tsx

@@ -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>
+  );
+};

+ 0 - 0
src/client/modules/AppStore/components/AppStoreTile/AppStoreTile.module.scss → src/app/(dashboard)/app-store/components/AppStoreTile/AppStoreTile.module.scss


+ 5 - 5
src/client/modules/AppStore/components/AppStoreTile/AppStoreTile.tsx → src/app/(dashboard)/app-store/components/AppStoreTile/AppStoreTile.tsx

@@ -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;

+ 1 - 0
src/app/(dashboard)/app-store/components/AppStoreTile/index.ts

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

+ 1 - 1
src/client/modules/AppStore/components/CategorySelector/CategorySelecte.test.tsx → src/app/(dashboard)/app-store/components/CategorySelector/CategorySelecte.test.tsx

@@ -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', () => {

+ 5 - 7
src/client/modules/AppStore/components/CategorySelector/CategorySelector.tsx → src/app/(dashboard)/app-store/components/CategorySelector/CategorySelector.tsx

@@ -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;

+ 1 - 0
src/app/(dashboard)/app-store/components/CategorySelector/index.ts

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

+ 0 - 0
src/client/modules/AppStore/helpers/__tests__/table.helpers.test.ts → src/app/(dashboard)/app-store/helpers/__tests__/table.helpers.test.ts


+ 41 - 0
src/client/modules/AppStore/helpers/table.helpers.ts → src/app/(dashboard)/app-store/helpers/table.helpers.ts

@@ -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 },
+];

+ 0 - 0
src/client/modules/AppStore/helpers/table.types.ts → src/app/(dashboard)/app-store/helpers/table.types.ts


+ 23 - 0
src/app/(dashboard)/app-store/page.tsx

@@ -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>
+  );
+}

+ 0 - 0
src/client/modules/AppStore/state/appStoreState.ts → src/app/(dashboard)/app-store/state/appStoreState.ts


+ 0 - 0
src/client/components/AppTile/AppTile.module.scss → src/app/(dashboard)/apps/components/AppTile.module.scss


+ 5 - 3
src/client/components/AppTile/AppTile.tsx → src/app/(dashboard)/apps/components/AppTile.tsx

@@ -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'>;
 

+ 0 - 0
src/client/components/AppTile/index.tsx → src/app/(dashboard)/apps/components/index.tsx


+ 37 - 0
src/app/(dashboard)/apps/page.tsx

@@ -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>
+    </>
+  );
+}

Някои файлове не бяха показани, защото твърде много файлове са промени