Compare commits

..
132 Commits
Author SHA1 Message Date
Releases Homarr cb54de524b chore(release): 1.1.0 [skip ci] 2025-01-22 19:46:18 +00:00
homarr-releases[bot] 7d8c7b9c2d chore(release): automatic release v1.1.0 2025-01-22 19:45:14 +00:00
Meier Lukas 73f7d885cd fix: docker hosts and ports env variable wrongly used (#2050)
* fix: docker hosts and ports env variable wrongly used

* fix: ci issues
2025-01-22 20:43:54 +01:00
Meier Lukas 470d27e091 fix: hide current language switch in other users preferences (#2069) 2025-01-22 20:06:40 +01:00
Meier Lukas 99c8e5b609 fix: add missing request handler for rss feeds (#2006)
* fix: add missing request handler for rss feeds

* fix: ci issues
2025-01-22 18:23:49 +01:00
Meier Lukas b413e2ec7d fix: change error when section for item not found to logged warning and remove item (#2044) 2025-01-22 18:18:14 +01:00
Meier Lukas 29d4f9e003 fix: entity state and execute automation cards aren't movable (#2043)
* fix: entity state and execute automation cards aren't movable

* fix: disable eslint issue
2025-01-22 18:17:15 +01:00
homarr-renovate[bot] 1f54bf5b9e fix(deps): update tiptap monorepo to v2.11.3 (#2067)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-22 16:39:48 +00:00
homarr-renovate[bot] 4d8c22a4e6 fix(deps): update nextjs monorepo to ^15.1.6 (#2063)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-22 11:16:22 +00:00
homarr-renovate[bot] 3e9e1e5e4f fix(deps): update dependency undici to v7.3.0 (#2061)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-22 10:35:59 +00:00
homarr-renovate[bot] 582b981e69 chore(deps): update dependency proxmox-api&gt;undici to v7.3.0 (#2060)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-22 10:15:15 +00:00
homarr-crowdin[bot] f8ec4a43cf chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-22 00:44:52 +00:00
homarr-renovate[bot] 2a8809551c fix(deps): update dependency @tabler/icons-react to ^3.29.0 (#2057)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-21 21:40:00 +00:00
homarr-renovate[bot] 9df59296ff chore(deps): update node.js to v22.13.1 (#2053)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-21 17:16:47 +00:00
homarr-renovate[bot] 701538fcb5 fix(deps): update testcontainers-node monorepo to ^10.17.1 (#2047)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-21 14:47:05 +00:00
homarr-renovate[bot] 0e82f2d199 chore(deps): update dependency testcontainers to ^10.17.0 (#2046)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-21 14:36:28 +00:00
homarr-renovate[bot] a2b5d60aa8 chore(deps): update vitest monorepo to ^3.0.3 (#2045)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-21 14:16:33 +00:00
Yossi Hillali 02dabfcd01 feat: add dropdown list for switching boards (#2042) 2025-01-21 14:34:41 +01:00
Meier Lukas 931f312291 fix: unable to move items with touch devices (#2038) 2025-01-21 13:54:44 +01:00
Meier Lukas 175927970b fix: empty env variables should be ignored (#2040)
* fix: empty env variables should be ignored

* fix: e2e test fails because of empty env variable values
2025-01-21 13:09:49 +01:00
Meier Lukas c922c5b083 fix: certificate card delete button not reachable caused by overflow (#2037) 2025-01-21 11:03:48 +01:00
Meier Lukas 73bbcfeee8 fix: url of batch link can get to long with 414 http code (#2036) 2025-01-21 11:03:16 +01:00
Meier Lukas 7a2d97ba91 fix: copy only working for https and localhost, adding open in new tab (#2035) 2025-01-21 11:03:03 +01:00
Meier Lukas e47d497560 chore: add stack and cause to trpc error logging (#2033) 2025-01-21 11:02:33 +01:00
Meier Lukas b9271ba14a chore: make error messages for auth errors more detailed (#2041) 2025-01-21 11:02:21 +01:00
Meier Lukas 9d85677046 feat: add support for calendar without integrations, add today mark (#2016)
* feat: add support for calendar without integrations, add today mark

* fix: ci issues
2025-01-21 11:01:52 +01:00
Meier Lukas b30c192b8f feat: add initial focus on new integration search (#2015) 2025-01-21 11:00:56 +01:00
Meier Lukas d4f04da709 fix: add item does not respect dynamic sections (#2010)
* bug: add item does not respect dynamic sections

* fix: deepsource issue
2025-01-21 11:00:29 +01:00
Meier Lukas 7960a4cea1 fix: unable to change home board in user preferences (#2011) 2025-01-21 10:59:04 +01:00
homarr-crowdin[bot] 243d7e9dd4 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-21 09:23:53 +00:00
Yossi Hillali bdefff636b fix: health monitoring cpu temp ring when no data, disks health when no data (#2029) 2025-01-20 21:43:18 +01:00
homarr-renovate[bot] 55feb9dff8 fix(deps): update dependency typescript-eslint to ^8.21.0 (#2032)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-20 17:33:27 +00:00
homarr-renovate[bot] 45af6442a1 fix(deps): update dependency jotai to ^2.11.1 (#2026)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-20 14:15:54 +00:00
homarr-crowdin[bot] 72debef4b4 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-20 05:32:12 +00:00
Meier Lukas 25635db17e chore: update homarr-docs-sitemap 2025-01-19 10:56:20 +01:00
homarr-renovate[bot] 1897752e38 fix(deps): update mantine monorepo to ^7.16.1 (#2014)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-19 09:53:43 +00:00
homarr-renovate[bot] 554853ffbd fix(deps): update tanstack-query monorepo to ^5.64.2 (#2013)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-19 08:53:10 +00:00
homarr-crowdin[bot] e40237e4a5 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-19 00:47:05 +00:00
SeDemalandMeier Lukas fa6262cf5e fix: User options switch on load value always false (#1995)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-01-18 08:48:07 +00:00
homarr-renovate[bot] 49bd02e869 fix(deps): update dependency better-sqlite3 to ^11.8.1 (#2005)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-18 06:52:55 +00:00
homarr-crowdin[bot] 3c8fbe371b chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-18 00:42:34 +00:00
Releases Homarr 81a8cf4b36 chore(release): 1.0.1 [skip ci] 2025-01-17 19:14:12 +00:00
homarr-releases[bot] b5c008b4ad chore(release): automatic release v1.0.1 2025-01-17 19:13:04 +00:00
Toine Rademacher (toineenzo)andMeier Lukas 343438391b chore: Fixed broken links in README.md (#1985)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-01-17 17:39:40 +00:00
Toine Rademacher (toineenzo) 8400b9fb11 chore: Change icon and issue link to new repo in layout.tsx and index.tsx (#1986) 2025-01-17 17:35:20 +00:00
Meier Lukas fe65e73fff chore: add version 1.0.0 to bug issue template (#1983) 2025-01-17 18:23:58 +01:00
Meier Lukas e023edf9d6 fix: add missing old-import proxmox integration kind mapping (#1979) 2025-01-17 16:12:02 +01:00
Meier Lukas 927913cf4e fix: add error from ldap bind operation to log statement (#1980) 2025-01-17 16:11:50 +01:00
Meier Lukas 07d8103051 chore: update docs sitemap (#1982) 2025-01-17 16:11:29 +01:00
homarr-renovate[bot] fe08ccf589 chore(deps): update vitest monorepo to v3 (major) (#1964)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 16:10:59 +01:00
homarr-renovate[bot] c55207bd59 fix(deps): update dependency flag-icons to ^7.3.2 (#1981)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 15:09:46 +00:00
homarr-renovate[bot] e35811c921 fix(deps): update nextjs monorepo to ^15.1.5 (#1978)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 15:09:21 +00:00
Meier Lukas e481e116ff Merge branch 'main' into dev 2025-01-17 13:52:59 +01:00
Meier Lukas 32be09a4a7 docs: remove beta banner from readme 2025-01-17 13:51:34 +01:00
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
248 changed files with 15499 additions and 4204 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
+2 -1
View File
@@ -32,6 +32,7 @@ body:
description: What version of Homarr are you running?
options:
- 1.0.0-beta
- 1.0.0
- Other (describe in "additional information")
default: 0
validations:
@@ -75,4 +76,4 @@ body:
- Other (describe in "additional information")
default: 0
validations:
required: false
required: false
+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
+1 -1
View File
@@ -1 +1 @@
22.13.0
22.13.1
+1232
View File
File diff suppressed because it is too large. Load diff
+5 -4
View File
@@ -1,13 +1,14 @@
// 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 "@homarr/docker/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");
+22 -19
View File
@@ -18,12 +18,15 @@
"@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",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.11.3",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
@@ -38,18 +41,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.1",
"@mantine/core": "^7.16.1",
"@mantine/dropzone": "^7.16.1",
"@mantine/hooks": "^7.16.1",
"@mantine/modals": "^7.16.1",
"@mantine/tiptap": "^7.16.1",
"@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",
"@tabler/icons-react": "^3.29.0",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2",
"@tanstack/react-query-next-experimental": "5.64.2",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -61,18 +64,18 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"flag-icons": "^7.2.3",
"flag-icons": "^7.3.2",
"glob": "^11.0.1",
"jotai": "^2.11.0",
"jotai": "^2.11.1",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.4",
"next": "15.1.6",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "19.0.0",
"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 +85,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
@@ -94,6 +94,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: unstable_httpBatchStreamLink({
transformer: superjson,
url: getTrpcUrl(),
maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
headers: createHeadersCallbackForSource("nextjs-react (json)"),
}),
}),
@@ -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";
@@ -2,6 +2,7 @@
import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
@@ -9,9 +10,11 @@ import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconPlus,
IconReplace,
IconResize,
IconSettings,
} from "@tabler/icons-react";
@@ -28,7 +31,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 = () => {
@@ -49,6 +52,8 @@ export const BoardContentHeaderActions = () => {
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
<SelectBoardsMenu />
</>
);
};
@@ -151,6 +156,32 @@ const EditModeMenu = () => {
);
};
const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
};
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
@@ -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>;
@@ -15,7 +15,9 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
return integrationKinds.filter((kind) =>
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
);
}, [search]);
const handleSearch = React.useCallback(
@@ -29,6 +31,7 @@ export const IntegrationCreateDropdownContent = () => {
leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")}
value={search}
data-autofocus
onChange={handleSearch}
/>
@@ -102,7 +102,15 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
return (
<Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
<Menu
width={256}
trapFocus
position="bottom-end"
withinPortal
shadow="md"
keepMounted={false}
withInitialFocusPlaceholder={false}
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent />
@@ -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,
@@ -146,7 +153,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.submitIssue"),
icon: IconBrandGithub,
href: "https://github.com/ajnart/homarr/issues/new/choose",
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
external: true,
},
{
@@ -158,7 +165,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.sourceCode"),
icon: IconGitFork,
href: "https://github.com/ajnart/homarr",
href: "https://github.com/homarr-labs/homarr",
external: true,
},
],
@@ -15,6 +15,11 @@ export const CopyMedia = ({ media }: CopyMediaProps) => {
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
// Clipboard only works on localhost or secure connections (https)
if (url.startsWith("http://") && !url.startsWith("http://localhost")) {
return null;
}
return (
<CopyButton value={url}>
{({ copy, copied }) => (
@@ -1,13 +1,28 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import {
ActionIcon,
Anchor,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
Tooltip,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -91,13 +106,14 @@ interface RowProps {
const Row = async ({ media }: RowProps) => {
const session = await auth();
const t = await getI18n();
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return (
<TableTr>
<TableTd w={64}>
<Image
src={`/api/user-medias/${media.id}`}
src={createLocalImageUrl(media.id)}
alt={media.name}
width={64}
height={64}
@@ -121,6 +137,17 @@ const Row = async ({ media }: RowProps) => {
<TableTd w={64}>
<Group wrap="nowrap" gap="xs">
<CopyMedia media={media} />
<Tooltip label={t("media.action.open.label")} openDelay={500}>
<ActionIcon
component="a"
href={createLocalImageUrl(media.id)}
target="_blank"
color="gray"
variant="subtle"
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
{canDelete && <DeleteMedia media={media} />}
</Group>
</TableTd>
@@ -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,104 @@
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)}
style={{ minWidth: 32 }}
size={32}
stroke={1.5}
/>
<Stack flex={1} gap="xs" maw="calc(100% - 48px)">
<Group justify="space-between" wrap="nowrap">
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{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";
};
@@ -16,7 +16,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import type { ContainerState } from "@homarr/docker";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -246,9 +246,9 @@ const containerStates = {
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<DockerContainerState, MantineColor>;
} satisfies Record<ContainerState, MantineColor>;
const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
@@ -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>;
@@ -51,7 +51,10 @@ export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
<Switch
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
label={t("user.field.pingIconsEnabled.label")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
@@ -83,13 +83,15 @@ export default async function EditUserPage(props: Props) {
</Box>
</Group>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</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 {
+3 -1
View File
@@ -30,7 +30,9 @@ const handler = auth(async (req) => {
req,
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
onError({ error, path, type }) {
logger.error(`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}`);
logger.error(
`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}\n${error.stack}\n${error.cause}`,
);
},
});
@@ -0,0 +1,62 @@
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
export interface CreateItemInput {
kind: WidgetKind;
}
export const createItemCallback =
({ kind }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);
if (!emptyPosition) {
console.error("Your board is full");
return previous;
}
const widget = {
id: createId(),
kind,
options: {},
width: 1,
height: 1,
...emptyPosition,
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
} satisfies Modify<
Item,
{
kind: WidgetKind;
}
>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== firstSection.id) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
};
@@ -0,0 +1,81 @@
import { createId } from "@homarr/db/client";
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
export interface DuplicateItemInput {
itemId: string;
}
export const duplicateItemCallback =
({ itemId }: DuplicateItemInput) =>
(previous: Board): Board => {
const itemToDuplicate = previous.sections
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
if (!currentSection) return previous;
const dynamicSectionsOfCurrentSection = previous.sections.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === currentSection.id,
);
const elements = [...currentSection.items, ...dynamicSectionsOfCurrentSection];
let sectionId = currentSection.id;
let emptyPosition = getFirstEmptyPosition(
elements,
currentSection.kind === "dynamic" ? currentSection.width : previous.columnCount,
currentSection.kind === "dynamic" ? currentSection.height : undefined,
{
width: itemToDuplicate.width,
height: itemToDuplicate.height,
},
);
if (!emptyPosition) {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
emptyPosition = getFirstEmptyPosition(elements, previous.columnCount, undefined, {
width: itemToDuplicate.width,
height: itemToDuplicate.height,
});
if (!emptyPosition) {
console.error("Your board is full");
return previous;
}
sectionId = firstSection.id;
}
const widget = structuredClone(itemToDuplicate);
widget.id = createId();
widget.xOffset = emptyPosition.xOffset;
widget.yOffset = emptyPosition.yOffset;
widget.sectionId = sectionId;
const result = {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== sectionId) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
return result;
};
@@ -0,0 +1,25 @@
import type { Item } from "~/app/[locale]/boards/_types";
export const getFirstEmptyPosition = (
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
columnCount: number,
rowCount = 9999,
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
element.yOffset + element.height > yOffset &&
element.xOffset < xOffset + size.width &&
element.xOffset + element.width > xOffset,
);
if (!isOccupied) {
return { xOffset, yOffset };
}
}
}
return undefined;
};
@@ -0,0 +1,61 @@
import { describe, expect, test, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { createItemCallback } from "../create-item";
import * as emptyPosition from "../empty-position";
import { createDynamicSection, createEmptySection, createItem } from "./shared";
describe("item actions create-item", () => {
test("should add it to first section", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const input = {
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
columnCount: 4,
} satisfies Pick<Board, "sections" | "columnCount">;
const result = createItemCallback({
kind: "clock",
})(input as unknown as Board);
const firstSection = result.sections.find((section) => section.id === "2");
const item = firstSection?.items.at(0);
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([], input.columnCount);
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const firstSection = createEmptySection("2", 0);
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
firstSection.items.push(expectedItem);
const dynamicSectionInFirst = createDynamicSection({
id: "4",
parentSectionId: "2",
yOffset: 0,
xOffset: 0,
width: 2,
height: 2,
});
const input = {
sections: [
createEmptySection("1", 2),
firstSection,
createEmptySection("3", 1),
dynamicSectionInFirst,
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
],
columnCount: 4,
} satisfies Pick<Board, "sections" | "columnCount">;
const result = createItemCallback({
kind: "clock",
})(input as unknown as Board);
const firstSectionResult = result.sections.find((section) => section.id === "2");
const item = firstSectionResult?.items.find((item) => item.id !== "12");
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
});
});
@@ -0,0 +1,52 @@
import { describe, expect, test, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { duplicateItemCallback } from "../duplicate-item";
import * as emptyPosition from "../empty-position";
import { createEmptySection, createItem } from "./shared";
describe("item actions duplicate-item", () => {
test("should copy it in the same section", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const currentSection = createEmptySection("2", 1);
const currentItem = createItem({
id: "1",
xOffset: 1,
yOffset: 3,
width: 3,
height: 2,
kind: "minecraftServerStatus",
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
});
const otherItem = createItem({
id: "2",
});
currentSection.items.push(currentItem, otherItem);
const input = {
columnCount: 10,
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
} satisfies Pick<Board, "sections" | "columnCount">;
const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
const section = result.sections.find((section) => section.id === "2");
expect(section?.items.length).toBe(3);
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
expect(duplicatedItem).toEqual(
expect.objectContaining({
kind: "minecraftServerStatus",
xOffset: 5,
yOffset: 5,
width: 3,
height: 2,
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
}),
);
});
});
@@ -0,0 +1,138 @@
import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "../empty-position";
describe("get first empty position", () => {
test.each([
[[[" ", " ", " ", " "]], [1, 1], 0, 0],
[[["a", " ", " ", " "]], [1, 1], 1, 0],
[[[" ", "a", " ", " "]], [1, 1], 0, 0],
[
[
["a", "a", " ", " "],
["a", "a", " ", " "],
],
[1, 1],
2,
0,
],
[[["a", "a", "a", "a"]], [1, 1], 0, 1],
[
[
["a", "a", "a", "a"],
["a", "a", "a", "a"],
],
[1, 1],
0,
2,
],
[
[
["a", "a", " ", "b", "b"],
["a", "a", " ", "b", "b"],
],
[1, 2],
2,
0,
],
[
[
["a", "a", " ", " ", "b", "b"],
["a", "a", " ", " ", "b", "b"],
],
[2, 2],
2,
0,
],
[
[
["a", "a", " ", "d", "b", "b"],
["a", "a", "c", "e", "b", "b"],
],
[1, 1],
2,
0,
],
[
[
["a", "a", " ", " ", "b", "b"],
["a", "a", " ", "e", "b", "b"],
],
[2, 2],
0,
2,
],
])("should return the first empty position", (layout, size, expectedX, expectedY) => {
const elements = createElementsFromLayout(layout);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = getFirstEmptyPosition(elements, layout[0]!.length, undefined, { width: size[0]!, height: size[1]! });
expect(result).toEqual({ xOffset: expectedX, yOffset: expectedY });
});
test.each([
[[[" ", " "]], [1, 1], 0, 0, 1],
[[["a", " "]], [1, 1], 1, 0, 1],
[[["a", "a"]], [1, 1], undefined, undefined, 1],
[[["a", "a"]], [1, 1], 0, 1, 2],
[
[
["a", "b", " ", " "],
["a", "c", " ", "d"],
],
[2, 2],
undefined,
undefined,
3,
],
[
[
["a", "b", " ", " "],
["a", "c", " ", "d"],
],
[2, 2],
0,
2,
4,
],
[[["a", "b"]], [2, 1], 0, 1, 2],
])("should return the first empty position with limited rows", (layout, size, expectedX, expectedY, rowCount) => {
const elements = createElementsFromLayout(layout);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = getFirstEmptyPosition(elements, layout[0]!.length, rowCount, { width: size[0]!, height: size[1]! });
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
});
const createElementsFromLayout = (layout: string[][]) => {
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
const row = layout[yOffset];
if (!row) continue;
for (let xOffset = 0; xOffset < row.length; xOffset++) {
const item = row[xOffset];
if (item === " " || !item) continue;
const existing = elements.find((element) => element.char === item);
if (existing) {
existing.height = yOffset - existing.yOffset + 1;
existing.width = xOffset - existing.xOffset + 1;
continue;
}
elements.push({
yOffset,
xOffset,
width: 1,
height: 1,
char: item,
});
}
}
return elements;
};
@@ -0,0 +1,32 @@
import type { DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
export const createEmptySection = (id: string, yOffset: number): EmptySection => ({
id,
kind: "empty",
yOffset,
xOffset: 0,
items: [],
});
export const createDynamicSection = (section: Omit<Partial<DynamicSection>, "kind">): DynamicSection => ({
id: section.id ?? "0",
kind: "dynamic",
parentSectionId: section.parentSectionId ?? "0",
height: section.height ?? 1,
width: section.width ?? 1,
yOffset: section.yOffset ?? 0,
xOffset: section.xOffset ?? 0,
items: section.items ?? [],
});
export const createItem = (item: Partial<Item>): Item => ({
id: item.id ?? "0",
width: item.width ?? 1,
height: item.height ?? 1,
yOffset: item.yOffset ?? 0,
xOffset: item.xOffset ?? 0,
kind: item.kind ?? "clock",
integrationIds: item.integrationIds ?? [],
options: item.options ?? {},
advancedOptions: item.advancedOptions ?? { customCssClasses: [] },
});
@@ -1,12 +1,13 @@
import { useCallback } from "react";
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
import type { Item } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
import { duplicateItemCallback } from "./actions/duplicate-item";
interface MoveAndResizeItem {
itemId: string;
@@ -42,87 +43,19 @@ interface UpdateItemIntegrations {
newIntegrations: string[];
}
interface CreateItem {
kind: WidgetKind;
}
interface DuplicateItem {
itemId: string;
}
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
const createItem = useCallback(
({ kind }: CreateItem) => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
if (!lastSection) return previous;
const widget = {
id: createId(),
kind,
options: {},
width: 1,
height: 1,
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
} satisfies Modify<
Omit<Item, "yOffset" | "xOffset">,
{
kind: WidgetKind;
}
>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== lastSection.id) return section;
return {
...section,
items: section.items.concat(widget as unknown as Item),
};
}),
};
});
(input: CreateItemInput) => {
updateBoard(createItemCallback(input));
},
[updateBoard],
);
const duplicateItem = useCallback(
({ itemId }: DuplicateItem) => {
updateBoard((previous) => {
const itemToDuplicate = previous.sections
.flatMap((section) => section.items)
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const newItem = {
...itemToDuplicate,
id: createId(),
yOffset: undefined,
xOffset: undefined,
} satisfies Modify<Item, { yOffset?: number; xOffset?: number }>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (!section.items.some((item) => item.id === itemId)) return section;
return {
...section,
items: section.items.concat(newItem as unknown as Item),
};
}),
};
});
({ itemId }: DuplicateItemInput) => {
updateBoard(duplicateItemCallback({ itemId }));
},
[updateBoard],
);
@@ -79,7 +79,12 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
>
<Throw
error={new NoIntegrationSelectedError()}
when={widgetSupportsIntegrations && item.integrationIds.length === 0}
when={
widgetSupportsIntegrations &&
item.integrationIds.length === 0 &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
<BoardItemMenu offset={4} item={newItem} />
<Comp
@@ -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),
@@ -10,11 +12,7 @@ export const env = createEnv({
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
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(),
},
server: {},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
@@ -28,10 +26,8 @@ export const env = createEnv({
runtimeEnv: {
PORT: process.env.PORT,
NODE_ENV: process.env.NODE_ENV,
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
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(),
emptyStringAsUndefined: true,
});
+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.3.0"
},
"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"
}
+3 -10
View File
@@ -24,10 +24,10 @@
<strong>Demo ✨</strong>
</a>
•
<a href="https://homarr.dev/docs/getting-started/installation/">
<a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong>
</a> •
<a href="https://translate.homarr.dev/">
<a href="https://crowdin.com/project/homarr_labs">
<strong>Translations 🈺</strong>
</a> •
<a href="https://discord.com/invite/aCsmEV5RgA">
@@ -35,13 +35,6 @@
</a>
</p>
> [!WARNING]
> This is a beta version of Homarr. You can download the old but stable version at https://github.com/ajnart/homarr/. Running this version is at your own risk.
Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.
<br/>
<br/>
![Features Section](./section-features.png)
@@ -100,7 +93,7 @@ Homarr has a [built-in collection of widgets and integrations](https://homarr.de
Since we are updating Homarr very frequently, we recommend reading our official installation guides:
<h2>
<a href="https://homarr.dev/docs/getting-started/installation/">
<a href="https://homarr.dev/docs/category/installation-1/">
Please click here for official installation instructions
</a>
</h2>
+3 -3
View File
@@ -49,9 +49,9 @@ describe("Onboarding", () => {
environment: {
AUTH_PROVIDERS: "ldap",
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
AUTH_LDAP_BASE: "",
AUTH_LDAP_BIND_DN: "",
AUTH_LDAP_BIND_PASSWORD: "",
AUTH_LDAP_BASE: "not-used",
AUTH_LDAP_BIND_DN: "not-used",
AUTH_LDAP_BIND_PASSWORD: "not-used",
},
mounts: {
"/appdata": localMountPath,
+14 -10
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.0.0-beta.12",
"version": "1.1.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 --"
},
@@ -39,25 +40,28 @@
"@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.3.3",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.8",
"@vitest/coverage-v8": "^3.0.3",
"@vitest/ui": "^3.0.3",
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^26.0.0",
"prettier": "^3.4.2",
"semantic-release": "^24.2.1",
"testcontainers": "^10.16.0",
"testcontainers": "^10.17.1",
"turbo": "^2.3.3",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
"vitest": "^3.0.3"
},
"packageManager": "pnpm@9.15.3",
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=22.13.0"
"node": ">=22.13.1"
},
"pnpm": {
"allowNonAppliedPatches": true,
"overrides": {
"proxmox-api>undici": "7.3.0"
},
"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"
}
}
+4 -4
View File
@@ -22,12 +22,14 @@
"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",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
@@ -41,9 +43,8 @@
"@trpc/client": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.3",
"lodash.clonedeep": "^4.5.0",
"next": "15.1.4",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
@@ -53,8 +54,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@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 ".";
+41 -38
View File
@@ -98,56 +98,59 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1) }))
.use(async ({ ctx, input, next }) => {
const dbIntegrations = await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const dbIntegrations =
input.integrationIds.length >= 1
? await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
item: {
secrets: true,
items: {
with: {
section: {
columns: {
boardId: true,
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
userPermissions: true,
groupPermissions: true,
},
},
userPermissions: true,
groupPermissions: true,
},
})
: [];
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
/**
+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);
}),
});
+10 -13
View File
@@ -1,26 +1,24 @@
import { TRPCError } from "@trpc/server";
import type Docker from "dockerode";
import type { Container } from "dockerode";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import type { DockerContainerState } from "@homarr/definitions";
import { DockerSingleton } from "@homarr/docker";
import type { Container, ContainerInfo, ContainerState, Docker, Port } from "@homarr/docker";
import { logger } from "@homarr/log";
import { createCacheChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { DockerSingleton } from "./docker-singleton";
const dockerCache = createCacheChannel<{
containers: (Docker.ContainerInfo & { instance: string; iconUrl: string | null })[];
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[];
}>("docker-containers", 5 * 60 * 1000);
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
const result = await dockerCache
.consumeAsync(async () => {
const dockerInstances = DockerSingleton.getInstance();
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(
// Return all the containers of all the instances into only one item
dockerInstances.map(({ instance, host: key }) =>
@@ -33,8 +31,7 @@ export const dockerRouter = createTRPCRouter({
),
).then((containers) => containers.flat());
const extractImage = (container: Docker.ContainerInfo) =>
container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
const dbIcons =
likeQueries.length >= 1
@@ -151,7 +148,7 @@ const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
};
const getContainerOrThrowAsync = async (id: string) => {
const dockerInstances = DockerSingleton.getInstance();
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
const foundContainer = containers.find((container) => container) ?? null;
@@ -168,21 +165,21 @@ const getContainerOrThrowAsync = async (id: string) => {
interface DockerContainer {
name: string;
id: string;
state: DockerContainerState;
state: ContainerState;
image: string;
ports: Docker.Port[];
ports: Port[];
iconUrl: string | null;
}
function sanitizeContainers(
containers: (Docker.ContainerInfo & { instance: string; iconUrl: string | null })[],
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[],
): DockerContainer[] {
return containers.map((container) => {
return {
name: container.Names[0]?.split("/")[1] ?? "Unknown",
id: container.Id,
instance: container.instance,
state: container.State as DockerContainerState,
state: container.State as ContainerState,
image: container.Image,
ports: container.Ports,
iconUrl: container.iconUrl,
@@ -1,51 +0,0 @@
import Docker from "dockerode";
interface DockerInstance {
host: string;
instance: Docker;
}
export class DockerSingleton {
private static instances: DockerInstance[];
private createInstances() {
const instances: DockerInstance[] = [];
const hostVariable = process.env.DOCKER_HOST;
const portVariable = process.env.DOCKER_PORT;
if (hostVariable === undefined || portVariable === undefined) {
instances.push({ host: "socket", instance: new Docker() });
return instances;
}
const hosts = hostVariable.split(",");
const ports = portVariable.split(",");
if (hosts.length !== ports.length) {
throw new Error("The number of hosts and ports must match");
}
hosts.forEach((host, i) => {
instances.push({
host: `${host}:${ports[i]}`,
instance: new Docker({
host,
port: parseInt(ports[i] ?? "", 10),
}),
});
return instances;
});
return instances;
}
public static findInstance(key: string): DockerInstance | undefined {
return this.instances.find((instance) => instance.host === key);
}
public static getInstance(): DockerInstance[] {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!DockerSingleton.instances) {
DockerSingleton.instances = new DockerSingleton().createInstances();
}
return this.instances;
}
}
@@ -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 }));
+15 -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,19 @@ export const userRouter = createTRPCRouter({
});
}
// Only allow user to select boards they have access to
if (input.homeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.homeBoardId), "view");
}
if (input.mobileHomeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.mobileHomeBoardId), "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();
};
});
}),
});
+31 -7
View File
@@ -1,12 +1,36 @@
import type { RssFeed } from "@homarr/cron-jobs";
import { createItemChannel } from "@homarr/redis";
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import { z } from "@homarr/validation";
import { createOneItemMiddleware } from "../../middlewares/item";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.unstable_concat(createOneItemMiddleware("rssFeed")).query(async ({ input }) => {
const channel = createItemChannel<RssFeed[]>(input.itemId);
return await channel.getAsync();
}),
getFeeds: publicProcedure
.input(
z.object({
urls: z.array(z.string()),
maximumAmountPosts: z.number(),
}),
)
.query(async ({ input }) => {
const rssFeeds = await Promise.all(
input.urls.map(async (url) => {
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: input.maximumAmountPosts,
});
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return rssFeeds
.flatMap((rssFeed) => rssFeed.data.entries)
.slice(0, input.maximumAmountPosts)
.sort((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
});
}),
});
+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,
};
};
+8 -5
View File
@@ -2,13 +2,15 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapte
import { cookies } from "next/headers";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { formatError } from "pretty-print-error";
import { db } from "@homarr/db";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
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";
@@ -26,15 +28,16 @@ export const createConfiguration = (
const adapter = createAdapter(db, provider);
return NextAuth({
logger: {
error: (code, ...message) => {
error: (error) => {
// Remove the big error message for failed login attempts
// as it is not useful for the user.
if (code.name === "CredentialsSignin") {
console.warn("The login attempt of a user was not successful.");
if (error.name === "CredentialsSignin") {
logger.warn("The login attempt of a user was not successful.");
return;
}
console.error(code, ...message);
logger.error(formatError(error));
logger.error(formatError(error.cause));
},
},
trustHost: true,
+6 -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(),
@@ -121,4 +86,5 @@ export const env = createEnv({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
},
skipValidation,
emptyStringAsUndefined: true,
});
+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"] => {
+4 -3
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",
@@ -34,8 +34,9 @@
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.3.1",
"next": "15.1.4",
"next": "15.1.6",
"next-auth": "5.0.0-beta.25",
"pretty-print-error": "^1.1.2",
"react": "19.0.0",
"react-dom": "19.0.0"
},
@@ -45,7 +46,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
@@ -1,5 +1,6 @@
import { CredentialsSignin } from "@auth/core/errors";
import { extractErrorMessage } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
@@ -7,7 +8,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 (
@@ -21,8 +22,8 @@ export const authorizeWithLdapCredentialsAsync = async (
distinguishedName: env.AUTH_LDAP_BIND_DN,
password: env.AUTH_LDAP_BIND_PASSWORD,
})
.catch(() => {
logger.error("Failed to connect to LDAP server");
.catch((error) => {
logger.error(`Failed to connect to LDAP server ${extractErrorMessage(error)}`);
throw new CredentialsSignin();
});
@@ -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.3.0"
},
"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,6 @@ 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(),
emptyStringAsUndefined: true,
});
+7 -4
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": {
"*": {
@@ -28,15 +29,17 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "15.1.4",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"undici": "7.3.0",
"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 {
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more