Compare commits

...
78 Commits
Author SHA1 Message Date
Releases Homarr d0a20dbfc5 chore(release): 1.0.0 [skip ci] 2025-01-17 12:47:30 +00:00
homarr-releases[bot] 68329965f7 chore(release): automatic release v1.0.0 2025-01-17 12:46:22 +00:00
Meier Lukas f9fea70d39 chore: change documentation base url to homarr.dev (#1826) 2025-01-17 13:45:18 +01:00
Meier Lukas 3ed46aecbd feat(widget): add proxmox integration (#1969)
* feat(widget): add proxmox integration

* fix: broken lock file

* fix: ci issues

* fix: ci issues

* fix: ci issues

* chore: debug temporary

* fix: name is not used correctly for nodes and storage in proxmox

* fix: remove temporary debu logs

* fix: job runs for both cluster and system health and throws error

* fix: ts-expect-error is unnecessary

* fix: remove unused import
2025-01-17 13:01:04 +01:00
Meier Lukas a31c6a97e0 ci: change weekly release base to main branch (#1976) 2025-01-17 12:59:02 +01:00
homarr-crowdin[bot]andMeier Lukas d1be727eb3 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-01-17 11:54:32 +00:00
Meier Lukas a1084f91e5 fix: mysql transactions do not work with run property of sqlite (#1974)
* fix: mysql transactions do not work with run property of sqlite

* fix: ci issues
2025-01-17 12:53:01 +01:00
Meier Lukas 7622f0a4d2 fix: websockets not working extend not defined (#1975)
* fix: websockets not working extend not defined

* fix: revert dockerode to 4.0.2
2025-01-17 12:52:42 +01:00
homarr-renovate[bot] 34cacb3153 fix(deps): update dependency flag-icons to ^7.3.1 (#1970)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 02:57:17 +00:00
homarr-renovate[bot] 48cf5d5304 chore(deps): update dependency prettier-plugin-packagejson to ^2.5.8 (#1973)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 02:14:40 +00:00
Meier Lukas 8c36c3e36b feat(certificates): handle self signed certificates (#1951)
* wip: add page and loading of certificates in folder

* wip: add certificate addition and removal

* feat: add removal ui for certificates

* feat: migrate integrations to fetch or agent with trusted certificates

* fix: lock file issues

* fix: typecheck issue

* fix: inconsistent package versions

* chore: address pull request feedback

* fix: add missing navigation item and restrict access to page

* chore: address pull request feedback

* fix: inconsistent undici dependency version

* fix: inconsistent undici dependency version
2025-01-17 00:08:40 +01:00
homarr-renovate[bot] b10b2013af fix(deps): update dependency drizzle-orm to ^0.38.4 (#1967)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 15:33:39 +00:00
homarr-renovate[bot] ea8acc83ea fix(deps): update dependency flag-icons to ^7.3.0 (#1966)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 14:44:14 +00:00
homarr-renovate[bot] cb96508634 fix(deps): update dependency drizzle-kit to ^0.30.2 (#1965)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 14:34:48 +00:00
homarr-renovate[bot] 5e83847772 fix(deps): update dependency undici to v7.2.3 (#1963)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 09:32:31 +00:00
homarr-renovate[bot] 1557537763 chore(deps): update dependency @types/node to ^22.10.7 (#1962)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 00:54:29 +00:00
homarr-crowdin[bot] 5f993d2630 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-16 00:43:40 +00:00
homarr-renovate[bot] 44767c46c2 fix(deps): update dependency flag-icons to ^7.2.4 (#1960)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-15 22:53:09 +00:00
homarr-renovate[bot] aaddf0d987 fix(deps): update dependency undici to v7.2.2 (#1957)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-15 17:41:33 +00:00
homarr-crowdin[bot] 7f8e6e4ea1 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-15 16:13:54 +00:00
homarr-renovate[bot] 34a76c98e8 fix(deps): update dependency better-sqlite3 to ^11.8.0 (#1956)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-15 03:17:53 +00:00
homarr-renovate[bot] 0f82a6f63d chore(deps): update dependency prettier-plugin-packagejson to ^2.5.7 (#1954)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 20:42:28 +00:00
Meier Lukas d4bb014a9b fix(category): remove not always working and ignoring dynamic sections (#1939) 2025-01-14 19:55:21 +01:00
Meier Lukas e01d74f4f8 feat(board): add mobile home board (#1910)
* feat(board): add mobile home board

* fix: add missing translations

* fix: mysql key reference with other datatype

* fix: format issue

* fix: missing trpc context arguments in tests

* fix: missing trpc context arguments in tests
2025-01-14 19:54:55 +01:00
Meier Lukas ec3bda34e0 fix(media-request): media and media-request status can have missing values (#1911) 2025-01-14 19:45:21 +01:00
Meier Lukas 5c16fb8d6c chore: add start command to run from source (#1928)
* chore: add start command to run from source

* fix: start command does not run in parallel
2025-01-14 19:44:37 +01:00
Meier Lukas 986e013ca3 fix(app): missing noreferer attribute for anchor tag (#1915) 2025-01-14 19:31:50 +01:00
Meier Lukas 1fd3fd8dfb refactor: env validation typescript and common package (#1912) 2025-01-14 19:03:38 +01:00
homarr-renovate[bot] a03a01b964 fix(deps): update mantine monorepo to ^7.16.0 (#1948)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 17:53:17 +00:00
Meier Lukas 4d94fce523 test: adjust nzbget test to use append instead of scan (#1952) 2025-01-14 18:39:12 +01:00
homarr-renovate[bot] 75c791801e fix(deps): update dependency eslint-config-prettier to v10 (#1937)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 17:48:54 +01:00
homarr-renovate[bot] 3e67b411b6 chore(deps): update dependency @types/react to v19.0.7 (#1947)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 09:16:54 +00:00
homarr-renovate[bot] ff1a003037 fix(deps): update dependency sass to ^1.83.4 (#1946)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 06:41:43 +00:00
homarr-renovate[bot] 99c69ba65d fix(deps): update dependency sass to ^1.83.3 (#1945)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 05:49:13 +00:00
homarr-crowdin[bot] f5cf92c49c chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-14 00:44:35 +00:00
homarr-renovate[bot] 05dc51b38e chore(deps): update pnpm to v9.15.4 (#1943)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 21:24:22 +00:00
homarr-renovate[bot] a53e0f7cee fix(deps): update tanstack-query monorepo to ^5.64.1 (#1935)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 19:27:22 +00:00
homarr-renovate[bot] f3a91ca37f chore(deps): update dependency @types/node to ^22.10.6 (#1942)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 19:15:16 +00:00
homarr-renovate[bot] 1e406a8a58 fix(deps): update dependency @ianvs/prettier-plugin-sort-imports to ^4.4.1 (#1936)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:49:04 +00:00
homarr-renovate[bot] 2ea763c231 fix(deps): update dependency eslint-plugin-react to ^7.37.4 (#1934)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:46:24 +00:00
homarr-renovate[bot] 579bb2a33e chore(deps): update dependency @types/react to v19.0.6 (#1933)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:45:09 +00:00
homarr-renovate[bot] feecdd36d4 fix(deps): update dependency typescript-eslint to ^8.20.0 (#1938)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:24:00 +00:00
Meier Lukas 56755d1ab4 fix: trpc removed export anything from client in react-query (#1941) 2025-01-13 18:50:51 +01:00
homarr-crowdin[bot] 6b36ec3103 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-13 00:47:42 +00:00
homarr-renovate[bot] fc6dbc92df chore(deps): update react monorepo (#1927)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-12 10:45:02 +00:00
homarr-crowdin[bot] 467d5e21ae chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-12 00:49:05 +00:00
homarr-renovate[bot] b399cb6979 fix(deps): update tanstack-query monorepo to ^5.64.0 (#1922)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-11 19:13:10 +00:00
Meier Lukas 4d69b81676 ci: add missing git config to release pipeline 2025-01-11 11:13:48 +01:00
homarr-crowdin[bot] a1fc9902a5 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-11 10:06:50 +00:00
Meier Lukas 0649220080 Merge branch 'beta' into dev 2025-01-11 10:56:33 +01:00
homarr-renovate[bot] e502fbc533 chore(deps): update dependency eslint to ^9.18.0 (#1916)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-10 21:54:40 +00:00
homarr-releases[bot] fac522adb9 chore(release): automatic release v0.1.0 2024-12-13 19:13:52 +00:00
homarr-releases[bot] 9945425eff chore(release): automatic release v0.1.0 2024-12-06 19:14:14 +00:00
homarr-releases[bot] 8cd5833281 chore(release): automatic release v0.1.0 2024-11-29 19:15:06 +00:00
homarr-releases[bot] ef7174aa36 chore(release): automatic release v0.1.0 2024-11-22 19:14:01 +00:00
homarr-releases[bot] 4ce76f8a73 chore(release): automatic release v0.1.0 2024-11-15 19:13:45 +00:00
homarr-releases[bot] 3e65af9ebe chore(release): automatic release v0.1.0 2024-11-08 19:12:25 +00:00
homarr-releases[bot] c6e5bec348 chore(release): automatic release v0.1.0 2024-11-04 19:34:22 +00:00
homarr-releases[bot] 3e74c065f5 chore(release): automatic release v0.1.0 2024-11-04 18:21:54 +00:00
homarr-releases[bot] 14ae732d0c chore(release): automatic release v0.1.0 2024-11-01 19:13:45 +00:00
homarr-releases[bot] b97883f44d chore(release): automatic release v0.1.0 2024-10-25 19:12:51 +00:00
homarr-releases[bot] 741753fa74 chore(release): automatic release v0.1.0 2024-10-18 19:13:17 +00:00
homarr-releases[bot] 1f0f8912b6 chore(release): automatic release v0.1.0 2024-10-11 21:37:04 +00:00
homarr-releases[bot] 49d6932218 chore(release): automatic release v0.1.0 2024-10-11 19:12:56 +00:00
homarr-releases[bot] f13fefa64d chore(release): automatic release v0.1.0 2024-10-04 19:33:01 +00:00
homarr-releases[bot] cef567efdf chore(release): automatic release v0.1.0 2024-10-04 19:12:57 +00:00
homarr-releases[bot] db2b79ee7f chore(release): automatic release v0.1.0 2024-09-27 19:13:34 +00:00
Meier Lukas b46d30b04c chore(release): release v0.1.0 2024-09-21 11:42:43 +02:00
homarr-releases[bot] de4283b5fa chore(release): automatic release v0.1.0 2024-09-20 19:13:13 +00:00
homarr-releases[bot] a08d3e387f chore(release): automatic release v0.1.0 2024-09-13 19:12:49 +00:00
homarr-releases[bot] bd1ff01109 chore(release): automatic release v0.1.0 2024-09-06 19:13:34 +00:00
homarr-releases[bot] b24b1cc8a9 chore(release): automatic release v0.1.0 2024-08-30 19:11:57 +00:00
homarr-releases[bot] e81edc898f chore(release): automatic release v0.1.0 2024-08-23 19:11:47 +00:00
homarr-releases[bot] 4d864878e3 chore(release): automatic release v0.1.0 2024-08-16 19:13:19 +00:00
homarr-releases[bot] 5776ead3e3 chore(release): automatic release v0.1.0 2024-08-09 19:12:44 +00:00
homarr-releases[bot] baf32c3fe6 chore(release): automatic release v0.1.0 2024-08-02 19:52:15 +00:00
Meier Lukas ea408a0fdd Merge pull request #570 from homarr-labs/dev
chore: merge to main
2024-05-28 21:50:27 +02:00
Manuel 17f93097f6 Merge pull request #547 from homarr-labs/dev
chore: merge to main
2024-05-24 22:16:13 +02:00
202 changed files with 10861 additions and 2241 deletions

No files matched your search

+4
View File
@@ -28,6 +28,10 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# If it is used, please use the full path to the directory where the certificates are stored.
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger
+5
View File
@@ -6,6 +6,11 @@
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
{
matchPackagePatterns: ["^dockerode$"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
@@ -94,6 +94,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
git config user.name "Releases Homarr"
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git fetch origin dev
git checkout dev
git pull origin dev
@@ -37,7 +37,7 @@ jobs:
token: ${{ github.token }}
branch: dev
- name: Create pull request
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base beta --head dev --label automerge'
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
+3 -1
View File
@@ -54,7 +54,9 @@ yarn-error.log*
*.log
apps/tasks/tasks.cjs
apps/tasks/tasks.css
apps/websocket/wssServer.cjs
apps/websocket/wssServer.css
apps/nextjs/.million/
packages/cli/cli.cjs
@@ -62,4 +64,4 @@ packages/cli/cli.cjs
e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
apps/nextjs/public/images/background.png
+1188
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,13 +1,13 @@
// Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import "@homarr/db/env.mjs";
import "@homarr/common/env.mjs";
import "@homarr/auth/env";
import "@homarr/db/env";
import "@homarr/common/env";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.mjs";
import "./src/env.ts";
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
+16 -15
View File
@@ -18,6 +18,7 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -38,18 +39,18 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.15.3",
"@mantine/core": "^7.15.3",
"@mantine/dropzone": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/tiptap": "^7.15.3",
"@mantine/colors-generator": "^7.16.0",
"@mantine/core": "^7.16.0",
"@mantine/dropzone": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/modals": "^7.16.0",
"@mantine/tiptap": "^7.16.0",
"@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-query-devtools": "^5.63.0",
"@tanstack/react-query-next-experimental": "5.63.0",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@tanstack/react-query-next-experimental": "5.64.1",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -61,7 +62,7 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"flag-icons": "^7.2.3",
"flag-icons": "^7.3.1",
"glob": "^11.0.1",
"jotai": "^2.11.0",
"mantine-react-table": "2.0.0-beta.8",
@@ -72,7 +73,7 @@
"react-dom": "19.0.0",
"react-error-boundary": "^5.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.83.1",
"sass": "^1.83.4",
"superjson": "2.2.2",
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1"
@@ -82,13 +83,13 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.0",
"@types/node": "^22.10.5",
"@types/node": "^22.10.7",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.4",
"@types/react-dom": "19.0.2",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.2",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"node-loader": "^2.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
@@ -21,7 +21,7 @@ import type { AppRouter } from "@homarr/api";
import { clientApi, getTrpcUrl } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { env } from "~/env.mjs";
import { env } from "~/env";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
@@ -28,7 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env.mjs";
import { env } from "~/env";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
@@ -101,7 +101,11 @@ export default async function BoardSettingsPage(props: Props) {
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
<DangerZoneSettingsContent
hideVisibility={
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
}
/>
</AccordionItemFor>
</>
)}
+1 -1
View File
@@ -9,7 +9,7 @@ import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
@@ -3,7 +3,7 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconCopy, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -43,6 +43,12 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await revalidatePathActionAsync("/");
},
});
const setMobileHomeBoardMutation = clientApi.board.setMobileHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
@@ -68,6 +74,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
const handleSetMobileHomeBoard = useCallback(async () => {
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
@@ -85,6 +95,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
@@ -15,7 +15,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -88,6 +88,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{t("action.setMobileHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />
@@ -1,4 +1,4 @@
import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -7,4 +7,6 @@ export const integrationSecretIcons = {
username: IconUser,
apiKey: IconKey,
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -6,6 +6,7 @@ import {
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
IconCertificate,
IconGitFork,
IconHome,
IconInfoSmall,
@@ -119,6 +120,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
},
{
label: t("items.tools.items.certificates"),
icon: IconCertificate,
href: "/manage/tools/certificates",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.tasks"),
icon: IconReport,
@@ -37,6 +37,25 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
)}
{...form.getInputProps("homeBoardId")}
/>
<SelectWithCustomItems
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({
value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("mobileHomeBoardId")}
/>
</>
)}
</CommonSettingsForm>
@@ -0,0 +1,81 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { superRefineCertificateFile, z } from "@homarr/validation";
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
const t = useI18n();
const handleClick = () => {
openModal({});
};
return <Button onClick={handleClick}>{t("certificate.action.create.label")}</Button>;
};
const AddCertificateModal = createModal(({ actions }) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation();
return (
<form
onSubmit={form.onSubmit(async (values) => {
const formData = new FormData();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
formData.set("file", values.file!);
await mutateAsync(formData, {
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
actions.closeModal();
},
onError() {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={form.submitting}>
{t("common.action.add")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("certificate.action.create.label");
},
});
@@ -0,0 +1,54 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
interface RemoveCertificateProps {
fileName: string;
}
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
const t = useI18n();
const handleClick = () => {
openConfirmModal({
title: t("certificate.action.remove.label"),
children: t("certificate.action.remove.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{ fileName },
{
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.remove.notification.success.title"),
message: t("certificate.action.remove.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
},
onError() {
showErrorNotification({
title: t("certificate.action.remove.notification.error.title"),
message: t("certificate.action.remove.notification.error.message"),
});
},
},
);
},
});
};
return (
<ActionIcon onClick={handleClick} color="red" variant="subtle">
<IconTrash color="red" size={16} />
</ActionIcon>
);
};
@@ -0,0 +1,97 @@
import { X509Certificate } from "node:crypto";
import { notFound } from "next/navigation";
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import { auth } from "@homarr/auth/next";
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
import { getMantineColor } from "@homarr/common";
import type { SupportedLanguage } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AddCertificateButton } from "./_components/add-certificate";
import { RemoveCertificate } from "./_components/remove-certificate";
interface CertificatesPageProps {
params: Promise<{
locale: SupportedLanguage;
}>;
}
export default async function CertificatesPage({ params }: CertificatesPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const { locale } = await params;
const t = await getI18n();
const certificates = await loadCustomRootCertificatesAsync();
const x509Certificates = certificates
.map((cert) => ({
...cert,
x509: new X509Certificate(cert.content),
}))
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
return (
<>
<DynamicBreadcrumb />
<Stack>
<Group justify="space-between">
<Stack gap={4}>
<Title>{t("certificate.page.list.title")}</Title>
<Text>{t("certificate.page.list.description")}</Text>
</Stack>
<AddCertificateButton />
</Group>
{x509Certificates.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("certificate.page.list.noResults.title")} />
)}
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
{x509Certificates.map((cert) => (
<Card key={cert.x509.fingerprint} withBorder>
<Group wrap="nowrap">
<IconCertificate color={getMantineColor(iconColor(cert.x509.validToDate), 6)} size={32} stroke={1.5} />
<Stack flex={1} gap="xs">
<Group justify="space-between">
<Text fw={500}>{cert.x509.subject}</Text>
<Text c="gray.6" ta="end" size="sm">
{cert.fileName}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
{t("certificate.page.list.expires", {
when: new Intl.RelativeTimeFormat(locale).format(
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
"days",
),
})}
</Text>
<RemoveCertificate fileName={cert.fileName} />
</Group>
</Stack>
</Group>
</Card>
))}
</SimpleGrid>
</Stack>
</>
);
}
const iconColor = (validTo: Date) => {
const daysUntilInvalid = dayjs(validTo).diff(new Date(), "days");
if (daysUntilInvalid < 1) return "red";
if (daysUntilInvalid < 7) return "orange";
if (daysUntilInvalid < 30) return "yellow";
return "green";
};
@@ -18,13 +18,14 @@ interface ChangeHomeBoardFormProps {
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
homeBoardId: variables.homeBoardId,
mobileHomeBoardId: variables.mobileHomeBoardId,
});
showSuccessNotification({
message: t("user.action.changeHomeBoard.notification.success.message"),
@@ -36,9 +37,10 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
});
},
});
const form = useZodForm(validation.user.changeHomeBoard, {
const form = useZodForm(validation.user.changeHomeBoards, {
initialValues: {
homeBoardId: user.homeBoardId ?? "",
mobileHomeBoardId: user.mobileHomeBoardId ?? "",
},
});
@@ -52,7 +54,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
<Select
label={t("management.page.user.setting.general.item.board.type.general")}
w="100%"
data={boardsData}
{...form.getInputProps("homeBoardId")}
/>
<Select
label={t("management.page.user.setting.general.item.board.type.mobile")}
w="100%"
data={boardsData}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
@@ -64,4 +77,4 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
);
};
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
type FormType = z.infer<typeof validation.user.changeHomeBoards>;
@@ -89,7 +89,7 @@ export default async function EditUserPage(props: Props) {
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board")}</Title>
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
@@ -5,7 +5,7 @@ import { IconExclamationCircle } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { everyoneGroup } from "@homarr/definitions";
@@ -5,7 +5,7 @@ import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { env } from "~/env.mjs";
import { env } from "~/env";
import { WidgetPreviewPageContent } from "./_content";
interface Props {
@@ -0,0 +1,111 @@
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
export interface RemoveCategoryInput {
id: string;
}
export const removeCategoryCallback =
(input: RemoveCategoryInput) =>
(previous: Board): Board => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
const emptySectionsAbove = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
);
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
const emptySectionsBelow = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
);
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
if (!aboveSection || !removedSection) {
return previous;
}
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
const aboveYOffset = Math.max(
calculateYHeightWithOffsetForItems(aboveSection),
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
);
const categoryYOffset = Math.max(
calculateYHeightWithOffsetForItems(currentCategory),
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
);
const previousCategoryItems = currentCategory.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset,
}));
const previousBelowWrapperItems = removedSection.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
}));
return {
...previous,
sections: [
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
{
...aboveSection,
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
},
...previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
)
.map((section) => ({
...section,
position: section.yOffset - 2,
})),
...previous.sections
.filter((section): section is DynamicSection => section.kind === "dynamic")
.map((dynamicSection) => {
// Move dynamic sections from removed section to above section with required yOffset
if (dynamicSection.parentSectionId === removedSection.id) {
return {
...dynamicSection,
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
parentSectionId: aboveSection.id,
};
}
// Move dynamic sections from category to above section with required yOffset
if (dynamicSection.parentSectionId === currentCategory.id) {
return {
...dynamicSection,
yOffset: dynamicSection.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
return dynamicSection;
}),
],
};
};
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
return sections.reduce((acc, section) => {
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
return acc;
}
const yHeightWithOffset = section.yOffset + section.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
};
const calculateYHeightWithOffsetForItems = (section: Section) =>
section.items.reduce((acc, item) => {
const yHeightWithOffset = item.yOffset + item.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
@@ -0,0 +1,125 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test } from "vitest";
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
import { removeCategoryCallback } from "../remove-category";
describe("Remove Category", () => {
test.each([
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
])(
"should remove category",
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
const sections = createSections(initialYOffsets);
const input = removeId.toString();
const result = removeCategoryCallback({ id: input })({ sections } as never);
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
expectedRemovals.forEach((expectedRemoval) => {
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
});
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
expect.arrayContaining(expectedRemovals),
);
},
);
test("should correctly move items to above empty section", () => {
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
const sections: Section[] = createSections(initialYOffsets);
const aboveSection = sections.find((section) => section.yOffset === 2)!;
aboveSection.items = [
createItem({ id: "above-1" }),
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
];
const removedCategory = sections.find((section) => section.yOffset === 3)!;
removedCategory.items = [
createItem({ id: "category-1" }),
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
];
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
removedEmptySection.items = [
createItem({ id: "below-1", xOffset: 5 }),
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
];
sections.push(
createDynamicSection({
id: "7",
parentSectionId: "3",
yOffset: 7,
height: 3,
items: [createItem({ id: "dynamic-1" })],
}),
);
const input = "3";
const result = removeCategoryCallback({ id: input })({ sections } as never);
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
const aboveSectionResult = result.sections.find((section) => section.id === "2")!;
expect(aboveSectionResult.items).toEqual(
expect.arrayContaining([
createItem({ id: "above-1" }),
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
createItem({ id: "category-1", yOffset: 5 }),
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
]),
);
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
expect(dynamicSection.yOffset).toBe(12);
expect(dynamicSection.parentSectionId).toBe("2");
});
});
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
return {
id: item.id ?? "0",
kind: "app",
options: {},
advancedOptions: {
customCssClasses: [],
},
height: item.height ?? 1,
width: item.width ?? 1,
yOffset: item.yOffset ?? 0,
xOffset: item.xOffset ?? 0,
integrationIds: [],
} satisfies Item;
};
const createDynamicSection = (
section: Partial<
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
>,
) => {
return {
id: section.id ?? "0",
kind: "dynamic",
height: section.height ?? 1,
width: section.width ?? 1,
yOffset: section.yOffset ?? 0,
xOffset: section.xOffset ?? 0,
parentSectionId: section.parentSectionId ?? "0",
items: section.items ?? [],
} satisfies DynamicSection;
};
const createSections = (initialYOffsets: number[]) => {
return initialYOffsets.map((yOffset, index) => ({
id: yOffset.toString(),
kind: index % 2 === 0 ? "empty" : "category",
name: "Category",
yOffset,
xOffset: 0,
items: [createItem({ id: yOffset.toString() })],
})) satisfies Section[];
};
@@ -2,10 +2,12 @@ import { useCallback } from "react";
import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
import type { RemoveCategoryInput } from "./actions/remove-category";
import { removeCategoryCallback } from "./actions/remove-category";
interface AddCategory {
name: string;
@@ -17,10 +19,6 @@ interface RenameCategory {
name: string;
}
interface RemoveCategory {
id: string;
}
export const useCategoryActions = () => {
const { updateBoard } = useUpdateBoard();
@@ -132,57 +130,8 @@ export const useCategoryActions = () => {
);
const removeCategory = useCallback(
({ id: categoryId }: RemoveCategory) => {
updateBoard((previous) => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === categoryId,
);
if (!currentCategory) return previous;
const aboveWrapper = previous.sections.find(
(section): section is EmptySection =>
section.kind === "empty" && section.yOffset === currentCategory.yOffset - 1,
);
const removedWrapper = previous.sections.find(
(section): section is EmptySection =>
section.kind === "empty" && section.yOffset === currentCategory.yOffset + 1,
);
if (!aboveWrapper || !removedWrapper) return previous;
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
const aboveYOffset = calculateYHeightWithOffset(aboveWrapper);
const categoryYOffset = calculateYHeightWithOffset(currentCategory);
const previousCategoryItems = currentCategory.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset,
}));
const previousBelowWrapperItems = removedWrapper.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
}));
return {
...previous,
sections: [
...previous.sections.filter((section) => section.yOffset < currentCategory.yOffset - 1),
{
...aboveWrapper,
items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems],
},
...previous.sections
.filter(
(section): section is CategorySection | EmptySection => section.yOffset >= currentCategory.yOffset + 2,
)
.map((section) => ({
...section,
position: section.yOffset - 2,
})),
],
};
});
(input: RemoveCategoryInput) => {
updateBoard(removeCategoryCallback(input));
},
[updateBoard],
);
@@ -195,10 +144,3 @@ export const useCategoryActions = () => {
removeCategory,
};
};
const calculateYHeightWithOffset = (section: Section) =>
section.items.reduce((acc, item) => {
const yHeightWithOffset = item.yOffset + item.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
@@ -1,6 +1,8 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
export const env = createEnv({
shared: {
PORT: z.coerce.number().default(3000),
@@ -13,7 +15,7 @@ export const env = createEnv({
server: {
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.number().optional(),
DOCKER_PORTS: z.string().optional(),
},
/**
* Specify your client-side environment variables schema here.
@@ -32,6 +34,5 @@ export const env = createEnv({
DOCKER_PORTS: process.env.DOCKER_PORTS,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});
+3 -3
View File
@@ -38,15 +38,15 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.2.1"
"undici": "7.2.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.10.5",
"@types/node": "^22.10.7",
"dotenv-cli": "^8.0.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.3"
+2 -31
View File
@@ -1,35 +1,6 @@
import type { Dispatcher } from "undici";
import { Agent, setGlobalDispatcher } from "undici";
import { setGlobalDispatcher } from "undici";
import { logger } from "@homarr/log";
export class LoggingAgent extends Agent {
constructor(...props: ConstructorParameters<typeof Agent>) {
super(...props);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const url = new URL(`${options.origin as string}${options.path}`);
// The below code should prevent sensitive data from being logged as
// some integrations use query parameters for auth
url.searchParams.forEach((value, key) => {
if (value === "") return; // Skip empty values
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
if (value === "true" || value === "false") return; // Skip boolean values
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
url.searchParams.set(key, "REDACTED");
});
logger.info(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
);
return super.dispatch(options, handler);
}
}
import { LoggingAgent } from "@homarr/common/server";
const agent = new LoggingAgent();
setGlobalDispatcher(agent);
+1 -1
View File
@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
}
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.0.0-beta.12",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -22,9 +22,10 @@
"lint:ws": "pnpm dlx sherif@latest",
"package:new": "turbo gen init",
"release": "semantic-release",
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
@@ -52,12 +53,15 @@
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
},
"packageManager": "pnpm@9.15.3",
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=22.13.0"
},
"pnpm": {
"allowNonAppliedPatches": true,
"overrides": {
"proxmox-api>undici": "7.2.3"
},
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
}
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+3 -2
View File
@@ -22,6 +22,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
@@ -41,7 +42,7 @@
"@trpc/client": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.3",
"dockerode": "4.0.2",
"lodash.clonedeep": "^4.5.0",
"next": "15.1.4",
"react": "19.0.0",
@@ -54,7 +55,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.34",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
import { createTRPCClient, httpLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import SuperJSON from "superjson";
import type { AppRouter } from ".";
+2
View File
@@ -1,6 +1,7 @@
import { apiKeysRouter } from "./router/apiKeys";
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { certificateRouter } from "./router/certificates/certificate-router";
import { cronJobsRouter } from "./router/cron-jobs";
import { dockerRouter } from "./router/docker/docker-router";
import { groupRouter } from "./router/group";
@@ -41,6 +42,7 @@ export const appRouter = createTRPCRouter({
apiKeys: apiKeysRouter,
media: mediaRouter,
updateChecker: updateCheckerRouter,
certificates: certificateRouter,
});
// export type definition of API
+502 -206
View File
@@ -2,8 +2,9 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, InferInsertModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
boardGroupPermissions,
@@ -121,6 +122,7 @@ export const boardRouter = createTRPCRouter({
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
search: publicProcedure
@@ -194,6 +196,7 @@ export const boardRouter = createTRPCRouter({
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure
@@ -201,21 +204,49 @@ export const boardRouter = createTRPCRouter({
.input(validation.board.create)
.mutation(async ({ ctx, input }) => {
const boardId = createId();
await ctx.db.transaction(async (transaction) => {
await transaction.insert(boards).values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
});
await transaction.insert(sections).values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.boards).values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
});
await transaction.insert(schema.sections).values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.insert(boards)
.values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
})
.run();
transaction
.insert(sections)
.values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
})
.run();
});
},
});
}),
duplicateBoard: permissionRequiredProcedure
@@ -299,28 +330,54 @@ export const boardRouter = createTRPCRouter({
})),
);
ctx.db.transaction((transaction) => {
transaction
.insert(boards)
.values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
})
.run();
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
transaction.insert(schema.boards).values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
});
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
if (sectionsToInsert.length > 0) {
await transaction.insert(schema.sections).values(sectionsToInsert);
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
if (itemsToInsert.length > 0) {
await transaction.insert(schema.items).values(itemsToInsert);
}
if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
}
if (itemIntegrationsToInsert.length > 0) {
await transaction.insert(schema.integrationItems).values(itemIntegrationsToInsert);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.insert(boards)
.values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
})
.run();
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
}
});
},
});
}),
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
@@ -336,7 +393,10 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
@@ -358,30 +418,30 @@ export const boardRouter = createTRPCRouter({
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
? await ctx.db.query.users.findFirst({
? ((await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
})
})) ?? null)
: null;
// 1. user home board, 2. home board, 3. not found
let boardWhere: SQL<unknown> | null = null;
if (user?.homeBoardId) {
boardWhere = eq(boards.id, user.homeBoardId);
} else {
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
}
const homeBoardId = await getHomeIdBoardAsync(ctx.db, user, ctx.deviceType);
if (!boardWhere) {
if (!homeBoardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No home board found",
});
}
const boardWhere = eq(boards.id, homeBoardId);
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
@@ -428,148 +488,313 @@ export const boardRouter = createTRPCRouter({
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
await ctx.db.transaction(async (transaction) => {
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
}
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
await transaction.insert(items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
);
}
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId: prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const addedItems = filterAddedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(items).where(inArray(items.id, itemIds));
}
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
);
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (sectionIds.length > 0) {
await transaction.delete(sections).where(inArray(sections.id, sectionIds));
}
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(schema.items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(schema.sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId:
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(sections.id, sectionIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
)
.run();
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
)
.run();
}
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id))
.run();
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
.update(sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId:
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id))
.run();
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
)
.run();
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
});
}),
@@ -649,18 +874,42 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.delete(schema.boardUserPermissions)
.where(eq(boardUserPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.boardUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
});
}),
saveGroupBoardPermissions: protectedProcedure
@@ -668,18 +917,42 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.delete(schema.boardGroupPermissions)
.where(eq(boardGroupPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.boardGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
});
}),
importOldmarrConfig: permissionRequiredProcedure
@@ -692,6 +965,29 @@ export const boardRouter = createTRPCRouter({
}),
});
/**
* Get the home board id of the user with the given device type
* For an example of a user with deviceType = 'mobile' it would go through the following order:
* 1. user.mobileHomeBoardId
* 2. user.homeBoardId
* 3. serverSettings.mobileHomeBoardId
* 4. serverSettings.homeBoardId
* 5. show NOT_FOUND error
*/
const getHomeIdBoardAsync = async (
db: Database,
user: InferSelectModel<typeof users> | null,
deviceType: DeviceType,
) => {
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (user?.[settingKey] || user?.homeBoardId) {
return user[settingKey] ?? user.homeBoardId;
} else {
const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
}
};
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
const boards = await db.query.boards.findMany({
columns: {
@@ -0,0 +1,27 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
import { superRefineCertificateFile, validation } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
export const certificateRouter = createTRPCRouter({
addCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(
zfd.formData({
file: zfd.file().superRefine(superRefineCertificateFile),
}),
)
.mutation(async ({ input }) => {
const content = await input.file.text();
await addCustomRootCertificateAsync(input.file.name, content);
}),
removeCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ fileName: validation.certificates.validFileNameSchema }))
.mutation(async ({ input }) => {
await removeCustomRootCertificateAsync(input.fileName);
}),
});
@@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server";
import { objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
import { and, asc, createId, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import {
groupMembers,
groupPermissions,
@@ -130,6 +130,16 @@ export const integrationRouter = createTRPCRouter({
limit: input.limit,
});
}),
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: inArray(integrations.id, input),
columns: {
id: true,
kind: true,
},
});
}),
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
@@ -360,20 +370,45 @@ export const integrationRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(schema.integrationUserPermissions)
.where(eq(schema.integrationUserPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.integrationUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
});
}),
saveGroupIntegrationPermissions: protectedProcedure
@@ -381,20 +416,45 @@ export const integrationRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.delete(schema.integrationGroupPermissions)
.where(eq(schema.integrationGroupPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.integrationGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
});
}),
searchInIntegration: protectedProcedure
+1 -1
View File
@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
export const throwIfCredentialsDisabled = () => {
if (!env.AUTH_PROVIDERS.includes("credentials")) {
+10
View File
@@ -24,6 +24,7 @@ describe("all should return all apps", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(),
});
@@ -55,6 +56,7 @@ describe("all should return all apps", () => {
// Arrange
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: null,
});
@@ -72,6 +74,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
@@ -103,6 +106,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await db.insert(apps).values([
@@ -128,6 +132,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -145,6 +150,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
@@ -171,6 +177,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
@@ -199,6 +206,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
@@ -237,6 +245,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
@@ -261,6 +270,7 @@ describe("delete should delete an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-full-all"]),
});
+32 -29
View File
@@ -52,7 +52,7 @@ describe("getAllBoards should return all boards accessable to the current user",
test("without session it should return only public boards", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: null });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: null });
const user1 = await createRandomUserAsync(db);
const user2 = await createRandomUserAsync(db);
@@ -85,6 +85,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: {
user: {
id: defaultCreatorId,
@@ -124,7 +125,7 @@ describe("getAllBoards should return all boards accessable to the current user",
test("with session user beeing creator it should return all private boards of them", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const user1 = await createRandomUserAsync(db);
const user2 = await createRandomUserAsync(db);
@@ -168,6 +169,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -232,6 +234,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -290,7 +293,7 @@ describe("createBoard should create a new board", () => {
permissions: ["board-create"] satisfies GroupPermissionKey[],
},
};
const caller = boardRouter.createCaller({ db, session });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
await db.insert(users).values({
id: defaultCreatorId,
@@ -316,7 +319,7 @@ describe("createBoard should create a new board", () => {
test("should throw error when user has no board-create permission", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.createBoard({ name: "newBoard", columnCount: 12, isPublic: true });
@@ -330,7 +333,7 @@ describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -358,7 +361,7 @@ describe("rename board should rename board", () => {
test("should throw error when similar board name exists", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await db.insert(users).values({
id: defaultCreatorId,
@@ -385,7 +388,7 @@ describe("rename board should rename board", () => {
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
@@ -401,7 +404,7 @@ describe("changeBoardVisibility should change board visibility", () => {
async (visibility) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -436,7 +439,7 @@ describe("deleteBoard should delete board", () => {
test("should delete board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -463,7 +466,7 @@ describe("deleteBoard should delete board", () => {
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.deleteBoard({ id: "nonExistentBoardId" });
@@ -478,7 +481,7 @@ describe("getHomeBoard should return home board", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db
@@ -502,7 +505,7 @@ describe("getHomeBoard should return home board", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db.insert(serverSettings).values({
@@ -523,7 +526,7 @@ describe("getHomeBoard should return home board", () => {
test("should throw error when home board not configured in serverSettings", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await createFullBoardAsync(db, "home");
// Act
@@ -539,7 +542,7 @@ describe("getBoardByName should return board by name", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, name);
@@ -557,7 +560,7 @@ describe("getBoardByName should return board by name", () => {
it("should throw error when not present", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await createFullBoardAsync(db, "default");
// Act
@@ -573,7 +576,7 @@ describe("savePartialBoardSettings should save general settings", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const newPageTitle = "newPageTitle";
const newMetaTitle = "newMetaTitle";
@@ -633,7 +636,7 @@ describe("savePartialBoardSettings should save general settings", () => {
it("should throw error when board not found", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const actAsync = async () =>
await caller.savePartialBoardSettings({
@@ -652,7 +655,7 @@ describe("saveBoard should save full board", () => {
it("should remove section when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -689,7 +692,7 @@ describe("saveBoard should save full board", () => {
it("should remove item when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
@@ -744,7 +747,7 @@ describe("saveBoard should save full board", () => {
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const anotherIntegration = {
id: createId(),
kind: "adGuardHome",
@@ -814,7 +817,7 @@ describe("saveBoard should save full board", () => {
async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -867,7 +870,7 @@ describe("saveBoard should save full board", () => {
it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -931,7 +934,7 @@ describe("saveBoard should save full board", () => {
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const integration = {
id: createId(),
kind: "plex",
@@ -998,7 +1001,7 @@ describe("saveBoard should save full board", () => {
});
it("should update section when present in input", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
@@ -1056,7 +1059,7 @@ describe("saveBoard should save full board", () => {
it("should update item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
@@ -1112,7 +1115,7 @@ describe("saveBoard should save full board", () => {
});
it("should fail when board not found", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const actAsync = async () =>
await caller.saveBoard({
@@ -1128,7 +1131,7 @@ describe("getBoardPermissions should return board permissions", () => {
test("should return board permissions", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUserAsync(db);
@@ -1202,7 +1205,7 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUserAsync(db);
@@ -1244,7 +1247,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -52,6 +52,7 @@ describe("All procedures should only be accessible for users with admin permissi
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions("admin"),
});
@@ -68,6 +69,7 @@ describe("All procedures should only be accessible for users with admin permissi
);
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions(...groupPermissionsWithoutAdmin),
});
@@ -81,6 +83,7 @@ describe("All procedures should only be accessible for users with admin permissi
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: null,
});
+34 -34
View File
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import * as env from "@homarr/auth/env.mjs";
import * as env from "@homarr/auth/env";
import { createId, eq } from "@homarr/db";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
@@ -37,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (page, expectedCount) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
@@ -60,7 +60,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
@@ -81,7 +81,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("groups should contain id, name, email and image of members", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
await db.insert(users).values(user);
@@ -117,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (query, expectedCount, firstKey) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
@@ -140,7 +140,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getPaginated({});
@@ -154,7 +154,7 @@ describe("byId should return group by id including members and permissions", ()
test('should return group with id "1" with members and permissions', async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
const groupId = "1";
@@ -197,7 +197,7 @@ describe("byId should return group by id including members and permissions", ()
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: "2",
@@ -214,7 +214,7 @@ describe("byId should return group by id including members and permissions", ()
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getById({ id: "1" });
@@ -228,7 +228,7 @@ describe("create should create group in database", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const name = "a".repeat(64);
await db.insert(users).values(defaultSession.user);
@@ -252,7 +252,7 @@ describe("create should create group in database", () => {
test("with more than 64 characters name it should fail while validation", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const longName = "a".repeat(65);
// Act
@@ -273,7 +273,7 @@ describe("create should create group in database", () => {
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -290,7 +290,7 @@ describe("create should create group in database", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.createGroup({ name: "test" });
@@ -307,7 +307,7 @@ describe("update should update name with value that is no duplicate", () => {
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -340,7 +340,7 @@ describe("update should update name with value that is no duplicate", () => {
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -368,7 +368,7 @@ describe("update should update name with value that is no duplicate", () => {
test("with non existing id it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -389,7 +389,7 @@ describe("update should update name with value that is no duplicate", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -407,7 +407,7 @@ describe("savePermissions should save permissions for group", () => {
test("with existing group and permissions it should save permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
@@ -437,7 +437,7 @@ describe("savePermissions should save permissions for group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -458,7 +458,7 @@ describe("savePermissions should save permissions for group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -476,7 +476,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("with existing group and user it should transfer ownership", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const newUserId = createId();
@@ -513,7 +513,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -534,7 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -552,7 +552,7 @@ describe("deleteGroup should delete group", () => {
test("with existing group it should delete group", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -581,7 +581,7 @@ describe("deleteGroup should delete group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -601,7 +601,7 @@ describe("deleteGroup should delete group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -620,7 +620,7 @@ describe("addMember should add member to group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -658,7 +658,7 @@ describe("addMember should add member to group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -679,7 +679,7 @@ describe("addMember should add member to group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -697,7 +697,7 @@ describe("addMember should add member to group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -735,7 +735,7 @@ describe("removeMember should remove member from group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -776,7 +776,7 @@ describe("removeMember should remove member from group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -797,7 +797,7 @@ describe("removeMember should remove member from group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -815,7 +815,7 @@ describe("removeMember should remove member from group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -33,6 +33,7 @@ describe("all should return all integrations", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
@@ -63,6 +64,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -89,6 +91,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -100,6 +103,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -147,6 +151,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
@@ -172,6 +177,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
@@ -206,6 +212,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
@@ -249,6 +256,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
const input = {
@@ -272,6 +280,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -346,6 +355,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -364,6 +374,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
@@ -386,6 +397,7 @@ describe("delete should delete an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -419,6 +431,7 @@ describe("delete should delete an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
+6 -1
View File
@@ -24,7 +24,7 @@ vi.mock("@homarr/auth", async () => {
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env.mjs", () => {
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],
@@ -38,6 +38,7 @@ describe("all should return all existing invites without sensitive informations"
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -72,6 +73,7 @@ describe("all should return all existing invites without sensitive informations"
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -111,6 +113,7 @@ describe("create should create a new invite expiring on the specified date with
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
@@ -142,6 +145,7 @@ describe("delete should remove invite by id", () => {
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -179,6 +183,7 @@ describe("delete should remove invite by id", () => {
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -26,6 +26,7 @@ describe("getAll server settings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -44,6 +45,7 @@ describe("getAll server settings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -58,6 +60,7 @@ describe("saveSettings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
+9 -1
View File
@@ -28,7 +28,7 @@ vi.mock("@homarr/auth", async () => {
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env.mjs", () => {
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],
@@ -42,6 +42,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -65,6 +66,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -89,6 +91,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -109,6 +112,7 @@ describe("register should create a user with valid invitation", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -164,6 +168,7 @@ describe("register should create a user with valid invitation", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -206,6 +211,7 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -242,6 +248,7 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -277,6 +284,7 @@ describe("delete should delete user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -18,6 +18,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ error: "error" }));
@@ -37,6 +38,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ statusCode: 200 }));
+9 -3
View File
@@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, createId, eq, like } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions";
import type { SupportedAuthProvider } from "@homarr/definitions";
@@ -18,6 +18,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
@@ -209,6 +210,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
@@ -232,6 +234,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
@@ -373,8 +376,8 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeHomeBoardId: protectedProcedure
.input(convertIntersectionToZodObject(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))))
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(validation.user.changeHomeBoards.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
@@ -401,10 +404,13 @@ export const userRouter = createTRPCRouter({
});
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.userId), "view");
await ctx.db
.update(users)
.set({
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
})
.where(eq(users.id, input.userId));
}),
@@ -1,15 +1,15 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { HealthMonitoring } from "@homarr/integrations";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
getSystemHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -25,9 +25,8 @@ export const healthMonitoringRouter = createTRPCRouter({
}),
);
}),
subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
subscribeSystemHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
@@ -49,4 +48,26 @@ export const healthMonitoringRouter = createTRPCRouter({
};
});
}),
getClusterHealthStatus: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "proxmox"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "proxmox"))
.subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next(healthInfo);
});
unsubscribes.push(unsubscribe);
return () => {
unsubscribe();
};
});
}),
});
+2
View File
@@ -12,6 +12,7 @@ import type { OpenApiMeta } from "trpc-to-openapi";
import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -39,6 +40,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
return {
session,
deviceType: userAgent(opts.headers).device.type,
db,
};
};
+1 -1
View File
@@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
import { createAdapter } from "./adapter";
import { createSessionCallback } from "./callbacks";
import { env } from "./env.mjs";
import { env } from "./env";
import { createSignInEventHandler } from "./events";
import { createCredentialsConfiguration, createLdapConfiguration } from "./providers/credentials/credentials-provider";
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
+5 -40
View File
@@ -1,10 +1,9 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
import { supportedAuthProviders } from "@homarr/definitions";
const supportedAuthProviders = ["credentials", "oidc", "ldap"];
const authProvidersSchema = z
.string()
.min(1)
@@ -14,7 +13,7 @@ const authProvidersSchema = z
.toLowerCase()
.split(",")
.filter((provider) => {
if (supportedAuthProviders.includes(provider)) return true;
if (supportedAuthProviders.some((supportedProvider) => supportedProvider === provider)) return true;
else if (!provider)
console.log("One or more of the entries for AUTH_PROVIDER could not be parsed and/or returned null.");
else console.log(`The value entered for AUTH_PROVIDER "${provider}" is incorrect.`);
@@ -23,41 +22,7 @@ const authProvidersSchema = z
)
.default("credentials");
const createDurationSchema = (defaultValue) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1];
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
const booleanSchema = z
.string()
.default("false")
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
const skipValidation = Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION);
const skipValidation = shouldSkipEnvValidation();
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
export const env = createEnv({
@@ -71,7 +36,7 @@ export const env = createEnv({
AUTH_OIDC_CLIENT_ID: z.string().min(1),
AUTH_OIDC_CLIENT_SECRET: z.string().min(1),
AUTH_OIDC_CLIENT_NAME: z.string().min(1).default("OIDC"),
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
+1 -1
View File
@@ -8,7 +8,7 @@ import { groupMembers, groups, users } from "@homarr/db/schema";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { env } from "./env";
import { extractProfileName } from "./providers/oidc/oidc-provider";
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
+2 -2
View File
@@ -11,7 +11,7 @@
"./client": "./client.ts",
"./server": "./server.ts",
"./shared": "./shared.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -45,7 +45,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { SupportedAuthProvider } from "@homarr/definitions";
import { env } from "../env.mjs";
import { env } from "../env";
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
// The question mark is placed there because isProviderEnabled is called during static build of about page
@@ -7,7 +7,7 @@ import { logger } from "@homarr/log";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { env } from "../../../env.mjs";
import { env } from "../../../env";
import { LdapClient } from "../ldap-client";
export const authorizeWithLdapCredentialsAsync = async (
@@ -3,7 +3,7 @@ import { Client } from "ldapts";
import { objectEntries } from "@homarr/common";
import { env } from "../../env.mjs";
import { env } from "../../env";
export interface BindOptions {
distinguishedName: string;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Provider } from "next-auth/providers";
import { env } from "../env.mjs";
import { env } from "../env";
export const filterProviders = (providers: Exclude<Provider, () => unknown>[]) => {
// During build this will be undefined, so we default to an empty array
@@ -2,7 +2,7 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapte
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { env } from "../../env.mjs";
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
@@ -9,7 +9,7 @@ import { createDb } from "@homarr/db/test";
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
import * as ldapClient from "../credentials/ldap-client";
vi.mock("../../env.mjs", () => ({
vi.mock("../../env", () => ({
env: {
AUTH_LDAP_BIND_DN: "bind_dn",
AUTH_LDAP_BIND_PASSWORD: "bind_password",
+1 -1
View File
@@ -11,7 +11,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("../env.mjs", () => {
vi.mock("../env", () => {
return {
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
+9
View File
@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@homarr/certificates",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
"./server": "./src/server.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"undici": "7.2.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+87
View File
@@ -0,0 +1,87 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import { Agent } from "node:https";
import path from "node:path";
import { rootCertificates } from "node:tls";
import axios from "axios";
import { fetch } from "undici";
import { LoggingAgent } from "@homarr/common/server";
const getCertificateFolder = () => {
return process.env.NODE_ENV === "production"
? path.join("/appdata", "trusted-certificates")
: process.env.LOCAL_CERTIFICATE_PATH;
};
export const loadCustomRootCertificatesAsync = async () => {
const folder = getCertificateFolder();
if (!folder) {
return [];
}
if (!fsSync.existsSync(folder)) {
await fs.mkdir(folder, { recursive: true });
}
const dirContent = await fs.readdir(folder);
return await Promise.all(
dirContent
.filter((file) => file.endsWith(".crt"))
.map(async (file) => ({
content: await fs.readFile(path.join(folder, file), "utf8"),
fileName: file,
})),
);
};
export const removeCustomRootCertificateAsync = async (fileName: string) => {
const folder = getCertificateFolder();
if (!folder) {
return;
}
await fs.rm(path.join(folder, fileName));
};
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
const folder = getCertificateFolder();
if (!folder) {
throw new Error(
"When you want to use custom certificates locally you need to set LOCAL_CERTIFICATE_PATH to an absolute path",
);
}
if (fileName.includes("/")) {
throw new Error("Invalid file name");
}
await fs.writeFile(path.join(folder, fileName), content);
};
export const createCertificateAgentAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return new LoggingAgent({
connect: {
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
},
});
};
export const createAxiosCertificateInstanceAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return axios.create({
httpsAgent: new Agent({
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
}),
});
};
export const fetchWithTrustedCertificatesAsync: typeof fetch = async (url, options) => {
const agent = await createCertificateAgentAsync();
return fetch(url, {
...options,
dispatcher: agent,
});
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
@@ -2,6 +2,8 @@ import { randomBytes } from "crypto";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "./src/env-validation";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
export const env = createEnv({
@@ -23,6 +25,5 @@ export const env = createEnv({
runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});
+6 -3
View File
@@ -9,7 +9,8 @@
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./client": "./src/client.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts",
"./env-validation": "./src/env-validation.ts"
},
"typesVersions": {
"*": {
@@ -30,13 +31,15 @@
"dayjs": "^1.11.13",
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"undici": "7.2.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import crypto from "crypto";
import { env } from "../env.mjs";
import { env } from "../env";
const algorithm = "aes-256-cbc"; //Using AES encryption
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
export const createBooleanSchema = (defaultValue: boolean) =>
z
.string()
.default(defaultValue.toString())
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
export const createDurationSchema = (defaultValue: `${number}${"s" | "m" | "h" | "d"}`) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1] as "s" | "m" | "h" | "d";
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
export const shouldSkipEnvValidation = () =>
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint";
+32
View File
@@ -0,0 +1,32 @@
import type { Dispatcher } from "undici";
import { Agent } from "undici";
import { logger } from "@homarr/log";
export class LoggingAgent extends Agent {
constructor(...props: ConstructorParameters<typeof Agent>) {
super(...props);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const url = new URL(`${options.origin as string}${options.path}`);
// The below code should prevent sensitive data from being logged as
// some integrations use query parameters for auth
url.searchParams.forEach((value, key) => {
if (value === "") return; // Skip empty values
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
if (value === "true" || value === "false") return; // Skip boolean values
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
url.searchParams.set(key, "REDACTED");
});
logger.info(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
);
return super.dispatch(options, handler);
}
}
+2
View File
@@ -1,2 +1,4 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";
export * from "./fetch-agent";
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import { logger } from "@homarr/log";
import { LoggingAgent } from "~/undici-log-agent-override";
import { LoggingAgent } from "../fetch-agent";
vi.mock("undici", () => {
return {
+11
View File
@@ -0,0 +1,11 @@
import { userAgent as userAgentNextServer } from "next/server";
import type { Modify } from "./types";
export const userAgent = (headers: Headers) => {
return userAgentNextServer({ headers }) as Omit<ReturnType<typeof userAgentNextServer>, "device"> & {
device: Modify<ReturnType<typeof userAgentNextServer>["device"], { type: DeviceType }>;
};
};
export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined;
+1 -1
View File
@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -45,7 +45,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+73 -35
View File
@@ -1,7 +1,7 @@
import { splitToNChunks, Stopwatch } from "@homarr/common";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db";
import { db, inArray, sql } from "@homarr/db";
import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
import { createId } from "@homarr/db/client";
import { iconRepositories, icons } from "@homarr/db/schema";
import { fetchIconsAsync } from "@homarr/icons";
@@ -83,43 +83,81 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
);
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
await handleTransactionsAsync(db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (newIconRepositories.length >= 1) {
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
await transaction.insert(schema.icons).values(chunck);
}
}
if (deadIcons.length >= 1) {
await transaction.delete(schema.icons).where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
);
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
await transaction.delete(schema.iconRepositories).where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
);
}
countDeleted += deadIcons.length;
countDeleted += deadIcons.length;
});
},
handleSync() {
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
countDeleted += deadIcons.length;
});
},
});
logger.info(`Updated database within ${stopWatch.getElapsedInHumanWords()} (-${countDeleted}, +${countInserted})`);
@@ -1,14 +1,23 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: () => ({}),
createRequestIntegrationJobHandler(
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind !== "proxmox") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
},
}),
{
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: () => ({}),
},
},
),
);
@@ -1,4 +1,4 @@
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { NEVER } from "@homarr/cron-jobs-core/expressions";
import { db, eq, inArray } from "@homarr/db";
import { sessions, users } from "@homarr/db/schema";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import { env } from "../env.mjs";
import { env } from "../env";
export default {
dialect: "mysql",
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import { env } from "../env.mjs";
import { env } from "../env";
export default {
dialect: "sqlite",
+4 -2
View File
@@ -2,16 +2,18 @@ import Database from "better-sqlite3";
import type { Logger } from "drizzle-orm";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import type { MySql2Database } from "drizzle-orm/mysql2";
import { drizzle as drizzleMysql } from "drizzle-orm/mysql2";
import mysql from "mysql2";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
import * as sqliteSchema from "./schema/sqlite";
type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>;
export type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>;
export type HomarrDatabaseMysql = MySql2Database<typeof mysqlSchema>;
const init = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+5 -4
View File
@@ -1,12 +1,14 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
const drivers = {
betterSqlite3: "better-sqlite3",
mysql2: "mysql2",
};
} as const;
const isDriver = (driver) => process.env.DB_DRIVER === driver;
const isDriver = (driver: (typeof drivers)[keyof typeof drivers]) => process.env.DB_DRIVER === driver;
const isUsingDbHost = Boolean(process.env.DB_HOST);
const onlyAllowUrl = isDriver(drivers.betterSqlite3);
const urlRequired = onlyAllowUrl || !isUsingDbHost;
@@ -55,6 +57,5 @@ export const env = createEnv({
DB_NAME: process.env.DB_NAME,
DB_PORT: process.env.DB_PORT,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});
+2
View File
@@ -7,5 +7,7 @@ export * from "drizzle-orm";
export const db = database;
export type Database = typeof db;
export type { HomarrDatabaseMysql } from "./driver";
export { createId } from "@paralleldrive/cuid2";
export { handleDiffrentDbDriverOperationsAsync as handleTransactionsAsync } from "./transactions";
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `mobile_home_board_id` varchar(64);--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -141,6 +141,13 @@
"when": 1735651231818,
"tag": "0019_crazy_marvel_zombies",
"breakpoints": true
},
{
"idx": 20,
"version": "5",
"when": 1736514409126,
"tag": "0020_salty_doorman",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
import type { Database } from "../..";
import { env } from "../../env.mjs";
import { env } from "../../env";
import * as mysqlSchema from "../../schema/mysql";
import { seedDataAsync } from "../seed";
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `mobile_home_board_id` text REFERENCES board(id);
File diff suppressed because it is too large. Load diff
@@ -141,6 +141,13 @@
"when": 1735651175378,
"tag": "0019_steady_darkhawk",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1736510755691,
"tag": "0020_empty_hellfire_club",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { env } from "../../env.mjs";
import { env } from "../../env";
import * as sqliteSchema from "../../schema/sqlite";
import { seedDataAsync } from "../seed";
+5 -5
View File
@@ -11,7 +11,7 @@
"./test": "./test/index.ts",
"./queries": "./queries/index.ts",
"./validationSchemas": "./validationSchemas.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -45,10 +45,10 @@
"@paralleldrive/cuid2": "^2.2.2",
"@t3-oss/env-nextjs": "^0.11.1",
"@testcontainers/mysql": "^10.16.0",
"better-sqlite3": "^11.7.2",
"better-sqlite3": "^11.8.0",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.1",
"drizzle-orm": "^0.38.3",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.4",
"drizzle-zod": "^0.6.1",
"mysql2": "3.12.0"
},
@@ -58,7 +58,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^8.0.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.3"
+3
View File
@@ -62,6 +62,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
onDelete: "set null",
}),
+3
View File
@@ -45,6 +45,9 @@ export const users = sqliteTable("user", {
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: text().references(() => searchEngines.id, {
onDelete: "set null",
}),
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more