Merge pull request #879 from runtipi/release/2.1.0

Release 2.1.0
This commit is contained in:
Nicolas Meienberger 2023-11-07 22:14:38 +01:00 committed by GitHub
commit df59d21ce7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 1915 additions and 1154 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();
});

View file

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

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

View file

@ -6,7 +6,6 @@ const nextConfig = {
transpilePackages: ['@runtipi/shared'],
experimental: {
serverComponentsExternalPackages: ['bullmq'],
serverActions: true,
},
serverRuntimeConfig: {
INTERNAL_IP: process.env.INTERNAL_IP,

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
.link {
text-decoration: none;
color: inherit;
}
.link:hover {
text-decoration: none;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
}
});

View file

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

View file

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

View file

@ -0,0 +1,8 @@
.link {
text-decoration: none;
color: inherit;
}
.link:hover {
text-decoration: none;
}

View 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>
);
});
};

View file

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

View file

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

View file

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

View 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>
);
};

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
.button {
max-height: 40px;
}

View file

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

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

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export const limitText = (text: string, limit: number) => (text.length > limit ? `${text.substring(0, limit)}...` : text);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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