commit
df59d21ce7
77 changed files with 1915 additions and 1154 deletions
17
.github/workflows/alpha-release.yml
vendored
17
.github/workflows/alpha-release.yml
vendored
|
@ -40,11 +40,12 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
|
@ -52,9 +53,9 @@ jobs:
|
|||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: meienberger/runtipi:${{ needs.create-tag.outputs.tagname }}
|
||||
cache-from: type=registry,ref=meienberger/runtipi:buildcache
|
||||
cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max
|
||||
tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.create-tag.outputs.tagname }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max
|
||||
|
||||
build-cli:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -65,7 +66,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
|
19
.github/workflows/beta-release.yml
vendored
19
.github/workflows/beta-release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
@ -40,11 +40,12 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
|
@ -52,9 +53,9 @@ jobs:
|
|||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: meienberger/runtipi:${{ needs.get-tag.outputs.tag }}
|
||||
cache-from: type=registry,ref=meienberger/runtipi:buildcache
|
||||
cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max
|
||||
tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.get-tag.outputs.tag }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max
|
||||
|
||||
build-cli:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -64,7 +65,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
|
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
@ -41,7 +41,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
@ -76,7 +76,7 @@ jobs:
|
|||
|
||||
- name: Get number of CPU cores
|
||||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@v1
|
||||
uses: SimenB/github-actions-cpu-cores@v2
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test --max-workers ${{ steps.cpu-cores.outputs.count }}
|
||||
|
@ -104,7 +104,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
|
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
|
@ -132,7 +132,7 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
|
41
.github/workflows/release-candidate.yml
vendored
41
.github/workflows/release-candidate.yml
vendored
|
@ -1,41 +0,0 @@
|
|||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Build images and publish RCs to DockerHub
|
||||
build-images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get tag from VERSION file
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(npm run version --silent)
|
||||
TAG=${VERSION}
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: meienberger/runtipi:rc-${{ steps.meta.outputs.TAG }}
|
||||
cache-from: type=registry,ref=meienberger/runtipi:buildcache
|
||||
cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max
|
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
|
@ -12,7 +12,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
@ -36,11 +36,12 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
|
@ -48,9 +49,9 @@ jobs:
|
|||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: meienberger/runtipi:latest,meienberger/runtipi:${{ needs.get-tag.outputs.tag }}
|
||||
cache-from: type=registry,ref=meienberger/runtipi:buildcache
|
||||
cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max
|
||||
tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.get-tag.outputs.tag }},ghcr.io/${{ github.repository_owner }}/runtipi:latest
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max
|
||||
|
||||
build-cli:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -61,7 +62,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
|
|
@ -14,6 +14,8 @@
|
|||
![Build](https://github.com/runtipi/runtipi/workflows/Tipi%20CI/badge.svg)
|
||||
[![Crowdin](https://badges.crowdin.net/runtipi/localized.svg)](https://crowdin.com/project/runtipi)
|
||||
|
||||
> 💡 Tipi is built with TypeScript, Next.js app router and Drizzle ORM! If you want to collaborate on a cool project, join the discussion on Discord!
|
||||
|
||||
#### Join the discussion
|
||||
|
||||
[![Discord](https://img.shields.io/discord/976934649643294750?label=discord&logo=discord)](https://discord.gg/Bu9qEPnHsc)
|
||||
|
|
|
@ -2,8 +2,10 @@ import { test, expect } from '@playwright/test';
|
|||
import { loginUser } from './fixtures/fixtures';
|
||||
import { clearDatabase } from './helpers/db';
|
||||
import { testUser } from './helpers/constants';
|
||||
import { setSettings } from './helpers/settings';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setSettings({});
|
||||
await clearDatabase();
|
||||
await loginUser(page);
|
||||
|
||||
|
@ -31,3 +33,49 @@ test('user can change their password', async ({ page }) => {
|
|||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can change their email', async ({ page }) => {
|
||||
// Change email
|
||||
const newEmail = 'tester2@test.com';
|
||||
|
||||
await page.getByRole('tab', { name: 'Security' }).click();
|
||||
await page.getByRole('button', { name: 'Change username' }).click();
|
||||
await page.getByPlaceholder('New username').click();
|
||||
await page.getByPlaceholder('New username').fill(newEmail);
|
||||
|
||||
// Wrong password
|
||||
await page.getByPlaceholder('Password', { exact: true }).click();
|
||||
await page.getByPlaceholder('Password', { exact: true }).fill('incorrect');
|
||||
|
||||
await page.getByRole('button', { name: 'Change username' }).click();
|
||||
|
||||
await expect(page.getByText('Invalid password')).toBeVisible();
|
||||
|
||||
// Wrong email
|
||||
await page.getByPlaceholder('Password', { exact: true }).click();
|
||||
await page.getByPlaceholder('Password', { exact: true }).fill(testUser.password);
|
||||
await page.getByPlaceholder('New username').click();
|
||||
await page.getByPlaceholder('New username').fill('incorrect');
|
||||
|
||||
await page.getByRole('button', { name: 'Change username' }).click();
|
||||
|
||||
await expect(page.getByText('Must be a valid email address')).toBeVisible();
|
||||
|
||||
// Correct email and password
|
||||
await page.getByPlaceholder('New username').click();
|
||||
await page.getByPlaceholder('New username').fill(newEmail);
|
||||
|
||||
await page.getByRole('button', { name: 'Change username' }).click();
|
||||
|
||||
await expect(page.getByText('Username changed successfully')).toBeVisible();
|
||||
|
||||
// Login with new email
|
||||
await page.getByPlaceholder('you@example.com').click();
|
||||
await page.getByPlaceholder('you@example.com').fill(newEmail);
|
||||
await page.getByPlaceholder('Your password').click();
|
||||
await page.getByPlaceholder('Your password').fill(testUser.password);
|
||||
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
});
|
||||
|
|
58
e2e/0005-guest-dashboard.spec.ts
Normal file
58
e2e/0005-guest-dashboard.spec.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { appTable } from '@/server/db/schema';
|
||||
import { setSettings } from './helpers/settings';
|
||||
import { loginUser } from './fixtures/fixtures';
|
||||
import { clearDatabase, db } from './helpers/db';
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await clearDatabase();
|
||||
await setSettings({});
|
||||
});
|
||||
|
||||
test('user can activate the guest dashboard and see it when logged out', async ({ page }) => {
|
||||
await loginUser(page);
|
||||
await page.goto('/settings');
|
||||
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await page.getByLabel('guestDashboard').setChecked(true);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByTestId('logout-button').click();
|
||||
|
||||
await expect(page.getByText('No apps to display')).toBeVisible();
|
||||
});
|
||||
|
||||
test('logged out users can see the apps on the guest dashboard', async ({ browser }) => {
|
||||
await setSettings({ guestDashboard: true });
|
||||
await db.insert(appTable).values({ config: {}, isVisibleOnGuestDashboard: true, id: 'hello-world', exposed: true, domain: 'duckduckgo.com', status: 'running' });
|
||||
await db.insert(appTable).values({ config: {}, isVisibleOnGuestDashboard: false, id: 'actual-budget', exposed: false, status: 'running' });
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await expect(page.getByText(/Hello World web server/)).toBeVisible();
|
||||
const locator = page.locator('text=Actual Budget');
|
||||
expect(locator).not.toBeVisible();
|
||||
|
||||
const [newPage] = await Promise.all([context.waitForEvent('page'), await page.getByRole('link', { name: /Hello World/ }).click()]);
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
expect(newPage.url()).toBe('https://duckduckgo.com/');
|
||||
await newPage.close();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('user can deactivate the guest dashboard and not see it when logged out', async ({ page }) => {
|
||||
await loginUser(page);
|
||||
await page.goto('/settings');
|
||||
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await page.getByLabel('guestDashboard').setChecked(false);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByTestId('logout-button').click();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// We should be redirected to the login page
|
||||
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
|
||||
});
|
|
@ -1,10 +1,12 @@
|
|||
import { clearDatabase } from './db';
|
||||
import { setSettings } from './settings';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function globalSetup() {
|
||||
await clearDatabase();
|
||||
await setSettings({});
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
|
|
8
e2e/helpers/settings.ts
Normal file
8
e2e/helpers/settings.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { promises } from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { settingsSchema } from '@runtipi/shared';
|
||||
|
||||
export const setSettings = async (settings: z.infer<typeof settingsSchema>) => {
|
||||
await promises.writeFile(path.join(__dirname, '../../state/settings.json'), JSON.stringify(settings));
|
||||
};
|
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
|
@ -1,6 +1,5 @@
|
|||
/// <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.
|
||||
|
|
|
@ -6,7 +6,6 @@ const nextConfig = {
|
|||
transpilePackages: ['@runtipi/shared'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['bullmq'],
|
||||
serverActions: true,
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
INTERNAL_IP: process.env.INTERNAL_IP,
|
||||
|
|
73
package.json
73
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "2.0.7",
|
||||
"version": "2.1.0",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"knip": "knip",
|
||||
|
@ -38,39 +38,40 @@
|
|||
"@otplib/plugin-thirty-two": "^12.0.1",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@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-beta20",
|
||||
"@tabler/icons-react": "^2.39.0",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
"argon2": "^0.31.1",
|
||||
"bullmq": "^4.12.3",
|
||||
"bullmq": "^4.13.0",
|
||||
"clsx": "^2.0.0",
|
||||
"connect-redis": "^7.1.0",
|
||||
"drizzle-orm": "^0.28.6",
|
||||
"fs-extra": "^11.1.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"next": "13.5.5",
|
||||
"next-client-cookies": "^1.0.5",
|
||||
"next-intl": "^2.20.2",
|
||||
"next-safe-action": "^4.0.1",
|
||||
"next": "14.0.1",
|
||||
"next-client-cookies": "^1.0.6",
|
||||
"next-intl": "^2.22.1",
|
||||
"next-safe-action": "^5.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-select": "^5.7.7",
|
||||
"react-tooltip": "^5.21.5",
|
||||
"react-select": "^5.8.0",
|
||||
"react-tooltip": "^5.22.0",
|
||||
"redaxios": "^0.5.1",
|
||||
"redis": "^4.6.10",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.69.4",
|
||||
"sass": "^1.69.5",
|
||||
"semver": "^7.5.4",
|
||||
"sharp": "0.32.6",
|
||||
"swr": "^2.2.4",
|
||||
|
@ -79,56 +80,56 @@
|
|||
"validator": "^13.11.0",
|
||||
"winston": "^3.11.0",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.3"
|
||||
"zustand": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.0",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@playwright/test": "^1.38.1",
|
||||
"@babel/core": "^7.23.2",
|
||||
"@faker-js/faker": "^8.2.0",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"@testing-library/dom": "^9.3.3",
|
||||
"@testing-library/jest-dom": "^6.1.3",
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@total-typescript/shoehorn": "^0.1.1",
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/fs-extra": "^11.0.2",
|
||||
"@types/jest": "^29.5.6",
|
||||
"@types/lodash.merge": "^4.6.7",
|
||||
"@types/node": "20.8.4",
|
||||
"@types/pg": "^8.10.5",
|
||||
"@types/react": "18.2.31",
|
||||
"@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.8.0",
|
||||
"@typescript-eslint/parser": "^6.8.0",
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"@types/fs-extra": "^11.0.3",
|
||||
"@types/jest": "^29.5.7",
|
||||
"@types/lodash.merge": "^4.6.8",
|
||||
"@types/node": "20.8.10",
|
||||
"@types/pg": "^8.10.7",
|
||||
"@types/react": "18.2.34",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@types/semver": "^7.5.4",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"@types/validator": "^13.11.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.9.1",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint": "8.52.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.1.0",
|
||||
"eslint-config-next": "13.5.4",
|
||||
"eslint-config-next": "14.0.1",
|
||||
"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-import": "^2.29.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-jest-dom": "^5.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-testing-library": "^6.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"knip": "^2.33.1",
|
||||
"knip": "^2.39.0",
|
||||
"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.13.0",
|
||||
"tsx": "^3.14.0",
|
||||
"typescript": "5.2.2",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^0.34.6",
|
||||
|
|
|
@ -55,7 +55,7 @@ services:
|
|||
- tipi_main_network
|
||||
|
||||
tipi-dashboard:
|
||||
image: meienberger/runtipi:${TIPI_VERSION}
|
||||
image: ghcr.io/runtipi/runtipi:${TIPI_VERSION}
|
||||
restart: on-failure
|
||||
container_name: tipi-dashboard
|
||||
networks:
|
||||
|
|
15
packages/cli/assets/migrations/00009-add-guest-dashboard.sql
Normal file
15
packages/cli/assets/migrations/00009-add-guest-dashboard.sql
Normal file
|
@ -0,0 +1,15 @@
|
|||
-- Update app table to add "is_visible_on_guest_dashboard" column
|
||||
ALTER TABLE "app"
|
||||
ADD COLUMN IF NOT EXISTS "is_visible_on_guest_dashboard" boolean DEFAULT FALSE;
|
||||
|
||||
-- Set default value to false
|
||||
UPDATE
|
||||
"app"
|
||||
SET
|
||||
"is_visible_on_guest_dashboard" = FALSE
|
||||
WHERE
|
||||
"is_visible_on_guest_dashboard" IS NULL;
|
||||
|
||||
-- Set is_visible_on_guest_dashboard column to not null constraint
|
||||
ALTER TABLE "app"
|
||||
ALTER COLUMN "is_visible_on_guest_dashboard" SET NOT NULL;
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@runtipi/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.1.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": "dist/index.js",
|
||||
|
@ -28,36 +28,36 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.3",
|
||||
"@types/node": "20.8.4",
|
||||
"@types/web-push": "^3.6.1",
|
||||
"@faker-js/faker": "^8.2.0",
|
||||
"@types/cli-progress": "^3.11.4",
|
||||
"@types/node": "20.8.10",
|
||||
"@types/web-push": "^3.6.2",
|
||||
"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.11",
|
||||
"vite": "^4.5.0",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@runtipi/postgres-migrations": "^5.3.0",
|
||||
"@runtipi/shared": "workspace:^",
|
||||
"axios": "^1.5.1",
|
||||
"axios": "^1.6.0",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "^4.12.3",
|
||||
"bullmq": "^4.13.0",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"cli-spinners": "^2.9.1",
|
||||
"commander": "^11.0.0",
|
||||
"commander": "^11.1.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"log-update": "^5.0.1",
|
||||
"pg": "^8.11.3",
|
||||
"semver": "^7.5.4",
|
||||
"systeminformation": "^5.21.11",
|
||||
"systeminformation": "^5.21.15",
|
||||
"web-push": "^3.6.6",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
|
|
|
@ -149,7 +149,10 @@ export class AppExecutors {
|
|||
* @param {Record<string, unknown>} config - The config of the app
|
||||
*/
|
||||
public stopApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
|
||||
const spinner = new TerminalSpinner(`Stopping app ${appId}`);
|
||||
|
||||
try {
|
||||
spinner.start();
|
||||
this.logger.info(`Stopping app ${appId}`);
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
|
@ -161,14 +164,18 @@ export class AppExecutors {
|
|||
await compose(appId, 'rm --force --stop');
|
||||
|
||||
this.logger.info(`App ${appId} stopped`);
|
||||
spinner.done(`App ${appId} stopped`);
|
||||
return { success: true, message: `App ${appId} stopped successfully` };
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to stop app ${appId} see logs for more details (logs/error.log)`);
|
||||
return this.handleAppError(err);
|
||||
}
|
||||
};
|
||||
|
||||
public startApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
const spinner = new TerminalSpinner(`Starting app ${appId}`);
|
||||
try {
|
||||
spinner.start();
|
||||
const { appDataDirPath } = this.getAppPaths(appId);
|
||||
|
||||
this.logger.info(`Starting app ${appId}`);
|
||||
|
@ -176,6 +183,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 --pull always');
|
||||
|
||||
this.logger.info(`App ${appId} started`);
|
||||
|
@ -185,8 +193,10 @@ export class AppExecutors {
|
|||
this.logger.error(`Error setting permissions for app ${appId}`);
|
||||
});
|
||||
|
||||
spinner.done(`App ${appId} started`);
|
||||
return { success: true, message: `App ${appId} started successfully` };
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to start app ${appId} see logs for more details (logs/error.log)`);
|
||||
return this.handleAppError(err);
|
||||
}
|
||||
};
|
||||
|
@ -269,8 +279,6 @@ export class AppExecutors {
|
|||
|
||||
// Start all apps
|
||||
for (const row of rows) {
|
||||
spinner.setMessage(`Starting app ${row.id}`);
|
||||
spinner.start();
|
||||
const { id, config } = row;
|
||||
|
||||
const { success } = await this.startApp(id, config);
|
||||
|
@ -278,10 +286,8 @@ export class AppExecutors {
|
|||
if (!success) {
|
||||
this.logger.error(`Error starting app ${id}`);
|
||||
await client.query(`UPDATE app SET status = 'stopped' WHERE id = '${id}'`);
|
||||
spinner.fail(`Error starting app ${id}`);
|
||||
} else {
|
||||
await client.query(`UPDATE app SET status = 'running' WHERE id = '${id}'`);
|
||||
spinner.done(`App ${id} started`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
@ -104,11 +104,7 @@ export class SystemExecutors {
|
|||
|
||||
public cleanLogs = async () => {
|
||||
try {
|
||||
const { rootFolderHost } = getEnv();
|
||||
|
||||
await fs.promises.rm(path.join(rootFolderHost, 'logs'), { recursive: true, force: true });
|
||||
await fs.promises.mkdir(path.join(rootFolderHost, 'logs'));
|
||||
|
||||
await this.logger.flush();
|
||||
this.logger.info('Logs cleaned successfully');
|
||||
|
||||
return { success: true, message: '' };
|
||||
|
@ -174,6 +170,8 @@ export class SystemExecutors {
|
|||
public start = async (sudo = true, killWatchers = true) => {
|
||||
const spinner = new TerminalSpinner('Starting Tipi...');
|
||||
try {
|
||||
await this.logger.flush();
|
||||
|
||||
const { isSudo } = getUserIds();
|
||||
|
||||
if (!sudo) {
|
||||
|
@ -205,8 +203,8 @@ export class SystemExecutors {
|
|||
throw new Error('Tipi needs to run as root to start. Use sudo ./runtipi-cli start');
|
||||
}
|
||||
|
||||
spinner.start();
|
||||
spinner.setMessage('Copying system files...');
|
||||
spinner.start();
|
||||
|
||||
this.logger.info('Copying system files...');
|
||||
await copySystemFiles();
|
||||
|
@ -274,6 +272,7 @@ export class SystemExecutors {
|
|||
this.logger.info('Adding initial jobs to queue...');
|
||||
await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent);
|
||||
await queue.add(`${Math.random().toString()}_repo_clone`, { type: 'repo', command: 'clone', url: envMap.get('APPS_REPO_URL') } as SystemEvent);
|
||||
await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent);
|
||||
|
||||
// Scheduled jobs
|
||||
this.logger.info('Adding scheduled jobs to queue...');
|
||||
|
|
|
@ -32,6 +32,7 @@ type EnvKeys =
|
|||
| 'REDIS_PASSWORD'
|
||||
| 'LOCAL_DOMAIN'
|
||||
| 'DEMO_MODE'
|
||||
| 'GUEST_DASHBOARD'
|
||||
| 'TIPI_GID'
|
||||
| 'TIPI_UID'
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
|
@ -178,6 +179,7 @@ export const generateSystemEnvFile = async () => {
|
|||
envMap.set('REDIS_HOST', 'tipi-redis');
|
||||
envMap.set('REDIS_PASSWORD', redisPassword);
|
||||
envMap.set('DEMO_MODE', String(data.demoMode || 'false'));
|
||||
envMap.set('GUEST_DASHBOARD', String(data.guestDashboard || 'false'));
|
||||
envMap.set('LOCAL_DOMAIN', data.localDomain || 'tipi.lan');
|
||||
envMap.set('NODE_ENV', 'production');
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ import { AppExecutors, SystemExecutors } from './executors';
|
|||
const main = async () => {
|
||||
program.description(description).version(version);
|
||||
|
||||
program.name('./runtipi-cli').usage('<command> [options]');
|
||||
|
||||
program
|
||||
.command('watch')
|
||||
.description('Watcher script for events queue')
|
||||
|
@ -20,7 +22,7 @@ const main = async () => {
|
|||
program
|
||||
.command('start')
|
||||
.description('Start tipi')
|
||||
.option('--no-permissions', 'Skip permissions check')
|
||||
.addHelpText('after', '\nExample call: sudo ./runtipi-cli start')
|
||||
.option('--no-sudo', 'Skip sudo usage')
|
||||
.action(async (options) => {
|
||||
const systemExecutors = new SystemExecutors();
|
||||
|
@ -73,6 +75,7 @@ const main = async () => {
|
|||
// Stop app: ./cli app stop <app>
|
||||
program
|
||||
.command('app [command] <app>')
|
||||
.addHelpText('after', '\nExample call: sudo ./runtipi-cli app start <app>')
|
||||
.description('App management')
|
||||
.action(async (command, app) => {
|
||||
const appExecutors = new AppExecutors();
|
||||
|
|
|
@ -113,7 +113,7 @@ export const startWorker = async () => {
|
|||
});
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
fileLogger.info(`Job ${job.id} completed with result: ${JSON.stringify(job.returnvalue)}`);
|
||||
fileLogger.info(`Job ${job.id} completed with result:`, JSON.stringify(job.returnvalue));
|
||||
});
|
||||
|
||||
worker.on('failed', (job) => {
|
||||
|
|
|
@ -1,4 +1,58 @@
|
|||
import fs from 'fs';
|
||||
import { createLogger } from '@runtipi/shared';
|
||||
import path from 'path';
|
||||
|
||||
export const fileLogger = createLogger('cli', path.join(process.cwd(), 'logs'));
|
||||
function streamLogToHistory(logsFolder: string, logFile: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const appLogReadStream = fs.createReadStream(path.join(logsFolder, logFile), 'utf-8');
|
||||
const appLogHistoryWriteStream = fs.createWriteStream(path.join(logsFolder, `${logFile}.history`), { flags: 'a' });
|
||||
|
||||
appLogReadStream
|
||||
.pipe(appLogHistoryWriteStream)
|
||||
.on('finish', () => {
|
||||
fs.writeFileSync(path.join(logsFolder, logFile), '');
|
||||
resolve(true);
|
||||
})
|
||||
.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class FileLogger {
|
||||
private winstonLogger = createLogger('cli', path.join(process.cwd(), 'logs'));
|
||||
|
||||
private logsFolder = path.join(process.cwd(), 'logs');
|
||||
|
||||
public flush = async () => {
|
||||
try {
|
||||
if (fs.existsSync(path.join(this.logsFolder, 'app.log'))) {
|
||||
await streamLogToHistory(this.logsFolder, 'app.log');
|
||||
}
|
||||
if (fs.existsSync(path.join(this.logsFolder, 'error.log'))) {
|
||||
await streamLogToHistory(this.logsFolder, 'error.log');
|
||||
}
|
||||
this.winstonLogger.info('Logs flushed');
|
||||
} catch (error) {
|
||||
this.winstonLogger.error('Error flushing logs', error);
|
||||
}
|
||||
};
|
||||
|
||||
public error = (...message: unknown[]) => {
|
||||
this.winstonLogger.error(message.join(' '));
|
||||
};
|
||||
|
||||
public info = (...message: unknown[]) => {
|
||||
this.winstonLogger.info(message.join(' '));
|
||||
};
|
||||
|
||||
public warn = (...message: unknown[]) => {
|
||||
this.winstonLogger.warn(message.join(' '));
|
||||
};
|
||||
|
||||
public debug = (...message: unknown[]) => {
|
||||
this.winstonLogger.debug(message.join(' '));
|
||||
};
|
||||
}
|
||||
|
||||
export const fileLogger = new FileLogger();
|
||||
|
|
|
@ -43,6 +43,14 @@ export const envSchema = z.object({
|
|||
if (typeof value === 'boolean') return value;
|
||||
return value === 'true';
|
||||
}),
|
||||
guestDashboard: z
|
||||
.string()
|
||||
.or(z.boolean())
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return value === 'true';
|
||||
}),
|
||||
seePreReleaseVersions: z
|
||||
.string()
|
||||
.or(z.boolean())
|
||||
|
@ -55,5 +63,5 @@ export const envSchema = z.object({
|
|||
|
||||
export const settingsSchema = envSchema
|
||||
.partial()
|
||||
.pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true, demoMode: true })
|
||||
.pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true, demoMode: true, guestDashboard: true })
|
||||
.and(z.object({ port: z.number(), sslPort: z.number(), listenIp: z.string().ip().trim() }).partial());
|
||||
|
|
|
@ -34,7 +34,7 @@ export const newLogger = (id: string, logsFolder: string) => {
|
|||
);
|
||||
exceptionHandlers = [new transports.File({ filename: path.join(logsFolder, 'error.log') })];
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
tr.push(new transports.Console({ level: 'debug' }));
|
||||
}
|
||||
|
||||
|
@ -45,7 +45,7 @@ export const newLogger = (id: string, logsFolder: string) => {
|
|||
colorize(),
|
||||
timestamp(),
|
||||
align(),
|
||||
printf((info) => `${info.timestamp} - ${info.level} > ${info.message}`),
|
||||
printf((info) => `${id}: ${info.timestamp} - ${info.level} > ${info.message}`),
|
||||
),
|
||||
transports: tr,
|
||||
exceptionHandlers,
|
||||
|
|
1835
pnpm-lock.yaml
1835
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -116,8 +116,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
const handleInstallSubmit = async (values: FormValues) => {
|
||||
setCustomStatus('installing');
|
||||
installDisclosure.close();
|
||||
const { exposed, domain } = values;
|
||||
installMutation.execute({ id: app.id, form: values, exposed, domain });
|
||||
installMutation.execute({ id: app.id, form: values });
|
||||
};
|
||||
|
||||
const handleUnistallSubmit = () => {
|
||||
|
@ -139,8 +138,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
|
||||
const handleUpdateSettingsSubmit = async (values: FormValues) => {
|
||||
updateSettingsDisclosure.close();
|
||||
const { exposed, domain } = values;
|
||||
updateConfigMutation.execute({ id: app.id, form: values, exposed, domain });
|
||||
updateConfigMutation.execute({ id: app.id, form: values });
|
||||
};
|
||||
|
||||
const handleUpdateSubmit = async () => {
|
||||
|
@ -185,8 +183,6 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
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} />
|
||||
|
@ -195,7 +191,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
|
||||
<span className="badge bg-muted mt-2 text-white">{app.info.version}</span>
|
||||
</div>
|
||||
<span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
|
||||
<span className="mt-1 text-muted text-center text-md-start mb-2">{app.info.short_desc}</span>
|
||||
<div className="mb-1">{customStatus !== 'missing' && <AppStatus status={customStatus} />}</div>
|
||||
<AppActions
|
||||
localDomain={localDomain}
|
||||
|
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import Markdown from '@/components/Markdown/Markdown';
|
||||
import { Markdown } from '@/components/Markdown';
|
||||
import { DataGrid, DataGridItem } from '@/components/ui/DataGrid';
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -14,7 +14,7 @@ import { validateAppConfig } from '../../utils/validators';
|
|||
interface IProps {
|
||||
formFields: FormField[];
|
||||
onSubmit: (values: FormValues) => void;
|
||||
initalValues?: { exposed?: boolean; domain?: string } & { [key: string]: string | boolean | undefined };
|
||||
initalValues?: { [key: string]: unknown };
|
||||
info: AppInfo;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ interface IProps {
|
|||
export type FormValues = {
|
||||
exposed?: boolean;
|
||||
domain?: string;
|
||||
isVisibleOnGuestDashboard?: boolean;
|
||||
[key: string]: string | boolean | undefined;
|
||||
};
|
||||
|
||||
|
@ -50,7 +51,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
|
|||
useEffect(() => {
|
||||
if (initalValues && !isDirty) {
|
||||
Object.entries(initalValues).forEach(([key, value]) => {
|
||||
setValue(key, value);
|
||||
setValue(key, value as string);
|
||||
});
|
||||
}
|
||||
}, [initalValues, isDirty, setValue]);
|
||||
|
@ -153,6 +154,14 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
|
|||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit(validate)}>
|
||||
{formFields.filter(typeFilter).map(renderField)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="isVisibleOnGuestDashboard"
|
||||
defaultValue={false}
|
||||
render={({ field: { onChange, value, ref, ...props } }) => (
|
||||
<Switch className="mb-3" disabled={info.force_expose} ref={ref} checked={value} onCheckedChange={onChange} {...props} label={t('display-on-guest-dashboard')} />
|
||||
)}
|
||||
/>
|
||||
{info.exposable && renderExposeForm()}
|
||||
<Button loading={loading} type="submit" className="btn-success">
|
||||
{initalValues ? t('submit-update') : t('sumbit-install')}
|
||||
|
|
|
@ -2,6 +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 { ScrollArea } from '@/components/ui/ScrollArea';
|
||||
import { InstallForm, FormValues } from '../InstallForm';
|
||||
|
||||
interface IProps {
|
||||
|
@ -20,9 +21,11 @@ export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit
|
|||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} />
|
||||
</DialogDescription>
|
||||
<ScrollArea maxHeight={500}>
|
||||
<DialogDescription>
|
||||
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} />
|
||||
</DialogDescription>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
@ -2,19 +2,18 @@ import React from 'react';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea';
|
||||
import { InstallForm, type FormValues } from '../InstallForm';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
||||
config: Record<string, unknown>;
|
||||
isOpen: boolean;
|
||||
exposed?: boolean;
|
||||
domain?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
}
|
||||
|
||||
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => {
|
||||
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit }) => {
|
||||
const t = useTranslations('apps.app-details.update-settings-form');
|
||||
|
||||
return (
|
||||
|
@ -23,9 +22,11 @@ export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, on
|
|||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config, exposed, domain }} />
|
||||
</DialogDescription>
|
||||
<ScrollArea maxHeight={500}>
|
||||
<DialogDescription>
|
||||
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config }} />
|
||||
</DialogDescription>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { EmptyPage } from '../../../../components/EmptyPage';
|
||||
import { AppStoreTile } from '../AppStoreTile';
|
||||
import { AppTableData } from '../../helpers/table.types';
|
||||
import { useAppStoreState } from '../../state/appStoreState';
|
||||
|
@ -15,11 +16,17 @@ export const AppStoreTable: React.FC<IProps> = ({ data }) => {
|
|||
|
||||
const tableData = React.useMemo(() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }), [data, sort, sortDirection, category, search]);
|
||||
|
||||
if (!tableData.length) {
|
||||
return <EmptyPage title="apps.app-store.no-results" subtitle="apps.app-store.no-results-subtitle" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row row-cards">
|
||||
{tableData.map((app) => (
|
||||
<AppStoreTile key={app.id} app={app} />
|
||||
))}
|
||||
<div className="card px-3 pb-3">
|
||||
<div className="row row-cards">
|
||||
{tableData.map((app) => (
|
||||
<AppStoreTile key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,8 +6,9 @@ import React from 'react';
|
|||
import { useTranslations } from 'next-intl';
|
||||
import { AppCategory } from '@runtipi/shared';
|
||||
import { AppLogo } from '@/components/AppLogo';
|
||||
import { limitText } from '@/lib/helpers/text-helpers';
|
||||
import styles from './AppStoreTile.module.scss';
|
||||
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
|
||||
import { colorSchemeForCategory } from '../../helpers/table.helpers';
|
||||
|
||||
type App = {
|
||||
id: string;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { limitText } from '@/lib/helpers/text-helpers';
|
||||
import { createAppConfig } from '../../../../../server/tests/apps.factory';
|
||||
import { limitText, sortTable } from '../table.helpers';
|
||||
import { sortTable } from '../table.helpers';
|
||||
import { AppTableData } from '../table.types';
|
||||
|
||||
describe('sortTable function', () => {
|
||||
|
|
|
@ -46,8 +46,6 @@ export const sortTable = (params: SortParams) => {
|
|||
return sortedData.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
|
||||
};
|
||||
|
||||
export const limitText = (text: string, limit: number) => (text.length > limit ? `${text.substring(0, limit)}...` : text);
|
||||
|
||||
export const colorSchemeForCategory: Record<AppCategory, string> = {
|
||||
network: 'blue',
|
||||
media: 'azure',
|
||||
|
|
|
@ -15,9 +15,5 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
export default async function AppStorePage() {
|
||||
const { apps } = await AppServiceClass.listApps();
|
||||
|
||||
return (
|
||||
<div className="card px-3 pb-3">
|
||||
<AppStoreTable data={apps} />
|
||||
</div>
|
||||
);
|
||||
return <AppStoreTable data={apps} />;
|
||||
}
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
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 { 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'>;
|
||||
|
||||
export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
|
||||
const t = useTranslations('apps');
|
||||
|
||||
return (
|
||||
<div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
|
||||
<div className="card card-sm card-link">
|
||||
<Link href={`/apps/${app.id}`} className="nav-link" passHref>
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="me-3">
|
||||
<AppLogo alt={`${app.name} logo`} id={app.id} size={60} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="d-flex h-3 align-items-center">
|
||||
<span className="h4 me-2 mb-1 fw-bolder">{app.name}</span>
|
||||
<div className={styles.statusContainer}>
|
||||
<AppStatus lite status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted">{limitText(app.short_desc, 50)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{updateAvailable && (
|
||||
<>
|
||||
<Tooltip anchorSelect=".updateAvailable">{t('update-available')}</Tooltip>
|
||||
<div className="updateAvailable ribbon bg-green ribbon-top">
|
||||
<IconDownload size={20} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
8
src/app/(dashboard)/apps/page.module.css
Normal file
8
src/app/(dashboard)/apps/page.module.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
}
|
|
@ -3,8 +3,11 @@ 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 { AppTile } from '@/components/AppTile';
|
||||
import Link from 'next/link';
|
||||
import clsx from 'clsx';
|
||||
import { EmptyPage } from '../../components/EmptyPage';
|
||||
import styles from './page.module.css';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translator = await getTranslatorFromCookie();
|
||||
|
@ -21,7 +24,12 @@ export default async function Page() {
|
|||
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} />;
|
||||
if (app.info?.available)
|
||||
return (
|
||||
<Link href={`/apps/${app.id}`} className={clsx('col-sm-6 col-lg-4', styles.link)} passHref>
|
||||
<AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import { IconBrandGithub, IconHeart, IconLogin, IconLogout, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import Image from 'next/image';
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
|
@ -12,17 +12,33 @@ import { useUIStore } from '@/client/state/uiStore';
|
|||
import { useAction } from 'next-safe-action/hook';
|
||||
import { logoutAction } from '@/actions/logout/logout-action';
|
||||
import Script from 'next/script';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { NavBar } from '../NavBar';
|
||||
|
||||
interface IProps {
|
||||
isUpdateAvailable?: boolean;
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
|
||||
export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = true }) => {
|
||||
const { setDarkMode } = useUIStore();
|
||||
const t = useTranslations('header');
|
||||
|
||||
const logoutMutation = useAction(logoutAction);
|
||||
const router = useRouter();
|
||||
|
||||
const logoutMutation = useAction(logoutAction, {
|
||||
onSuccess: () => {
|
||||
router.push('/');
|
||||
},
|
||||
});
|
||||
|
||||
const logHandler = () => {
|
||||
if (authenticated) {
|
||||
logoutMutation.execute();
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="text-white navbar navbar-expand-md navbar-dark navbar-overlap d-print-none" data-bs-theme="dark">
|
||||
|
@ -71,16 +87,9 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
|
|||
<div onClick={() => setDarkMode(false)} aria-hidden="true" className="lightMode nav-link px-0 hide-theme-light cursor-pointer" data-testid="light-mode-toggle">
|
||||
<IconSun data-testid="icon-sun" size={20} />
|
||||
</div>
|
||||
<Tooltip anchorSelect=".logOut">{t('logout')}</Tooltip>
|
||||
<div
|
||||
onClick={() => logoutMutation.execute()}
|
||||
tabIndex={0}
|
||||
onKeyPress={() => logoutMutation.execute()}
|
||||
role="button"
|
||||
className="logOut nav-link px-0 cursor-pointer"
|
||||
data-testid="logout-button"
|
||||
>
|
||||
<IconLogout size={20} />
|
||||
<Tooltip anchorSelect=".logOut">{authenticated ? t('logout') : t('login')}</Tooltip>
|
||||
<div onClick={() => logHandler()} tabIndex={0} onKeyPress={() => logHandler()} role="button" className="logOut nav-link px-0 cursor-pointer" data-testid="logout-button">
|
||||
{authenticated ? <IconLogout size={20} /> : <IconLogin size={20} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -30,7 +30,7 @@ export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div id="navbar-menu" className="collapse navbar-collapse" style={{}}>
|
||||
<div id="navbar-menu" className="collapse navbar-collapse">
|
||||
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||
<ul className="navbar-nav">
|
||||
{renderItem(t('dashboard'), 'dashboard', IconHome)}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { changeUsernameAction } from '@/actions/settings/change-username';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { useDisclosure } from '@/client/hooks/useDisclosure';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
type Props = {
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export const ChangeUsernameForm = ({ username }: Props) => {
|
||||
const router = useRouter();
|
||||
const changeUsernameDisclosure = useDisclosure();
|
||||
const t = useTranslations('settings.security');
|
||||
const schema = z.object({
|
||||
newUsername: z.string().email(t('change-username.form.invalid-username')),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const changeUsernameMutation = useAction(changeUsernameAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
toast.success(t('change-username.success'));
|
||||
router.push('/');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
changeUsernameMutation.execute(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<Input disabled type="email" value={username} />
|
||||
<Button className="mt-3" onClick={() => changeUsernameDisclosure.open()}>
|
||||
{t('change-username.form.submit')}
|
||||
</Button>
|
||||
<Dialog open={changeUsernameDisclosure.isOpen} onOpenChange={changeUsernameDisclosure.toggle}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('password-needed')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="d-flex flex-column">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-100">
|
||||
<p className="text-muted">{t('change-username.form.password-needed-hint')}</p>
|
||||
<Input
|
||||
error={formState.errors.newUsername?.message}
|
||||
disabled={changeUsernameMutation.status === 'executing'}
|
||||
type="email"
|
||||
placeholder={t('change-username.form.new-username')}
|
||||
{...register('newUsername')}
|
||||
/>
|
||||
<Input
|
||||
className="mt-2"
|
||||
error={formState.errors.password?.message}
|
||||
disabled={changeUsernameMutation.status === 'executing'}
|
||||
type="password"
|
||||
placeholder={t('form.password')}
|
||||
{...register('password')}
|
||||
/>
|
||||
<Button loading={changeUsernameMutation.status === 'executing'} type="submit" className="btn-success mt-3">
|
||||
{t('change-username.form.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { ChangeUsernameForm } from './ChangeUsernameForm';
|
|
@ -3,7 +3,7 @@
|
|||
import React from 'react';
|
||||
import semver from 'semver';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import Markdown from '@/components/Markdown/Markdown';
|
||||
import { Markdown } from '@/components/Markdown';
|
||||
import { IconStar } from '@tabler/icons-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useDisclosure } from '@/client/hooks/useDisclosure';
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { IconLock, IconKey } from '@tabler/icons-react';
|
||||
import { IconLock, IconKey, IconUser } from '@tabler/icons-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { OtpForm } from '../OtpForm';
|
||||
import { ChangePasswordForm } from '../ChangePasswordForm';
|
||||
import { ChangeUsernameForm } from '../ChangeUsernameForm';
|
||||
|
||||
export const SecurityContainer = (props: { totpEnabled: boolean }) => {
|
||||
const { totpEnabled } = props;
|
||||
export const SecurityContainer = (props: { totpEnabled: boolean; username?: string }) => {
|
||||
const { totpEnabled, username } = props;
|
||||
const t = useTranslations('settings.security');
|
||||
|
||||
return (
|
||||
<div className="card-body">
|
||||
<div className="d-flex">
|
||||
<IconUser className="me-2" />
|
||||
<h2>{t('change-username.title')}</h2>
|
||||
</div>
|
||||
<p className="text-muted">{t('change-username.subtitle')}</p>
|
||||
<ChangeUsernameForm username={username} />
|
||||
<div className="d-flex">
|
||||
<IconKey className="me-2" />
|
||||
<h2>{t('change-password-title')}</h2>
|
||||
|
|
|
@ -4,10 +4,11 @@ import { IconAdjustmentsAlt, IconUser } from '@tabler/icons-react';
|
|||
import clsx from 'clsx';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import validator from 'validator';
|
||||
import { Locale } from '@/shared/internationalization/locales';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { LanguageSelector } from '../../../../components/LanguageSelector';
|
||||
|
||||
export type SettingsFormValues = {
|
||||
|
@ -17,6 +18,7 @@ export type SettingsFormValues = {
|
|||
domain?: string;
|
||||
storagePath?: string;
|
||||
localDomain?: string;
|
||||
guestDashboard?: boolean;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
|
@ -62,6 +64,7 @@ export const SettingsForm = (props: IProps) => {
|
|||
handleSubmit,
|
||||
setValue,
|
||||
setError,
|
||||
control,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<SettingsFormValues>();
|
||||
|
||||
|
@ -113,6 +116,29 @@ export const SettingsForm = (props: IProps) => {
|
|||
<h2 className="text-2xl font-bold">{t('title')}</h2>
|
||||
</div>
|
||||
<p className="mb-4">{t('subtitle')}</p>
|
||||
<div className="mb-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="guestDashboard"
|
||||
defaultValue={false}
|
||||
render={({ field: { onChange, value, ref, ...rest } }) => (
|
||||
<Switch
|
||||
className="mb-3"
|
||||
ref={ref}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
{...rest}
|
||||
label={
|
||||
<>
|
||||
{t('guest-dashboard')}
|
||||
<Tooltip anchorSelect=".guest-dashboard-hint">{t('guest-dashboard-hint')}</Tooltip>
|
||||
<span className={clsx('ms-1 form-help guest-dashboard-hint')}>?</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
{...register('domain')}
|
||||
|
|
|
@ -38,7 +38,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
|
|||
<SettingsContainer initialValues={settings} currentLocale={locale} />
|
||||
</TabsContent>
|
||||
<TabsContent value="security">
|
||||
<SecurityContainer totpEnabled={Boolean(user?.totpEnabled)} />
|
||||
<SecurityContainer totpEnabled={Boolean(user?.totpEnabled)} username={user?.username} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
@ -12,18 +12,16 @@ const formSchema = z.object({}).catchall(z.any());
|
|||
const input = z.object({
|
||||
id: z.string(),
|
||||
form: formSchema,
|
||||
exposed: z.boolean().optional(),
|
||||
domain: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Given an app id, installs the app.
|
||||
*/
|
||||
export const installAppAction = action(input, async ({ id, form, domain, exposed }) => {
|
||||
export const installAppAction = action(input, async ({ id, form }) => {
|
||||
try {
|
||||
const appsService = new AppServiceClass(db);
|
||||
|
||||
await appsService.installApp(id, form, exposed, domain);
|
||||
await appsService.installApp(id, form);
|
||||
|
||||
revalidatePath('/apps');
|
||||
revalidatePath(`/app/${id}`);
|
||||
|
|
|
@ -19,11 +19,11 @@ const input = z.object({
|
|||
/**
|
||||
* Given an app id and form, updates the app config
|
||||
*/
|
||||
export const updateAppConfigAction = action(input, async ({ id, form, domain, exposed }) => {
|
||||
export const updateAppConfigAction = action(input, async ({ id, form }) => {
|
||||
try {
|
||||
const appsService = new AppServiceClass(db);
|
||||
|
||||
await appsService.updateAppConfig(id, form, exposed, domain);
|
||||
await appsService.updateAppConfig(id, form);
|
||||
|
||||
revalidatePath('/apps');
|
||||
revalidatePath(`/app/${id}`);
|
||||
|
|
31
src/app/actions/settings/change-username.ts
Normal file
31
src/app/actions/settings/change-username.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { action } from '@/lib/safe-action';
|
||||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { AuthServiceClass } from '@/server/services/auth/auth.service';
|
||||
import { db } from '@/server/db';
|
||||
import { handleActionError } from '../utils/handle-action-error';
|
||||
|
||||
const input = z.object({ newUsername: z.string().email(), password: z.string() });
|
||||
|
||||
/**
|
||||
* Given the current password and a new username, change the username of the current user.
|
||||
*/
|
||||
export const changeUsernameAction = action(input, async ({ newUsername, password }) => {
|
||||
try {
|
||||
const user = await getUserFromCookie();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const authService = new AuthServiceClass(db);
|
||||
|
||||
await authService.changeUsername({ userId: user.id, newUsername, password });
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
});
|
|
@ -4,6 +4,7 @@ import { action } from '@/lib/safe-action';
|
|||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { settingsSchema } from '@runtipi/shared';
|
||||
import { setSettings } from '@/server/core/TipiConfig';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { handleActionError } from '../utils/handle-action-error';
|
||||
|
||||
/**
|
||||
|
@ -19,6 +20,8 @@ export const updateSettingsAction = action(settingsSchema, async (settings) => {
|
|||
|
||||
await setSettings(settings);
|
||||
|
||||
revalidatePath('/');
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
|
|
|
@ -24,6 +24,7 @@ export const EmptyPage: React.FC<IProps> = ({ title, subtitle, redirectPath, act
|
|||
<div className="card empty">
|
||||
<Image
|
||||
src="/empty.svg"
|
||||
priority
|
||||
alt="Empty box"
|
||||
height="80"
|
||||
width="80"
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
}
|
37
src/app/components/GuestDashboardApps/GuestDashboardApps.tsx
Normal file
37
src/app/components/GuestDashboardApps/GuestDashboardApps.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { AppTile } from '@/components/AppTile';
|
||||
import type { AppService } from '@/server/services/apps/apps.service';
|
||||
import Link from 'next/link';
|
||||
|
||||
import React from 'react';
|
||||
import styles from './GuestDashboardApps.module.css';
|
||||
|
||||
type Props = {
|
||||
apps: Awaited<ReturnType<AppService['getGuestDashboardApps']>>;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
export const GuestDashboardApps = (props: Props) => {
|
||||
const { apps, hostname } = props;
|
||||
|
||||
const getUrl = (app: (typeof apps)[number]) => {
|
||||
if (app.domain && app.exposed) {
|
||||
return `https://${app.domain}`;
|
||||
}
|
||||
|
||||
const { https } = app.info;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
return `${protocol}://${hostname}:${app.info.port}${app.info.url_suffix || ''}`;
|
||||
};
|
||||
|
||||
return apps.map((app) => {
|
||||
const url = getUrl(app);
|
||||
|
||||
return (
|
||||
<div key={app.id} className="col-sm-6 col-lg-4">
|
||||
<Link passHref href={url} target="_blank" rel="noopener noreferrer" className={styles.link}>
|
||||
<AppTile key={app.id} app={app.info} status={app.status} updateAvailable={false} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
1
src/app/components/GuestDashboardApps/index.tsx
Normal file
1
src/app/components/GuestDashboardApps/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { GuestDashboardApps } from './GuestDashboardApps';
|
|
@ -1,8 +1,10 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { StatusScreen } from '../client/components/StatusScreen';
|
||||
'use client';
|
||||
|
||||
const ErrorPage: React.FC = () => {
|
||||
import React from 'react';
|
||||
import { StatusScreen } from '@/components/StatusScreen';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleHome = () => {
|
||||
|
@ -10,6 +12,4 @@ const ErrorPage: React.FC = () => {
|
|||
};
|
||||
|
||||
return <StatusScreen loading={false} title="404" subtitle="Page not found" actionTitle="Home page" onAction={handleHome} />;
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
}
|
|
@ -1,10 +1,43 @@
|
|||
import React from 'react';
|
||||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { db } from '@/server/db';
|
||||
import { AppServiceClass } from '@/server/services/apps/apps.service';
|
||||
import { getConfig } from '@/server/core/TipiConfig';
|
||||
import { AuthQueries } from '@/server/queries/auth/auth.queries';
|
||||
import { UnauthenticatedPage } from '@/components/UnauthenticatedPage';
|
||||
import { headers } from 'next/headers';
|
||||
import { GuestDashboardApps } from './components/GuestDashboardApps';
|
||||
import { EmptyPage } from './components/EmptyPage';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function RootPage() {
|
||||
const appService = new AppServiceClass(db);
|
||||
const { guestDashboard } = getConfig();
|
||||
|
||||
const headersList = headers();
|
||||
const host = headersList.get('host');
|
||||
const hostname = host?.split(':')[0];
|
||||
|
||||
if (guestDashboard) {
|
||||
const apps = await appService.getGuestDashboardApps();
|
||||
|
||||
return (
|
||||
<UnauthenticatedPage title="guest-dashboard" subtitle="runtipi">
|
||||
{apps.length === 0 ? (
|
||||
<EmptyPage title="guest-dashboard-no-apps" subtitle="guest-dashboard-no-apps-subtitle" />
|
||||
) : (
|
||||
<div className="row row-cards">
|
||||
<GuestDashboardApps apps={apps} hostname={hostname} />
|
||||
</div>
|
||||
)}
|
||||
</UnauthenticatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
const authQueries = new AuthQueries(db);
|
||||
|
||||
const isConfigured = await authQueries.getFirstOperator();
|
||||
|
||||
if (!isConfigured) {
|
||||
|
|
49
src/client/components/AppTile/AppTile.tsx
Normal file
49
src/client/components/AppTile/AppTile.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
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 { AppLogo } from '@/components/AppLogo';
|
||||
import { AppStatus } from '@/components/AppStatus';
|
||||
import { limitText } from '@/lib/helpers/text-helpers';
|
||||
import styles from './AppTile.module.scss';
|
||||
|
||||
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
|
||||
|
||||
export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
|
||||
const t = useTranslations('apps');
|
||||
|
||||
return (
|
||||
<div data-testid={`app-tile-${app.id}`}>
|
||||
<div className="card card-sm card-link">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="me-3">
|
||||
<AppLogo alt={`${app.name} logo`} id={app.id} size={60} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="d-flex h-3 align-items-center">
|
||||
<span className="h4 me-2 mb-1 fw-bolder">{app.name}</span>
|
||||
<div className={styles.statusContainer}>
|
||||
<AppStatus lite status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted">{limitText(app.short_desc, 50)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{updateAvailable && (
|
||||
<>
|
||||
<Tooltip anchorSelect=".updateAvailable">{t('update-available')}</Tooltip>
|
||||
<div className="updateAvailable ribbon bg-green ribbon-top">
|
||||
<IconDownload size={20} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -6,30 +6,8 @@ import remarkGfm from 'remark-gfm';
|
|||
import rehypeRaw from 'rehype-raw';
|
||||
import { PluggableList } from 'react-markdown/lib';
|
||||
|
||||
const MarkdownImg = (props: Pick<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, 'key' | keyof React.ImgHTMLAttributes<HTMLImageElement>>) => (
|
||||
<div className="d-flex justify-content-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img alt="app-demonstration" {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => (
|
||||
<ReactMarkdown
|
||||
className={clsx('markdown', className)}
|
||||
components={{
|
||||
// h2: (props) => <h2 {...props} className="text-xl font-bold mb-4 text-center md:text-left" />,
|
||||
// h3: (props) => <h3 {...props} className="text-lg font-bold mb-4 text-center md:text-left" />,
|
||||
// ul: (props) => <ul {...props} className="list-disc pl-4 mb-4" />,
|
||||
img: MarkdownImg,
|
||||
// p: (props) => <p {...props} className="mb-4 text-left md:text-left" />,
|
||||
// a: (props) => <a target="_blank" rel="noreferrer" {...props} className="text-blue-500" href={props.href} />,
|
||||
// div: (props) => <div {...props} className="mb-4" />,
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks, remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw] as PluggableList}
|
||||
>
|
||||
export const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => (
|
||||
<ReactMarkdown className={clsx('markdown', className)} remarkPlugins={[remarkBreaks, remarkGfm]} rehypePlugins={[rehypeRaw] as PluggableList}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
|
||||
export default Markdown;
|
||||
|
|
1
src/client/components/Markdown/index.ts
Normal file
1
src/client/components/Markdown/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Markdown } from './Markdown';
|
|
@ -0,0 +1,39 @@
|
|||
'use client';
|
||||
|
||||
import { MessageKey } from '@/server/utils/errors';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import React from 'react';
|
||||
import { Header } from 'src/app/(dashboard)/components/Header';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
title: MessageKey;
|
||||
subtitle?: MessageKey;
|
||||
};
|
||||
|
||||
export const UnauthenticatedPage = (props: Props) => {
|
||||
const { children, title, subtitle } = props;
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Header authenticated={false} />
|
||||
<div className="page-wrapper">
|
||||
<div className="page-header d-print-none">
|
||||
<div className="container-xl">
|
||||
<div className={clsx('row g-2 align-items-center')}>
|
||||
<div className="col text-white">
|
||||
{subtitle && <div className="page-pretitle">{t(subtitle)}</div>}
|
||||
<h2 className="page-title mt-1">{t(title)}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="container-xl">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
1
src/client/components/UnauthenticatedPage/index.ts
Normal file
1
src/client/components/UnauthenticatedPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { UnauthenticatedPage } from './UnauthenticatedPage';
|
3
src/client/components/ui/Button/Button.module.css
Normal file
3
src/client/components/ui/Button/Button.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.button {
|
||||
max-height: 40px;
|
||||
}
|
|
@ -12,7 +12,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, IProps>(({ type, className, children, loading, disabled, onClick, width, ...rest }, ref) => {
|
||||
const styles = { width: width ? `${width}px` : 'auto' };
|
||||
const styles = { width: width ? `${width}px` : 'auto', height: '36px' };
|
||||
return (
|
||||
<button style={styles} onClick={onClick} disabled={disabled || loading} ref={ref} className={clsx('btn', className, { disabled: disabled || loading })} type={type} {...rest}>
|
||||
{loading ? <span className="spinner-border spinner-border-sm mx-2" role="status" data-testid="loader" aria-hidden="true" /> : children}
|
||||
|
|
26
src/client/components/ui/ScrollArea/ScrollArea.module.css
Normal file
26
src/client/components/ui/ScrollArea/ScrollArea.module.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
.viewport {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
display: flex;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.scrollbarVertical {
|
||||
height: 100%;
|
||||
width: 0.625rem;
|
||||
border-left: 1px solid transparent;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.scrollbarHorizontal {
|
||||
flex-direction: column;
|
||||
height: 0.625rem;
|
||||
border-top: 1px solid transparent;
|
||||
padding: 0 1px;
|
||||
}
|
35
src/client/components/ui/ScrollArea/ScrollArea.tsx
Normal file
35
src/client/components/ui/ScrollArea/ScrollArea.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import clsx from 'clsx';
|
||||
import styles from './ScrollArea.module.css';
|
||||
|
||||
const ScrollBar = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>(
|
||||
({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={clsx(styles.scrollbar, { [styles.scrollbarVertical!]: orientation === 'vertical', [styles.scrollbarHorizontal!]: orientation === 'horizontal' }, className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className={clsx('position-relative rounded-pill bg-muted', orientation === 'vertical' && 'flex-grow-1')} />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
),
|
||||
);
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
const ScrollArea = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { maxHeight: number }>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={clsx('position-relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport style={{ maxHeight: props.maxHeight }} className={clsx(styles.viewport, 'w-100')}>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
),
|
||||
);
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
1
src/client/components/ui/ScrollArea/index.ts
Normal file
1
src/client/components/ui/ScrollArea/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ScrollArea } from './ScrollArea';
|
|
@ -177,6 +177,7 @@
|
|||
"install-form": {
|
||||
"title": "Install {name}",
|
||||
"expose-app": "Expose app",
|
||||
"display-on-guest-dashboard": "Display on guest dashboard",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"choose-option": "Choose an option...",
|
||||
|
@ -239,6 +240,8 @@
|
|||
"invalid-ip": "Invalid IP address",
|
||||
"invalid-url": "Invalid URL",
|
||||
"invalid-domain": "Invalid domain",
|
||||
"guest-dashboard": "Enable guest dashboard",
|
||||
"guest-dashboard-hint": "This will allow non-authenticated users to see a limited dashboard and easily access the running apps on your instance.",
|
||||
"domain-name": "Domain name",
|
||||
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
|
||||
"dns-ip": "DNS IP",
|
||||
|
@ -281,6 +284,18 @@
|
|||
"confirm-password": "Confirm new password",
|
||||
"change-password": "Change password",
|
||||
"password": "Password"
|
||||
},
|
||||
"change-username": {
|
||||
"title": "Change username",
|
||||
"subtitle": "Changing your username will log you out of all devices.",
|
||||
"success": "Username changed successfully",
|
||||
"form": {
|
||||
"new-username": "New username",
|
||||
"invalid-username": "Must be a valid email address",
|
||||
"password": "Password",
|
||||
"password-needed-hint": "Your password is required to change your username.",
|
||||
"submit": "Change username"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -291,10 +306,15 @@
|
|||
"app-store": "App Store",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"dark-mode": "Dark Mode",
|
||||
"light-mode": "Light Mode",
|
||||
"sponsor": "Sponsor",
|
||||
"source-code": "Source code",
|
||||
"update-available": "Update available"
|
||||
}
|
||||
},
|
||||
"runtipi": "Runtipi",
|
||||
"guest-dashboard": "Guest dashboard",
|
||||
"guest-dashboard-no-apps": "No apps to display",
|
||||
"guest-dashboard-no-apps-subtitle": "Ask your administrator to add apps to the guest dashboard or login to see your apps."
|
||||
}
|
||||
|
|
1
src/lib/helpers/text-helpers.ts
Normal file
1
src/lib/helpers/text-helpers.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const limitText = (text: string, limit: number) => (text.length > limit ? `${text.substring(0, limit)}...` : text);
|
|
@ -42,24 +42,30 @@ export class TipiConfig {
|
|||
status: 'RUNNING',
|
||||
storagePath: conf.STORAGE_PATH,
|
||||
demoMode: conf.DEMO_MODE,
|
||||
guestDashboard: conf.GUEST_DASHBOARD,
|
||||
seePreReleaseVersions: false,
|
||||
};
|
||||
|
||||
const parsedConfig = envSchema.safeParse({ ...envConfig, ...this.getFileConfig() });
|
||||
if (parsedConfig.success) {
|
||||
this.config = parsedConfig.data;
|
||||
} else {
|
||||
const errors = formatErrors(parsedConfig.error.flatten());
|
||||
Logger.error(`❌ Invalid env config ${JSON.stringify(errors)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getFileConfig() {
|
||||
const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
|
||||
const parsedFileConfig = envSchema.partial().safeParse(fileConfig);
|
||||
|
||||
if (parsedFileConfig.success) {
|
||||
const parsedConfig = envSchema.safeParse({ ...envConfig, ...parsedFileConfig.data });
|
||||
if (parsedConfig.success) {
|
||||
this.config = parsedConfig.data;
|
||||
} else {
|
||||
const errors = formatErrors(parsedConfig.error.flatten());
|
||||
Logger.error(`❌ Invalid env config ${JSON.stringify(errors)}`);
|
||||
}
|
||||
} else {
|
||||
const errors = formatErrors(parsedFileConfig.error.flatten());
|
||||
Logger.error(`❌ Invalid settings.json file: ${JSON.stringify(errors)}`);
|
||||
return parsedFileConfig.data;
|
||||
}
|
||||
|
||||
Logger.error(`❌ Invalid settings.json file: ${JSON.stringify(parsedFileConfig.error.flatten())}`);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public static getInstance(): TipiConfig {
|
||||
|
@ -70,7 +76,7 @@ export class TipiConfig {
|
|||
}
|
||||
|
||||
public getConfig() {
|
||||
return this.config;
|
||||
return { ...this.config, ...this.getFileConfig() };
|
||||
}
|
||||
|
||||
public getSettings() {
|
||||
|
|
|
@ -48,6 +48,7 @@ export const appTable = pgTable('app', {
|
|||
version: integer('version').default(1).notNull(),
|
||||
exposed: boolean('exposed').notNull(),
|
||||
domain: varchar('domain'),
|
||||
isVisibleOnGuestDashboard: boolean('is_visible_on_guest_dashboard').default(false).notNull(),
|
||||
});
|
||||
export type App = InferModel<typeof appTable>;
|
||||
export type NewApp = InferModel<typeof appTable, 'insert'>;
|
||||
|
|
|
@ -64,6 +64,13 @@ export class AppQueries {
|
|||
return this.db.query.appTable.findMany({ orderBy: asc(appTable.id) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all apps that are running and visible on guest dashboard sorted by id ascending
|
||||
*/
|
||||
public async getGuestDashboardApps() {
|
||||
return this.db.query.appTable.findMany({ where: and(eq(appTable.status, 'running'), eq(appTable.isVisibleOnGuestDashboard, true)), orderBy: asc(appTable.id) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a domain, return all apps that have this domain, are exposed and not the given id
|
||||
*
|
||||
|
|
|
@ -76,7 +76,7 @@ describe('Install app', () => {
|
|||
const appConfig = createAppConfig({ exposable: true });
|
||||
|
||||
// act & assert
|
||||
await expect(AppsService.installApp(appConfig.id, {}, true)).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
|
||||
await expect(AppsService.installApp(appConfig.id, { exposed: true })).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and config does not allow it', async () => {
|
||||
|
@ -84,7 +84,7 @@ describe('Install app', () => {
|
|||
const appConfig = createAppConfig({ exposable: false });
|
||||
|
||||
// act & assert
|
||||
await expect(AppsService.installApp(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
|
||||
await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError('server-messages.errors.app-not-exposable');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is not valid', async () => {
|
||||
|
@ -92,7 +92,7 @@ describe('Install app', () => {
|
|||
const appConfig = createAppConfig({ exposable: true });
|
||||
|
||||
// act & assert
|
||||
await expect(AppsService.installApp(appConfig.id, {}, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid');
|
||||
await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError('server-messages.errors.domain-not-valid');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is already used by another exposed app', async () => {
|
||||
|
@ -103,7 +103,7 @@ describe('Install app', () => {
|
|||
await insertApp({ domain, exposed: true }, appConfig2, db);
|
||||
|
||||
// act & assert
|
||||
await expect(AppsService.installApp(appConfig.id, {}, true, domain)).rejects.toThrowError('server-messages.errors.domain-already-in-use');
|
||||
await expect(AppsService.installApp(appConfig.id, { exposed: true, domain })).rejects.toThrowError('server-messages.errors.domain-already-in-use');
|
||||
});
|
||||
|
||||
it('Should throw if architecure is not supported', async () => {
|
||||
|
@ -308,7 +308,7 @@ describe('Update app config', () => {
|
|||
await insertApp({}, appConfig, db);
|
||||
|
||||
// act & assert
|
||||
expect(AppsService.updateAppConfig(appConfig.id, {}, true)).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
|
||||
expect(AppsService.updateAppConfig(appConfig.id, { exposed: true })).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is not valid', async () => {
|
||||
|
@ -317,7 +317,7 @@ describe('Update app config', () => {
|
|||
await insertApp({}, appConfig, db);
|
||||
|
||||
// act & assert
|
||||
expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid');
|
||||
expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError('server-messages.errors.domain-not-valid');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is already used', async () => {
|
||||
|
@ -329,7 +329,7 @@ describe('Update app config', () => {
|
|||
await insertApp({}, appConfig2, db);
|
||||
|
||||
// act & assert
|
||||
await expect(AppsService.updateAppConfig(appConfig2.id, {}, true, domain)).rejects.toThrowError('server-messages.errors.domain-already-in-use');
|
||||
await expect(AppsService.updateAppConfig(appConfig2.id, { exposed: true, domain })).rejects.toThrowError('server-messages.errors.domain-already-in-use');
|
||||
});
|
||||
|
||||
it('should throw if app is not exposed and config has force_expose set to true', async () => {
|
||||
|
@ -347,7 +347,7 @@ describe('Update app config', () => {
|
|||
await insertApp({}, appConfig, db);
|
||||
|
||||
// act & assert
|
||||
await expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
|
||||
await expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError('server-messages.errors.app-not-exposable');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -11,6 +11,12 @@ import { getConfig } from '../../core/TipiConfig';
|
|||
import { Logger } from '../../core/Logger';
|
||||
import { notEmpty } from '../../common/typescript.helpers';
|
||||
|
||||
type AlwaysFields = {
|
||||
isVisibleOnGuestDashboard?: boolean;
|
||||
domain?: string;
|
||||
exposed?: boolean;
|
||||
};
|
||||
|
||||
const sortApps = (a: AppInfo, b: AppInfo) => a.id.localeCompare(b.id);
|
||||
const filterApp = (app: AppInfo): boolean => {
|
||||
if (!app.supported_architectures) {
|
||||
|
@ -101,12 +107,12 @@ export class AppServiceClass {
|
|||
*
|
||||
* @param {string} id - The id of the app to be installed
|
||||
* @param {Record<string, string>} form - The form data submitted by the user
|
||||
* @param {boolean} [exposed] - A flag indicating if the app will be exposed to the internet
|
||||
* @param {string} [domain] - The domain name to expose the app to the internet, required if exposed is true
|
||||
*/
|
||||
public installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
|
||||
public installApp = async (id: string, form: Record<string, unknown> & AlwaysFields) => {
|
||||
const app = await this.queries.getApp(id);
|
||||
|
||||
const { exposed, domain, isVisibleOnGuestDashboard } = form;
|
||||
|
||||
if (app) {
|
||||
await this.startApp(id);
|
||||
} else {
|
||||
|
@ -148,7 +154,15 @@ export class AppServiceClass {
|
|||
}
|
||||
}
|
||||
|
||||
await this.queries.createApp({ id, status: 'installing', config: form, version: appInfo.tipi_version, exposed: exposed || false, domain: domain || null });
|
||||
await this.queries.createApp({
|
||||
id,
|
||||
status: 'installing',
|
||||
config: form,
|
||||
version: appInfo.tipi_version,
|
||||
exposed: exposed || false,
|
||||
domain: domain || null,
|
||||
isVisibleOnGuestDashboard,
|
||||
});
|
||||
|
||||
// Run script
|
||||
const eventDispatcher = new EventDispatcher('installApp');
|
||||
|
@ -181,10 +195,10 @@ export class AppServiceClass {
|
|||
*
|
||||
* @param {string} id - The ID of the app to update.
|
||||
* @param {object} form - The new configuration of the app.
|
||||
* @param {boolean} [exposed] - If the app should be exposed or not.
|
||||
* @param {string} [domain] - The domain for the app if exposed is true.
|
||||
*/
|
||||
public updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
|
||||
public updateAppConfig = async (id: string, form: Record<string, unknown> & AlwaysFields) => {
|
||||
const { exposed, domain } = form;
|
||||
|
||||
if (exposed && !domain) {
|
||||
throw new TranslatedError('server-messages.errors.domain-required-if-expose-app');
|
||||
}
|
||||
|
@ -226,7 +240,7 @@ export class AppServiceClass {
|
|||
await eventDispatcher.close();
|
||||
|
||||
if (success) {
|
||||
const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form });
|
||||
const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form, isVisibleOnGuestDashboard: form.isVisibleOnGuestDashboard });
|
||||
return updatedApp;
|
||||
}
|
||||
|
||||
|
@ -369,6 +383,20 @@ export class AppServiceClass {
|
|||
})
|
||||
.filter(notEmpty);
|
||||
};
|
||||
|
||||
public getGuestDashboardApps = async () => {
|
||||
const apps = await this.queries.getGuestDashboardApps();
|
||||
|
||||
return apps
|
||||
.map((app) => {
|
||||
const info = getAppInfo(app.id, app.status);
|
||||
if (info) {
|
||||
return { ...app, info };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(notEmpty);
|
||||
};
|
||||
}
|
||||
|
||||
export type AppService = InstanceType<typeof AppServiceClass>;
|
||||
|
|
|
@ -382,6 +382,45 @@ export class AuthServiceClass {
|
|||
return true;
|
||||
};
|
||||
|
||||
public changeUsername = async (params: { newUsername: string; password: string; userId: number }) => {
|
||||
if (getConfig().demoMode) {
|
||||
throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
|
||||
}
|
||||
|
||||
const { newUsername, password, userId } = params;
|
||||
|
||||
const user = await this.queries.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new TranslatedError('server-messages.errors.user-not-found');
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.password, password);
|
||||
|
||||
if (!valid) {
|
||||
throw new TranslatedError('server-messages.errors.invalid-password');
|
||||
}
|
||||
|
||||
const email = newUsername.trim().toLowerCase();
|
||||
|
||||
if (!validator.isEmail(email)) {
|
||||
throw new TranslatedError('server-messages.errors.invalid-username');
|
||||
}
|
||||
|
||||
const existingUser = await this.queries.getUserByUsername(email);
|
||||
|
||||
if (existingUser) {
|
||||
throw new TranslatedError('server-messages.errors.user-already-exists');
|
||||
}
|
||||
|
||||
await this.queries.updateUser(user.id, { username: email });
|
||||
const cache = new TipiCache('changeUsername');
|
||||
await this.destroyAllSessionsByUserId(user.id, cache);
|
||||
await cache.close();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a userId and a locale, change the user's locale
|
||||
*
|
||||
|
|
Loading…
Reference in a new issue