Compare commits

...
Author SHA1 Message Date
Releases Homarr f8c3dab7f9 chore(release): 1.0.0-beta.12 [skip ci] 2025-01-10 19:13:50 +00:00
homarr-releases[bot] 039800d94d chore(release): automatic release v1.0.0 2025-01-10 19:12:41 +00:00
homarr-renovate[bot] 9a7e421f68 fix(deps): update tiptap monorepo to v2.11.2 (#1913)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-10 18:42:40 +00:00
Meier Lukas e695fb64d3 fix(widget): set-options from widget components should be partial (#1882) 2025-01-10 14:47:19 +01:00
Meier Lukas 62da953356 feat(items): add search to selection (#1887) 2025-01-10 14:46:39 +01:00
Meier Lukas 39171ac76a refactor(icons): move walkxcode/dashboard-icons to homarr-labs/dashboard-icons (#1883) 2025-01-10 14:46:31 +01:00
Meier Lukas 546c824888 fix(dns-hole-summary): calculation for domains and percentage for multiple integrations (#1894) 2025-01-10 14:46:21 +01:00
Meier Lukas a12dd10269 fix(security): restrict link protocols to http and https (#1888) 2025-01-10 14:45:30 +01:00
Meier Lukas 80c02ef9f2 fix(translation): query parameter in search with escaped (#1895) 2025-01-10 14:44:31 +01:00
homarr-renovate[bot] 7d1b1d8342 fix(deps): update dependency glob to ^11.0.1 (#1908)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-10 11:29:01 +00:00
homarr-renovate[bot] dc3d27e1a1 fix(deps): update tiptap monorepo to v2.11.1 (#1909)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-10 11:15:46 +00:00
homarr-renovate[bot] ec8ad483ca chore(deps): update dependency jsdom to v26 (#1903)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-09 23:59:35 +01:00
homarr-renovate[bot] 8edf922e30 fix(deps): update dependency trpc-to-openapi to ^2.1.2 (#1900)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-09 15:43:55 +00:00
homarr-renovate[bot] 86a925939c fix(deps): update dependency mantine-react-table to v2.0.0-beta.8 (#1902)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-09 15:34:35 +00:00
homarr-renovate[bot] b034dcbb11 fix(deps): update dependency undici to v7.2.1 (#1904)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-09 15:16:57 +00:00
homarr-crowdin[bot] 93d7d99e70 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-09 00:44:53 +00:00
homarr-renovate[bot] bfe5e9a8e1 chore(deps): update dependency @types/react to v19.0.4 (#1899)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-08 20:18:06 +00:00
homarr-renovate[bot] e056f620da chore(deps): update dependency typescript to ^5.7.3 (#1897)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-08 19:14:44 +00:00
homarr-renovate[bot] 3860af31b6 fix(deps): update dependency octokit to ^4.1.0 (#1898)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-08 19:14:26 +00:00
homarr-renovate[bot] a69c64d88d fix(deps): update dependency ldapts to v7.3.1 (#1896)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-08 19:12:13 +00:00
homarr-renovate[bot] 34a6c65ead chore(deps): update node.js to v22.13.0 (#1890)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-08 16:06:01 +01:00
homarr-renovate[bot] e33e0a4d2b fix(deps): update tanstack-query monorepo to ^5.63.0 (#1893)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-08 14:36:02 +00:00
homarr-renovate[bot] ffdc8d3496 fix(deps): update dependency @tabler/icons-react to ^3.28.1 (#1891)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-08 11:15:24 +00:00
homarr-crowdin[bot] 64cf3d02c4 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-08 00:44:58 +00:00
homarr-renovate[bot] f4f02ca539 fix(deps): update nextjs monorepo to ^15.1.4 (#1885)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-07 20:54:33 +00:00
homarr-renovate[bot] 6327ca073c chore(deps): update node.js to v22.13.0 (#1884)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-07 19:53:55 +00:00
homarr-renovate[bot] 7807b28d24 fix(deps): update mantine monorepo to ^7.15.3 (#1880)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-07 06:22:05 +00:00
homarr-crowdin[bot] 02386ffcd8 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-07 05:32:34 +00:00
homarr-renovate[bot] 690cc303bf chore(deps): update dependency @types/dockerode to ^3.3.34 (#1879)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-07 00:56:21 +00:00
homarr-renovate[bot] 9da50e518b chore(deps): update dependency @types/chroma-js to v3 (#1876)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-06 22:05:56 +01:00
homarr-renovate[bot] eb424d757b fix(deps): update tanstack-query monorepo to ^5.62.16 (#1877)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-06 19:27:55 +00:00
Meier Lukas 65befa22ba feat(spotlight): add default search engine (#1807) 2025-01-06 19:59:40 +01:00
Meier Lukas 6a68ccfee4 chore(config): change next-config from mjs to typescript (#1861) 2025-01-06 19:59:25 +01:00
homarr-renovate[bot] e46b1c49f9 fix(deps): update dependency @tabler/icons-react to ^3.27.1 (#1875)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-06 18:55:52 +00:00
Manuel 64cc078b09 feat: docker add to homarr (#1825) 2025-01-06 19:37:18 +01:00
homarr-renovate[bot] be4e75321a fix(deps): update dependency typescript-eslint to ^8.19.1 (#1874)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-06 18:32:37 +00:00
homarr-renovate[bot] e0eab425e6 chore(deps): update dependency @types/react to v19.0.3 (#1872)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-06 17:00:21 +00:00
homarr-renovate[bot] 2dec74e5ff chore(deps): update pnpm to v9.15.3 (#1871)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-06 16:54:37 +00:00
homarr-crowdin[bot] 39ea2baff3 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-06 00:46:45 +00:00
homarr-crowdin[bot] a160f16772 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-05 00:48:20 +00:00
Meier Lukas ccb19e06c1 feat(app): add search and pagination (#1860) 2025-01-04 23:06:34 +01:00
Meier Lukas bf12944768 fix(iframe): remove second scrollbar from item card (#1828) 2025-01-04 22:47:33 +01:00
Meier Lukas 1e9098d052 feat(icons): add upload button to icon picker (#1859) 2025-01-04 22:43:30 +01:00
Meier Lukas 1562eae4d7 refactor: remove sharp image optimization (#1862) 2025-01-04 22:38:40 +01:00
Meier Lukas c349bf8371 feat(auth): add env variable for oidc-name-attribute-overwrite (#1850) 2025-01-04 21:49:33 +01:00
homarr-renovate[bot] 1f0116d725 fix(deps): update dependency better-sqlite3 to ^11.7.2 (#1858)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-04 20:16:31 +00:00
Meier LukasandManuel 49d10f7ad0 feat(board): add board duplication (#1856)
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
2025-01-04 18:53:57 +00:00
Meier Lukas d98552540a fix(deps): update nextjs monorepo to v15 (major) (#1844) 2025-01-04 19:47:23 +01:00
homarr-renovate[bot] 92f4f9421e fix(deps): update dependency dockerode to ^4.0.3 (#1854)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-04 12:59:23 +00:00
homarr-renovate[bot] 53f07f87b8 fix(deps): update tanstack-query monorepo to ^5.62.15 (#1853)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-04 11:39:29 +00:00
homarr-renovate[bot] 812469db82 fix(deps): update dependency sass to ^1.83.1 (#1852)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-04 02:16:26 +00:00
homarr-crowdin[bot] e2b211cf34 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-04 00:43:27 +00:00
homarr-renovate[bot] 2358f6f5eb fix(deps): update tanstack-query monorepo to ^5.62.14 (#1846)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-03 20:17:03 +00:00
Releases Homarr 024ec9235d chore(release): 1.0.0-beta.11 [skip ci] 2025-01-03 19:13:36 +00:00
homarr-releases[bot] 58fdea0245 chore(release): automatic release v1.0.0 2025-01-03 19:12:38 +00:00
Meier Lukas 4ead238462 test(e2e): add for onboarding and lldap authorization (#1834)
* test(e2e): add for onboarding and lldap authorization

* ci: add playwright chrome installation to e2e test

* fix(e2e): timeout between lldap login redirect to short

* test(e2e): add oidc azure test

* fix(e2e): lldap test fails

* wip: add temporary error log for failed ldap server connection

* fix(e2e): github actions don't support host.docker.internal

* chore: address pull request feedback

* refactor(e2e): move onboarding steps to onboarding actions and assertions

* fix(e2e): increase timeout for navigating back from azure login

* fix: wait for url network changed error

* fix: revert to wait for url

* fix(e2e): remove oidc test

* refactor(e2e): remove env validation

* ci: remove azure oidc env variables
2025-01-03 16:49:30 +01:00
Meier Lukas 6305c74f90 feat: remove import beta badge (#1829) 2025-01-03 16:35:43 +01:00
homarr-renovate[bot] 0780080681 chore(deps): update dependency @types/node to ^22.10.5 (#1842)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-03 15:33:47 +00:00
homarr-crowdin[bot] aa15a705d7 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-03 15:33:38 +00:00
homarr-renovate[bot] cc70175125 chore(deps): update semantic-release monorepo (#1841)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-03 15:28:36 +00:00
homarr-renovate[bot] bc5e25e9cd fix(deps): update tanstack-query monorepo to ^5.62.12 (#1843)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-03 15:16:35 +00:00
homarr-renovate[bot] d43557e165 chore(deps): update dependency @types/dockerode to ^3.3.33 (#1839)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-02 21:39:38 +00:00
homarr-renovate[bot] b6c96f73a5 chore(deps): update dependency @types/node to ^22.10.4 (#1838)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-02 18:55:31 +00:00
Yossi Hillali ee8831affe fix: prowlarr integration indexers status changed from id to indexerId (#1837) 2025-01-02 20:53:27 +02:00
Meier Lukas 6e21863d04 fix(icons): picker does not allow multiple words (#1827) 2025-01-02 14:52:38 +01:00
homarr-crowdin[bot] ca39ca66f0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-02 00:43:54 +00:00
Yossi Hillali cc7d53e84e fix: login page in light mode is always black (#1821) 2025-01-01 12:24:53 +02:00
homarr-renovate[bot] 99c409cbb7 chore(deps): update dependency @types/node to ^22.10.3 (#1824)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-01 02:26:11 +00:00
homarr-crowdin[bot] 1fcf9d0146 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-01 00:48:53 +00:00
homarr-renovate[bot] a234fc844e fix(deps): update dependency zod-form-data to ^2.0.5 (#1822)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-31 17:14:34 +00:00
Releases Homarr 168b587c18 chore(release): 1.0.0-beta.10 [skip ci] 2024-12-31 10:42:11 +00:00
homarr-releases[bot] dc12a82805 chore(release): automatic release v1.0.0 2024-12-31 10:41:19 +00:00
ManuelandMeier Lukas f5076454cd feat(integration): add search engine creation (#1816)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-31 11:40:37 +01:00
Meier Lukas aeb681a858 feat(docker): add support for pgid and puid env variables (#1759) 2024-12-31 11:36:28 +01:00
Meier Lukas 56b57ad171 fix(docker): replace anonymous docker volume with env variable for encrypting secrets (#1809) 2024-12-31 11:30:29 +01:00
Manuel 91e1cef611 feat: add request media (#1811) 2024-12-31 11:05:30 +01:00
homarr-renovate[bot] e53b06e8c2 chore(deps): update dependency concurrently to ^9.1.2 (#1818)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-31 08:46:41 +00:00
Meier Lukas 0ebf4bc55e feat(widget): add minecraft server status widget (#1801) 2024-12-31 09:45:48 +01:00
homarr-crowdin[bot] b8a155dd08 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-31 00:43:59 +00:00
Thomas CamlongandMeier Lukas 63786eed06 chore(lang): updated translations from crowdin (#1803)
* New translations en.json (Chinese Simplified)

* New translations en.json (Turkish)

* New translations en.json (Turkish)

* New translations en.json (Turkish)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Croatian)

* New translations en.json (Estonian)

* New translations en.json (Latvian)

* New translations en.json (Cree)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-30 21:44:23 +01:00
homarr-renovate[bot] 598f0047eb fix(deps): update dependency typescript-eslint to ^8.19.0 (#1814)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-30 17:33:38 +00:00
homarr-renovate[bot] eacda4c397 fix(deps): update tiptap monorepo to v2.11.0 (#1813)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-30 16:54:00 +00:00
Meier Lukas 5cb726f793 ci(docker): always push image (#1812) 2024-12-30 17:38:27 +01:00
homarr-crowdin[bot] e8d29420e0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-30 16:28:16 +00:00
Yossi Hillali 23c7d0bbf0 fix(media-server): redesign widget (#1775)
* fix: redisign media server widget

* fix: reviewed changes

* fix: reviewed chenges

* fix: add icon title

* fix: text resize
2024-12-29 19:24:38 +02:00
homarr-renovate[bot] 75aefcb0d5 fix(deps): update dependency octokit to ^4.0.3 (#1806)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-29 16:16:34 +00:00
homarr-crowdin[bot] b52a8a7935 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-29 08:27:17 +00:00
homarr-renovate[bot] 6d90209e00 chore(deps): update pnpm to v9.15.2 (#1804)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-29 08:26:48 +00:00
Thomas CamlongandMeier Lukas 8f0945f51a chore(lang): updated translations from crowdin (#1800)
* New translations en.json (German)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

* New translations en.json (Turkish)

* New translations en.json (Chinese Simplified)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-28 18:50:25 +01:00
homarr-crowdin[bot] 5b04b93f23 chore(lang): updated translations from crowdin (#1802)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-28 18:49:22 +01:00
Thomas CamlongandMeier Lukas c8228b920c chore(lang): updated translations from crowdin (#1797)
* New translations en.json (Hebrew)

* New translations en.json (Turkish)

* New translations en.json (Turkish)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-28 10:25:14 +01:00
homarr-crowdin[bot] abf7942fb7 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-28 09:14:42 +00:00
homarr-crowdin[bot] 50746eb3e4 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-28 09:06:24 +00:00
Thomas CamlongandMeier Lukas 76abfca11b chore(lang): updated translations from crowdin (#1786)
* New translations en.json (Hebrew)

* New translations en.json (Dutch)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Croatian)

* New translations en.json (Latvian)

* New translations en.json (Cree)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-27 20:47:14 +01:00
homarr-crowdin[bot] fcab6a347d chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-27 19:43:44 +00:00
Yossi Hillali c91aae53ac fix(health-monitoring): add months to uptime, fix status update (#1792) 2024-12-27 21:32:04 +02:00
homarr-renovate[bot]andMeier Lukas 4943e7b851 fix(deps): update dependency trpc-to-openapi to ^2.1.1 (#1788)
* fix(deps): update dependency trpc-to-openapi to ^2.1.1

* fix(deps): missing dependency lodash.clonedeep in api package

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-27 20:31:15 +01:00
214 changed files with 19070 additions and 5993 deletions

No files matched your search

+2 -1
View File
@@ -6,4 +6,5 @@ README.md
.next
.git
dev
.build
.build
e2e
+8 -5
View File
@@ -4,6 +4,14 @@
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
# The below secret is not used anywhere but required for Auth.js (Would encrypt JWTs and Mail hashes, both not used)
AUTH_SECRET="supersecret"
# The below secret is used to encrypt integration secrets in the database.
# It should be a 32-byte string, generated by running `openssl rand -hex 32` on Unix
# or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
@@ -20,11 +28,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# You can generate the secret via 'openssl rand -base64 32' on Unix
# @see https://next-auth.js.org/configuration/options#secret
AUTH_SECRET='supersecret'
TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger
+2
View File
@@ -91,6 +91,8 @@ jobs:
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
@@ -13,11 +13,6 @@ on:
required: false
default: true
description: Send notifications
push-image:
type: boolean
required: false
default: true
description: Push Docker Image
permissions:
contents: write
@@ -112,7 +107,6 @@ jobs:
NEXT_VERSION: ${{ needs.release.outputs.version }}
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
PUSH_IMAGE: ${{ github.event_name != 'workflow_dispatch' || github.events.inputs.push-image == true }}
steps:
- uses: actions/checkout@v4
with:
@@ -143,13 +137,13 @@ jobs:
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
type=raw,value=${{ env.NEXT_VERSION }}
- name: Build and maybe push
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
context: .
push: ${{ env.PUSH_IMAGE }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
@@ -160,4 +154,4 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. ${{ env.PUSH_IMAGE == 'true' && '' || 'This was a dry run' }}"
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
+2
View File
@@ -58,6 +58,8 @@ apps/websocket/wssServer.cjs
apps/nextjs/.million/
packages/cli/cli.cjs
# e2e mounts
e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
+1 -1
View File
@@ -1 +1 @@
22.12.0
22.13.0
+1 -1
View File
@@ -39,5 +39,5 @@
"i18n-ally.localesPaths": [
"packages/translation/src/lang",
],
"i18n-ally.keystyle": "auto",
"i18n-ally.keystyle": "nested",
}
+74
View File
@@ -1,3 +1,77 @@
## [1.0.0-beta.12](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-01-10)
### Features
* **app:** add search and pagination ([#1860](https://github.com/homarr-labs/homarr/issues/1860)) ([ccb19e0](https://github.com/homarr-labs/homarr/commit/ccb19e06c192029a81c851900982d52b045185a6))
* **auth:** add env variable for oidc-name-attribute-overwrite ([#1850](https://github.com/homarr-labs/homarr/issues/1850)) ([c349bf8](https://github.com/homarr-labs/homarr/commit/c349bf83714739ca1bc6057f2d43c9e3964fa205))
* **board:** add board duplication ([#1856](https://github.com/homarr-labs/homarr/issues/1856)) ([49d10f7](https://github.com/homarr-labs/homarr/commit/49d10f7ad0f58b577ed72c2a46a062af263ff225))
* docker add to homarr ([#1825](https://github.com/homarr-labs/homarr/issues/1825)) ([64cc078](https://github.com/homarr-labs/homarr/commit/64cc078b095e64edca17e07a09a4f5c340665f84))
* **icons:** add upload button to icon picker ([#1859](https://github.com/homarr-labs/homarr/issues/1859)) ([1e9098d](https://github.com/homarr-labs/homarr/commit/1e9098d052801fe7d53ce15e9f22ed89de04d644))
* **items:** add search to selection ([#1887](https://github.com/homarr-labs/homarr/issues/1887)) ([62da953](https://github.com/homarr-labs/homarr/commit/62da953356ff76694ddc50980a2b879a01bcbdd6))
* **spotlight:** add default search engine ([#1807](https://github.com/homarr-labs/homarr/issues/1807)) ([65befa2](https://github.com/homarr-labs/homarr/commit/65befa22ba91c36a3734ac6dc027175dc9fca892))
### Bug Fixes
* **deps:** update dependency @tabler/icons-react to ^3.27.1 ([#1875](https://github.com/homarr-labs/homarr/issues/1875)) ([e46b1c4](https://github.com/homarr-labs/homarr/commit/e46b1c49f9bb1320f82b2de0585f7e2c7bc72c54))
* **deps:** update dependency @tabler/icons-react to ^3.28.1 ([#1891](https://github.com/homarr-labs/homarr/issues/1891)) ([ffdc8d3](https://github.com/homarr-labs/homarr/commit/ffdc8d34960dea0172c50a1d93a4cd14997b13ac))
* **deps:** update dependency better-sqlite3 to ^11.7.2 ([#1858](https://github.com/homarr-labs/homarr/issues/1858)) ([1f0116d](https://github.com/homarr-labs/homarr/commit/1f0116d725d76845df4b9cce74ef87134edc627a))
* **deps:** update dependency dockerode to ^4.0.3 ([#1854](https://github.com/homarr-labs/homarr/issues/1854)) ([92f4f94](https://github.com/homarr-labs/homarr/commit/92f4f9421e8f693e6546716f5f04922161e29cdc))
* **deps:** update dependency glob to ^11.0.1 ([#1908](https://github.com/homarr-labs/homarr/issues/1908)) ([7d1b1d8](https://github.com/homarr-labs/homarr/commit/7d1b1d83429acaa9846479a7a5c48092d4614b75))
* **deps:** update dependency ldapts to v7.3.1 ([#1896](https://github.com/homarr-labs/homarr/issues/1896)) ([a69c64d](https://github.com/homarr-labs/homarr/commit/a69c64d88d57293eba8adbdf88ce694194268492))
* **deps:** update dependency mantine-react-table to v2.0.0-beta.8 ([#1902](https://github.com/homarr-labs/homarr/issues/1902)) ([86a9259](https://github.com/homarr-labs/homarr/commit/86a925939cb8ef92fe0bca5a762e688887916b04))
* **deps:** update dependency octokit to ^4.1.0 ([#1898](https://github.com/homarr-labs/homarr/issues/1898)) ([3860af3](https://github.com/homarr-labs/homarr/commit/3860af31b64e8a74e7e1c91470a9de5450f9e5e7))
* **deps:** update dependency sass to ^1.83.1 ([#1852](https://github.com/homarr-labs/homarr/issues/1852)) ([812469d](https://github.com/homarr-labs/homarr/commit/812469db827badee63c73a8c819922c24fdefd13))
* **deps:** update dependency trpc-to-openapi to ^2.1.2 ([#1900](https://github.com/homarr-labs/homarr/issues/1900)) ([8edf922](https://github.com/homarr-labs/homarr/commit/8edf922e3050ba9d8c951180ae68f628067c190e))
* **deps:** update dependency typescript-eslint to ^8.19.1 ([#1874](https://github.com/homarr-labs/homarr/issues/1874)) ([be4e753](https://github.com/homarr-labs/homarr/commit/be4e75321a0a6a05c6ac6c4ba97ffb5068aed4aa))
* **deps:** update dependency undici to v7.2.1 ([#1904](https://github.com/homarr-labs/homarr/issues/1904)) ([b034dcb](https://github.com/homarr-labs/homarr/commit/b034dcbb11017a016e30db0e2bf8c1a0765174ca))
* **deps:** update mantine monorepo to ^7.15.3 ([#1880](https://github.com/homarr-labs/homarr/issues/1880)) ([7807b28](https://github.com/homarr-labs/homarr/commit/7807b28d24327db89514da533cb06f4c296b579d))
* **deps:** update nextjs monorepo to ^15.1.4 ([#1885](https://github.com/homarr-labs/homarr/issues/1885)) ([f4f02ca](https://github.com/homarr-labs/homarr/commit/f4f02ca539609705b7192c4327272f88415e1594))
* **deps:** update nextjs monorepo to v15 (major) ([#1844](https://github.com/homarr-labs/homarr/issues/1844)) ([d985525](https://github.com/homarr-labs/homarr/commit/d98552540a9eb3c24ba92a490f921c2b1911b1f6))
* **deps:** update tanstack-query monorepo to ^5.62.14 ([#1846](https://github.com/homarr-labs/homarr/issues/1846)) ([2358f6f](https://github.com/homarr-labs/homarr/commit/2358f6f5ebf4bbb9eb4ccd91990f967d9cc44e3b))
* **deps:** update tanstack-query monorepo to ^5.62.15 ([#1853](https://github.com/homarr-labs/homarr/issues/1853)) ([53f07f8](https://github.com/homarr-labs/homarr/commit/53f07f87b8d600ce57fa94d2ffb4d6f0105102d0))
* **deps:** update tanstack-query monorepo to ^5.62.16 ([#1877](https://github.com/homarr-labs/homarr/issues/1877)) ([eb424d7](https://github.com/homarr-labs/homarr/commit/eb424d757b7934d5a6bce9de943528f17e678b80))
* **deps:** update tanstack-query monorepo to ^5.63.0 ([#1893](https://github.com/homarr-labs/homarr/issues/1893)) ([e33e0a4](https://github.com/homarr-labs/homarr/commit/e33e0a4d2bd91d610c6d29522a98cae8b9953478))
* **deps:** update tiptap monorepo to v2.11.1 ([#1909](https://github.com/homarr-labs/homarr/issues/1909)) ([dc3d27e](https://github.com/homarr-labs/homarr/commit/dc3d27e1a1b1611c1997235bf51daf00e3d1f239))
* **deps:** update tiptap monorepo to v2.11.2 ([#1913](https://github.com/homarr-labs/homarr/issues/1913)) ([9a7e421](https://github.com/homarr-labs/homarr/commit/9a7e421f6827a5a0ac3627318e51537840295e72))
* **dns-hole-summary:** calculation for domains and percentage for multiple integrations ([#1894](https://github.com/homarr-labs/homarr/issues/1894)) ([546c824](https://github.com/homarr-labs/homarr/commit/546c824888a2a6909b831b69ce1a4e283099bda5))
* **iframe:** remove second scrollbar from item card ([#1828](https://github.com/homarr-labs/homarr/issues/1828)) ([bf12944](https://github.com/homarr-labs/homarr/commit/bf1294476867fabc896f94c9326a1acfe6bffa50))
* **security:** restrict link protocols to http and https ([#1888](https://github.com/homarr-labs/homarr/issues/1888)) ([a12dd10](https://github.com/homarr-labs/homarr/commit/a12dd102692ee237e9695bd852dc894a2856b30e))
* **translation:** query parameter in search with escaped ([#1895](https://github.com/homarr-labs/homarr/issues/1895)) ([80c02ef](https://github.com/homarr-labs/homarr/commit/80c02ef9f24cec74025a0fcc983c6db37f7fd457))
* **widget:** set-options from widget components should be partial ([#1882](https://github.com/homarr-labs/homarr/issues/1882)) ([e695fb6](https://github.com/homarr-labs/homarr/commit/e695fb64d317ecb47b6844dc2a185502244f7db1))
## [1.0.0-beta.11](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-01-03)
### Features
* remove import beta badge ([#1829](https://github.com/homarr-labs/homarr/issues/1829)) ([6305c74](https://github.com/homarr-labs/homarr/commit/6305c74f90d943722c915ffbf54b5fd7e68a7dca))
### Bug Fixes
* **deps:** update dependency zod-form-data to ^2.0.5 ([#1822](https://github.com/homarr-labs/homarr/issues/1822)) ([a234fc8](https://github.com/homarr-labs/homarr/commit/a234fc844e5edcef9935efb0725b6ccceadfe69d))
* **deps:** update tanstack-query monorepo to ^5.62.12 ([#1843](https://github.com/homarr-labs/homarr/issues/1843)) ([bc5e25e](https://github.com/homarr-labs/homarr/commit/bc5e25e9cd3269a3d8cc8ef521ae75aff50088be))
* **icons:** picker does not allow multiple words ([#1827](https://github.com/homarr-labs/homarr/issues/1827)) ([6e21863](https://github.com/homarr-labs/homarr/commit/6e21863d04f7d0598e7e7ca3fb707f9beb09d7ef))
* login page in light mode is always black ([#1821](https://github.com/homarr-labs/homarr/issues/1821)) ([cc7d53e](https://github.com/homarr-labs/homarr/commit/cc7d53e84ec8c2dd3698a178ca6f35099194b5d7))
* prowlarr integration indexers status changed from id to indexerId ([#1837](https://github.com/homarr-labs/homarr/issues/1837)) ([ee8831a](https://github.com/homarr-labs/homarr/commit/ee8831affe34a33b80a0993aa538c2b16c035f11))
## [1.0.0-beta.10](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2024-12-31)
### Features
* add request media ([#1811](https://github.com/homarr-labs/homarr/issues/1811)) ([91e1cef](https://github.com/homarr-labs/homarr/commit/91e1cef611a47d57b42c91c0c6d49fcf2e6a10bb))
* **docker:** add support for pgid and puid env variables ([#1759](https://github.com/homarr-labs/homarr/issues/1759)) ([aeb681a](https://github.com/homarr-labs/homarr/commit/aeb681a85839e31284e8f1566adc4191a68ea385))
* **integration:** add search engine creation ([#1816](https://github.com/homarr-labs/homarr/issues/1816)) ([f507645](https://github.com/homarr-labs/homarr/commit/f5076454cd644a99439b697f24bb0b1f871b2205))
* **widget:** add minecraft server status widget ([#1801](https://github.com/homarr-labs/homarr/issues/1801)) ([0ebf4bc](https://github.com/homarr-labs/homarr/commit/0ebf4bc55e62caf5917340496abb350d564df255))
### Bug Fixes
* **deps:** update dependency octokit to ^4.0.3 ([#1806](https://github.com/homarr-labs/homarr/issues/1806)) ([75aefcb](https://github.com/homarr-labs/homarr/commit/75aefcb0d5210a53e8a00b675dbbce847aa8add4))
* **deps:** update dependency trpc-to-openapi to ^2.1.1 ([#1788](https://github.com/homarr-labs/homarr/issues/1788)) ([4943e7b](https://github.com/homarr-labs/homarr/commit/4943e7b851cd29291b5e8f2f9d0e084b4deeffc0))
* **deps:** update dependency typescript-eslint to ^8.19.0 ([#1814](https://github.com/homarr-labs/homarr/issues/1814)) ([598f004](https://github.com/homarr-labs/homarr/commit/598f0047eb13d6d0d6b68c9032a7a6648f4b6aa3))
* **deps:** update tiptap monorepo to v2.11.0 ([#1813](https://github.com/homarr-labs/homarr/issues/1813)) ([eacda4c](https://github.com/homarr-labs/homarr/commit/eacda4c397cb384a35e9eec0305f9b26f2a22a4a))
* **docker:** replace anonymous docker volume with env variable for encrypting secrets ([#1809](https://github.com/homarr-labs/homarr/issues/1809)) ([56b57ad](https://github.com/homarr-labs/homarr/commit/56b57ad171da170c2d8edd6112092cef0b586852))
* **health-monitoring:** add months to uptime, fix status update ([#1792](https://github.com/homarr-labs/homarr/issues/1792)) ([c91aae5](https://github.com/homarr-labs/homarr/commit/c91aae53acf27eab96a9150281f98a2156e9e2af))
* **media-server:** redesign widget ([#1775](https://github.com/homarr-labs/homarr/issues/1775)) ([23c7d0b](https://github.com/homarr-labs/homarr/commit/23c7d0bbf01215b680f346e7eef8072d8b9db09e))
## [1.0.0-beta.9](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2024-12-27)
### Bug Fixes
+22 -35
View File
@@ -1,4 +1,4 @@
FROM node:22.12.0-alpine AS base
FROM node:22.13.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -12,9 +12,6 @@ COPY . .
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Install sharp for image optimization
RUN corepack enable pnpm && pnpm install sharp -w
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
@@ -25,51 +22,41 @@ RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
# gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext su-exec
# gettext is required for envsubst, openssl for generating AUTH_SECRET, su-exec for running application as non-root
RUN apk add --no-cache redis nginx bash gettext su-exec openssl
RUN mkdir /appdata
VOLUME /appdata
RUN mkdir /secrets
VOLUME /secrets
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
COPY --from=builder /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr
# Don't run production as root
RUN chown -R nextjs:nodejs /secrets
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
RUN mkdir -p /var/cache/nginx && \
mkdir -p /var/log/nginx && \
mkdir -p /var/lib/nginx && \
touch /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
COPY --from=builder /app/apps/nextjs/next.config.mjs .
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
COPY --from=builder /app/packages/db/migrations ./db/migrations
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY scripts/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
COPY --chown=nextjs:nodejs scripts/generateRandomSecureKey.js ./generateRandomSecureKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
COPY --from=builder /app/apps/nextjs/.next/standalone ./
COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public
COPY scripts/run.sh ./run.sh
COPY --chmod=777 scripts/entrypoint.sh ./entrypoint.sh
COPY packages/redis/redis.conf /app/redis.conf
COPY nginx.conf /etc/nginx/templates/nginx.conf
ENV DB_URL='/appdata/db/db.sqlite'
@@ -78,4 +65,4 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
CMD ["sh", "run.sh"]
@@ -1,7 +1,9 @@
// Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import "@homarr/db/env.mjs";
import "@homarr/common/env.mjs";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
@@ -10,14 +12,22 @@ import "./src/env.mjs";
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
/** @type {import("next").NextConfig} */
const nextConfig = {
interface WebpackConfig {
module: {
rules: {
test: RegExp;
loader: string;
}[];
};
}
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
webpack: (config, { isServer }) => {
webpack: (config: WebpackConfig, { isServer }) => {
if (isServer) {
config.module.rules.push({
test: /\.node$/,
@@ -37,6 +47,7 @@ const nextConfig = {
};
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
export default withNextIntl(nextConfig);
+24 -24
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"dev": "pnpm with-env next dev --turbopack",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
@@ -23,7 +23,7 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.11.2",
"@homarr/gridstack": "^1.11.3",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
@@ -38,18 +38,18 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.15.2",
"@mantine/core": "^7.15.2",
"@mantine/dropzone": "^7.15.2",
"@mantine/hooks": "^7.15.2",
"@mantine/modals": "^7.15.2",
"@mantine/tiptap": "^7.15.2",
"@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",
"@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.26.0",
"@tanstack/react-query": "^5.62.11",
"@tanstack/react-query-devtools": "^5.62.11",
"@tanstack/react-query-next-experimental": "5.62.11",
"@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",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -62,17 +62,17 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"glob": "^11.0.1",
"jotai": "^2.11.0",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.22",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.4",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^19.0.0",
"react-dom": "^19.0.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.0",
"sass": "^1.83.1",
"superjson": "2.2.2",
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1"
@@ -81,16 +81,16 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.5",
"@types/node": "^22.10.2",
"@types/chroma-js": "3.1.0",
"@types/node": "^22.10.5",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react": "19.0.4",
"@types/react-dom": "19.0.2",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.1",
"concurrently": "^9.1.2",
"eslint": "^9.17.0",
"node-loader": "^2.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
@@ -18,7 +18,8 @@ import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
import { clientApi, getTrpcUrl } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { env } from "~/env.mjs";
@@ -11,15 +11,17 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps {
params: {
params: Promise<{
id: string;
};
searchParams: {
}>;
searchParams: Promise<{
token: string;
};
}>;
}
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound();
const session = await auth();
@@ -57,7 +59,7 @@ export default async function InviteUsagePage({ params, searchParams }: InviteUs
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<Card withBorder w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} />
</Card>
<Text size="xs" c="gray.5" ta="center">
@@ -9,12 +9,13 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
interface LoginProps {
searchParams: {
searchParams: Promise<{
callbackUrl?: string;
};
}>;
}
export default async function Login({ searchParams }: LoginProps) {
export default async function Login(props: LoginProps) {
const searchParams = await props.searchParams;
const session = await auth();
if (session) {
@@ -35,7 +36,7 @@ export default async function Login({ searchParams }: LoginProps) {
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<Card withBorder w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
@@ -31,15 +31,15 @@ import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
interface Props {
params: {
params: Promise<{
name: string;
};
searchParams: {
}>;
searchParams: Promise<{
tab?: keyof TranslationObject["board"]["setting"]["section"];
};
}>;
}
const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
try {
const board = await api.board.getBoardByName({ name: params.name });
const { hasFullAccess } = await getBoardPermissionsAsync(board);
@@ -63,7 +63,9 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
}
};
export default async function BoardSettingsPage({ params, searchParams }: Props) {
export default async function BoardSettingsPage(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
@@ -28,9 +28,9 @@ export const createBoardLayout = <TParams extends Params>({
params,
children,
}: PropsWithChildren<{
params: TParams;
params: Promise<TParams>;
}>) => {
const initialBoard = await getInitialBoard(params).catch((error) => {
const initialBoard = await getInitialBoard(await params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
logger.warn(error);
notFound();
+8 -4
View File
@@ -14,6 +14,7 @@ import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server";
@@ -63,14 +64,17 @@ export const viewport: Viewport = {
],
};
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
if (!isLocaleSupported(props.params.locale)) {
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>;
}) {
if (!isLocaleSupported((await props.params).locale)) {
notFound();
}
const session = await auth();
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
const i18nMessages = await getI18nMessages();
const StackedProvider = composeWrappers([
@@ -89,7 +93,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
lang={props.params.locale}
lang={(await props.params).locale}
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
@@ -5,29 +5,29 @@ import { splitToNChunks } from "@homarr/common";
import classes from "./hero-banner.module.css";
const icons = [
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
];
const countIconGroups = 3;
@@ -37,16 +37,16 @@ export async function generateMetadata() {
};
}
const getHost = () => {
const getHostAsync = async () => {
if (process.env.HOSTNAME) {
return `${process.env.HOSTNAME}:3000`;
}
return headers().get("host");
return (await headers()).get("host");
};
export default async function AboutPage() {
const baseServerUrl = `http://${getHost()}`;
const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about");
const attributes = await getPackageAttributesAsync();
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
@@ -9,10 +9,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function AppEditPage({ params }: AppEditPageProps) {
export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {
@@ -6,7 +6,10 @@ import { IconBox, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -14,22 +17,35 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
interface AppsPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function AppsPage(props: AppsPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const apps = await api.app.all();
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app");
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
@@ -44,6 +60,10 @@ export default async function AppsPage() {
))}
</Stack>
)}
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);
@@ -3,12 +3,14 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
@@ -64,11 +68,28 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
onSuccess: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
}, [board.id, board.name, openDuplicateModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
</Menu.Item>
)}
{hasChangeAccess && (
<>
<Menu.Divider />
@@ -1,12 +1,11 @@
"use client";
import { Affix, Button, Group, Menu } from "@mantine/core";
import { Affix, Button, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { BetaBadge } from "@homarr/ui";
export const CreateBoardButton = () => {
const t = useI18n();
@@ -26,10 +25,7 @@ export const CreateBoardButton = () => {
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
<Group>
{t("board.action.oldImport.label")}
<BetaBadge size="xs" />
</Group>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -11,10 +11,11 @@ import { IntegrationAccessSettings } from "../../_components/integration-access-
import { EditIntegrationForm } from "./_integration-edit-form";
interface EditIntegrationPageProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
const params = await props.params;
const editT = await getScopedI18n("integration.page.edit");
const t = await getI18n();
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
@@ -3,13 +3,13 @@
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName, integrationDefs } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -38,6 +38,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
kind,
value: "",
})),
attemptSearchEngineCreation: true,
},
});
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
@@ -78,6 +79,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
);
};
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack>
@@ -104,6 +107,16 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
</Stack>
</Fieldset>
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(searchParams.kind),
})}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/>
)}
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
@@ -13,12 +13,15 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
kind: IntegrationKind;
};
searchParams: Promise<
Partial<z.infer<typeof validation.integration.create>> & {
kind: IntegrationKind;
}
>;
}
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
const searchParams = await props.searchParams;
const session = await auth();
if (!session?.user.permissions.includes("integration-create")) {
notFound();
@@ -46,12 +46,13 @@ import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
interface IntegrationsPageProps {
searchParams: {
searchParams: Promise<{
tab?: IntegrationKind;
};
}>;
}
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
export default async function IntegrationsPage(props: IntegrationsPageProps) {
const searchParams = await props.searchParams;
const session = await auth();
if (!session) {
@@ -1,15 +1,40 @@
"use client";
import type { JSX } from "react";
import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { MaybePromise } from "@homarr/common/types";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMedia = () => {
export const UploadMediaButton = () => {
const t = useI18n();
const onSettledAsync = async () => {
await revalidatePathActionAsync("/manage/medias");
};
return (
<UploadMedia onSettled={onSettledAsync}>
{({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
</UploadMedia>
);
};
interface UploadMediaProps {
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
onSettled?: () => MaybePromise<void>;
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
}
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
@@ -18,10 +43,14 @@ export const UploadMedia = () => {
const formData = new FormData();
formData.append("file", file);
await mutateAsync(formData, {
onSuccess() {
async onSuccess(mediaId) {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
await onSuccess?.({
id: mediaId,
url: `/api/user-medias/${mediaId}`,
});
},
onError() {
showErrorNotification({
@@ -29,18 +58,14 @@ export const UploadMedia = () => {
});
},
async onSettled() {
await revalidatePathActionAsync("/manage/medias");
await onSettled?.();
},
});
};
return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => (
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
{({ onClick }) => children({ onClick, loading: isPending })}
</FileButton>
);
};
@@ -7,6 +7,7 @@ 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 { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -16,7 +17,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
import { UploadMedia } from "./_actions/upload-media";
import { UploadMediaButton } from "./_actions/upload-media";
const searchParamsSchema = z.object({
search: z.string().optional(),
@@ -29,12 +30,8 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface MediaListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: MediaListPageProps) {
@@ -45,7 +42,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return (
@@ -61,7 +58,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
)}
</Group>
{session.user.permissions.includes("media-upload") && <UploadMedia />}
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
</Group>
<Table striped highlightOnHover>
<TableThead>
@@ -10,10 +10,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineEditForm } from "./_search-engine-edit-form";
interface SearchEngineEditPageProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
export default async function SearchEngineEditPage(props: SearchEngineEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("search-engine-modify-all")) {
@@ -6,6 +6,7 @@ import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -22,12 +23,8 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface SearchEnginesPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
@@ -37,7 +34,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
redirect("/auth/login");
}
const searchParams = searchParamsSchema.parse(props.searchParams);
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const tEngine = await getScopedI18n("search.engine");
@@ -0,0 +1,29 @@
"use client";
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
{(form) => (
<>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -11,6 +11,7 @@ import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -41,6 +42,10 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.appearance.title")}</Title>
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
@@ -30,7 +30,7 @@ export default async function ApiPage() {
if (!session?.user || !session.user.permissions.includes("admin")) {
notFound();
}
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
const apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");
@@ -2,7 +2,14 @@
import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import {
IconCategoryPlus,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
@@ -10,6 +17,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -125,6 +134,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return (
<Group gap={"sm"}>
{groupedAlert}
@@ -134,7 +144,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
totalCount: table.getRowCount(),
})}
</Text>
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
<ContainerActionBar selectedContainers={dockerContainers} />
</Group>
);
},
@@ -151,16 +161,29 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
}
interface ContainerActionBarProps {
selectedIds: string[];
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
}
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const handleClick = () => {
openModal({
selectedContainers,
});
};
const selectedIds = selectedContainers.map((container) => container.id);
return (
<Group gap="xs">
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
{t("addToHomarr.label")}
</Button>
</Group>
);
};
@@ -174,9 +197,10 @@ interface ContainerActionBarButtonProps {
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const handleClickAsync = async () => {
await mutateAsync(
{ ids: props.selectedIds },
@@ -0,0 +1,67 @@
"use client";
import { Button, Group, Select, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface ChangeDefaultSearchEngineFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
});
showSuccessNotification({
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;
@@ -11,6 +11,7 @@ import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
@@ -19,12 +20,13 @@ import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
interface Props {
params: {
params: Promise<{
userId: string;
};
}>;
}
export async function generateMetadata({ params }: Props) {
export async function generateMetadata(props: Props) {
const params = await props.params;
const session = await auth();
const user = await api.user
.getById({
@@ -43,7 +45,8 @@ export async function generateMetadata({ params }: Props) {
};
}
export default async function EditUserPage({ params }: Props) {
export default async function EditUserPage(props: Props) {
const params = await props.params;
const t = await getI18n();
const tGeneral = await getScopedI18n("management.page.user.setting.general");
const session = await auth();
@@ -58,6 +61,7 @@ export default async function EditUserPage({ params }: Props) {
}
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isCredentialsUser = user.provider === "credentials";
@@ -95,6 +99,11 @@ export default async function EditUserPage({ params }: Props) {
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
@@ -15,10 +15,14 @@ import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
interface LayoutProps {
params: { userId: string };
params: Promise<{ userId: string }>;
}
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const params = await props.params;
const { children } = props;
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
@@ -10,12 +10,13 @@ import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
interface Props {
params: {
params: Promise<{
userId: string;
};
}>;
}
export default async function UserSecurityPage({ params }: Props) {
export default async function UserSecurityPage(props: Props) {
const params = await props.params;
const session = await auth();
const tSecurity = await getScopedI18n("management.page.user.setting.security");
const user = await api.user
@@ -10,10 +10,14 @@ import { ManageContainer } from "~/components/manage/manage-container";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const params = await props.params;
const { children } = props;
const t = await getI18n();
const tGroup = await getScopedI18n("management.page.group");
const group = await api.group.getById({ id: params.id });
@@ -17,15 +17,17 @@ import { AddGroupMember } from "./_add-group-member";
import { RemoveGroupMember } from "./_remove-group-member";
interface GroupsDetailPageProps {
params: {
params: Promise<{
id: string;
};
searchParams: {
}>;
searchParams: Promise<{
search: string | undefined;
};
}>;
}
export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
@@ -14,12 +14,13 @@ import { ReservedGroupAlert } from "./_reserved-group-alert";
import { TransferGroupOwnership } from "./_transfer-group-ownership";
interface GroupsDetailPageProps {
params: {
params: Promise<{
id: string;
};
}>;
}
export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
@@ -12,12 +12,13 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
interface GroupPermissionsPageProps {
params: {
params: Promise<{
id: string;
};
}>;
}
export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
export default async function GroupPermissionsPage(props: GroupPermissionsPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
@@ -5,6 +5,7 @@ import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead,
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -19,12 +20,8 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface GroupsListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {
@@ -35,7 +32,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
return (
@@ -9,11 +9,11 @@ import { env } from "~/env.mjs";
import { WidgetPreviewPageContent } from "./_content";
interface Props {
params: { kind: string };
params: Promise<{ kind: string }>;
}
export default async function WidgetPreview(props: Props) {
if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) {
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
notFound();
}
@@ -26,7 +26,7 @@ export default async function WidgetPreview(props: Props) {
},
});
const sort = props.params.kind as WidgetKind;
const sort = (await props.params).kind as WidgetKind;
return (
<Center h="100vh">
+5 -6
View File
@@ -1,11 +1,10 @@
import { headers } from "next/headers";
import { userAgent } from "next/server";
import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { createOpenApiFetchHandler } from "trpc-to-openapi";
import { appRouter, createTRPCContext } from "@homarr/api";
import { hashPasswordAsync } from "@homarr/auth";
import type { Session } from "@homarr/auth";
import { hashPasswordAsync } from "@homarr/auth";
import { createSessionAsync } from "@homarr/auth/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
@@ -13,7 +12,7 @@ import { logger } from "@homarr/log";
const handlerAsync = async (req: NextRequest) => {
const apiKeyHeaderValue = req.headers.get("ApiKey");
const ipAddress = req.ip ?? headers().get("x-forwarded-for");
const ipAddress = req.headers.get("x-forwarded-for");
const { ua } = userAgent(req);
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
@@ -88,9 +87,9 @@ const getSessionOrDefaultFromHeadersAsync = async (
};
export {
handlerAsync as DELETE,
handlerAsync as GET,
handlerAsync as PATCH,
handlerAsync as POST,
handlerAsync as PUT,
handlerAsync as DELETE,
handlerAsync as PATCH,
};
@@ -1,16 +1,17 @@
import { NextRequest } from "next/server";
import { createHandlers } from "@homarr/auth";
import { createHandlersAsync } from "@homarr/auth";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
export const GET = async (req: NextRequest) => {
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.GET(reqWithTrustedOrigin(req));
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
return await handlers.GET(reqWithTrustedOrigin(req));
};
export const POST = async (req: NextRequest) => {
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.POST(
reqWithTrustedOrigin(req),
);
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
return await handlers.POST(reqWithTrustedOrigin(req));
};
/**
@@ -5,7 +5,8 @@ import type { NextRequest } from "next/server";
import { db, eq } from "@homarr/db";
import { medias } from "@homarr/db/schema";
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const image = await db.query.medias.findFirst({
where: eq(medias.id, params.id),
columns: {
@@ -36,6 +36,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: item.kind === "iframe" ? "hidden" : undefined,
},
}}
p={0}
@@ -87,7 +88,14 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={updateOptions}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...partialNewOptions.newOptions,
...options,
},
})
}
{...dimensions}
/>
</ErrorBoundary>
@@ -1,21 +1,67 @@
import { Button, Card, Center, Grid, Stack, Text } from "@mantine/core";
import { useMemo, useState } from "react";
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { widgetImports } from "@homarr/widgets";
import type { WidgetDefinition } from "@homarr/widgets";
import { useItemActions } from "./item-actions";
export const ItemSelectModal = createModal<void>(({ actions }) => {
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem } = useItemActions();
const items = useMemo(
() =>
objectEntries(widgetImports)
.map(([kind, value]) => ({
kind,
icon: value.definition.icon,
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t],
);
const filteredItems = useMemo(
() => items.filter((item) => item.name.toLowerCase().includes(search.toLowerCase())),
[items, search],
);
const handleAdd = (kind: WidgetKind) => {
createItem({ kind });
actions.closeModal();
};
return (
<Grid>
{objectEntries(widgetImports).map(([key, value]) => {
return <WidgetItem key={key} kind={key} definition={value.definition} closeModal={actions.closeModal} />;
})}
</Grid>
<Stack>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch />}
placeholder={`${t("item.create.search")}...`}
data-autofocus
onKeyDown={(event) => {
// Add item if there is only one item in the list and user presses Enter
if (event.key === "Enter" && filteredItems.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleAdd(filteredItems[0]!.kind);
}
}}
/>
<Grid>
{filteredItems.map((item) => (
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
))}
</Grid>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("item.create.title"),
@@ -23,20 +69,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
});
const WidgetItem = ({
kind,
definition,
closeModal,
item,
onSelect,
}: {
kind: WidgetKind;
definition: WidgetDefinition;
closeModal: () => void;
item: {
kind: WidgetKind;
name: string;
description: string;
icon: TablerIcon;
};
onSelect: () => void;
}) => {
const t = useI18n();
const { createItem } = useItemActions();
const handleAdd = (kind: WidgetKind) => {
createItem({ kind });
closeModal();
};
return (
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
@@ -44,25 +88,16 @@ const WidgetItem = ({
<Stack justify="space-between" h="100%">
<Stack gap="xs">
<Center>
<definition.icon />
<item.icon />
</Center>
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
{t(`widget.${kind}.name`)}
{item.name}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
{t(`widget.${kind}.description`)}
{item.description}
</Text>
</Stack>
<Button
onClick={() => {
handleAdd(kind);
}}
variant="light"
size="xs"
mt="auto"
radius="md"
fullWidth
>
<Button onClick={onSelect} variant="light" size="xs" mt="auto" radius="md" fullWidth>
{t(`item.create.addToBoard`)}
</Button>
</Stack>
@@ -1,10 +1,12 @@
import type { FocusEventHandler } from "react";
import { startTransition, useState } from "react";
import {
ActionIcon,
Box,
Card,
Combobox,
Flex,
Group,
Image,
Indicator,
InputBase,
@@ -16,10 +18,13 @@ import {
useCombobox,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media";
import classes from "./icon-picker.module.css";
interface IconPickerProps {
@@ -34,6 +39,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const [value, setValue] = useState<string>(initialValue ?? "");
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
@@ -105,40 +111,61 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
leftSection={
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
onChange(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
onFocus?.(event);
combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
onChange(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
onFocus?.(event);
combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={({ url }) => {
startTransition(() => {
setValue(url);
setPreviewUrl(url);
setSearch(url);
onChange(url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Combobox.Target>
<Combobox.Dropdown>
@@ -88,7 +88,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false}>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
@@ -113,7 +113,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
{t("logout")}
</Menu.Item>
) : (
<Menu.Item onClick={() => router.push("/auth/login")} leftSection={<IconLogin size="1rem" />}>
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
+1 -1
View File
@@ -4,7 +4,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
import SuperJSON from "superjson";
import type { AppRouter } from "@homarr/api";
import { createHeadersCallbackForSource } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { createI18nMiddleware } from "@homarr/translation/middleware";
export async function middleware(request: NextRequest) {
+1 -1
View File
@@ -7,7 +7,7 @@ import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const getCurrentColorSchemeAsync = cache(async () => {
const cookieValue = cookies().get(colorSchemeCookieKey)?.value;
const cookieValue = (await cookies()).get(colorSchemeCookieKey)?.value;
if (cookieValue) {
return cookieValue as ColorScheme;
+3 -3
View File
@@ -38,17 +38,17 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.2.0"
"undici": "7.2.1"
},
"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.2",
"@types/node": "^22.10.5",
"dotenv-cli": "^8.0.0",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -36,6 +36,6 @@
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+92
View File
@@ -0,0 +1,92 @@
import { chromium } from "playwright";
import { GenericContainer } from "testcontainers";
import { describe, expect, test } from "vitest";
import { OnboardingActions } from "./shared/actions/onboarding-actions";
import { createHomarrContainer, withLogs } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
const defaultCredentials = {
username: "admin",
password: "password",
email: "admin@homarr.dev",
group: "lldap_admin",
};
const ldapBase = "dc=example,dc=com";
describe("LLDAP authorization", () => {
test("Authorize with LLDAP successfully", async () => {
// Arrange
const lldapContainer = await createLldapContainer().start();
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "ldap",
AUTH_LDAP_URI: `ldap://host.docker.internal:${lldapContainer.getMappedPort(3890)}`,
AUTH_LDAP_BASE: ldapBase,
AUTH_LDAP_BIND_DN: `uid=${defaultCredentials.username},ou=People,${ldapBase}`,
AUTH_LDAP_BIND_PASSWORD: defaultCredentials.password,
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const onboardingActions = new OnboardingActions(page, db);
await onboardingActions.skipOnboardingAsync({
group: defaultCredentials.group,
});
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
await page.getByLabel("Username").fill(defaultCredentials.username);
await page.getByLabel("Password").fill(defaultCredentials.password);
await page.locator("css=button[type='submit']").click();
// Assert
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
const users = await db.query.users.findMany({
with: {
groups: {
with: {
group: true,
},
},
},
});
expect(users).toHaveLength(1);
const user = users[0]!;
expect(user).toEqual(
expect.objectContaining({
name: defaultCredentials.username,
email: defaultCredentials.email,
provider: "ldap",
}),
);
const groups = user.groups.map((g) => g.group.name);
expect(groups).toContain(defaultCredentials.group);
// Cleanup
await browser.close();
await homarrContainer.stop();
await lldapContainer.stop();
}, 120_000);
});
const createLldapContainer = () => {
return withLogs(
new GenericContainer("lldap/lldap:stable").withExposedPorts(3890).withEnvironment({
LLDAP_JWT_SECRET: "REPLACE_WITH_RANDOM",
LLDAP_KEY_SEED: "REPLACE_WITH_RANDOM",
LLDAP_LDAP_BASE_DN: ldapBase,
LLDAP_LDAP_USER_PASS: defaultCredentials.password,
LLDAP_LDAP_USER_EMAIL: defaultCredentials.email,
}),
);
};
+85
View File
@@ -0,0 +1,85 @@
import { chromium } from "playwright";
import { describe, test } from "vitest";
import { OnboardingActions } from "./shared/actions/onboarding-actions";
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
describe("Onboarding", () => {
test("Credentials onboarding should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
}, 60_000);
test("External provider onboarding setup should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "ldap",
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
AUTH_LDAP_BASE: "",
AUTH_LDAP_BIND_DN: "",
AUTH_LDAP_BIND_PASSWORD: "",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const externalGroupName = "oidc-admins";
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processExternalGroupStepAsync({
name: externalGroupName,
});
await actions.processSettingsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
}, 60_000);
});
+53
View File
@@ -0,0 +1,53 @@
import { createId } from "@paralleldrive/cuid2";
import type { Page } from "playwright";
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
import type { SqliteDatabase } from "../e2e-db";
export class OnboardingActions {
private readonly page: Page;
private readonly db: SqliteDatabase;
constructor(page: Page, db: SqliteDatabase) {
this.page = page;
this.db = db;
}
public async skipOnboardingAsync(input?: { group?: string }) {
await this.db.update(sqliteSchema.onboarding).set({
step: "finish",
});
if (input?.group) {
await this.db.insert(sqliteSchema.groups).values({
id: createId(),
name: input.group,
});
}
}
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
await this.page.locator("button", { hasText: type }).click();
}
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
await this.page.waitForSelector("text=administrator user");
await this.page.getByLabel("Username").fill(input.username);
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
await this.page.locator("css=button[type='submit']").click();
}
public async processExternalGroupStepAsync(input: { name: string }) {
await this.page.waitForSelector("text=external provider");
await this.page.locator("input").fill(input.name);
await this.page.locator("css=button[type='submit']").click();
}
public async processSettingsStepAsync() {
await this.page.waitForSelector("text=Analytics");
await this.page.locator("css=button[type='submit']").click();
}
}
@@ -0,0 +1,62 @@
import { eq } from "drizzle-orm";
import type { Page } from "playwright";
import { expect } from "vitest";
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
import { OnboardingStep } from "../../../packages/definitions/src";
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
import type { SqliteDatabase } from "../e2e-db";
export class OnboardingAssertions {
private readonly page: Page;
private readonly db: SqliteDatabase;
constructor(page: Page, db: SqliteDatabase) {
this.page = page;
this.db = db;
}
public async assertDbOnboardingStepAsync(expectedStep: OnboardingStep) {
const onboarding = await this.db.query.onboarding.findFirst();
expect(onboarding?.step).toEqual(expectedStep);
}
public async assertUserAndAdminGroupInsertedAsync(expectedUsername: string) {
const users = await this.db.query.users.findMany({
with: {
groups: {
with: {
group: {
with: {
permissions: true,
},
},
},
},
},
});
const user = users.find((u) => u.name === expectedUsername);
expect(user).toBeDefined();
const adminGroup = user!.groups.find((g) => g.group.name === credentialsAdminGroup);
expect(adminGroup).toBeDefined();
expect(adminGroup!.group.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
}
public async assertExternalGroupInsertedAsync(expectedGroupName: string) {
const group = await this.db.query.groups.findFirst({
where: eq(sqliteSchema.groups.name, expectedGroupName),
with: {
permissions: true,
},
});
expect(group).toBeDefined();
expect(group!.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
}
public async assertFinishStepVisibleAsync() {
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
}
}
+43 -3
View File
@@ -1,11 +1,51 @@
import { GenericContainer, Wait } from "testcontainers";
import { Environment } from "testcontainers/build/types";
export const createHomarrContainer = () => {
export const createHomarrContainer = (
options: {
environment?: Environment;
mounts?: {
"/appdata"?: string;
"/var/run/docker.sock"?: string;
};
} = {},
) => {
if (!process.env.CI) {
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
}
return new GenericContainer("homarr-e2e")
const container = new GenericContainer("homarr-e2e")
.withExposedPorts(7575)
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575));
.withEnvironment({
...options.environment,
SECRET_ENCRYPTION_KEY: "0".repeat(64),
})
.withBindMounts(
Object.entries(options.mounts ?? {})
.filter((item) => item?.[0] !== undefined)
.map(([container, local]) => ({
source: local,
target: container,
})),
)
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575))
.withExtraHosts([
{
// This enabled the usage of host.docker.internal as hostname in the container
host: "host.docker.internal",
ipAddress: "host-gateway",
},
]);
return withLogs(container);
};
export const withLogs = (container: GenericContainer) => {
container.withLogConsumer((stream) =>
stream
.on("data", (line) => console.log(line))
.on("err", (line) => console.error(line))
.on("end", () => console.log("Stream closed")),
);
return container;
};
+32
View File
@@ -0,0 +1,32 @@
import { mkdir } from "fs/promises";
import path from "path";
import { createId } from "@paralleldrive/cuid2";
import Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as sqliteSchema from "../../packages/db/schema/sqlite";
export const createSqliteDbFileAsync = async () => {
const localMountPath = path.join(__dirname, "tmp", createId());
await mkdir(path.join(localMountPath, "db"), { recursive: true });
const localDbUrl = path.join(localMountPath, "db", "db.sqlite");
const connection = new Database(localDbUrl);
const db = drizzle(connection, {
schema: sqliteSchema,
casing: "snake_case",
});
await migrate(db, {
migrationsFolder: path.join(__dirname, "..", "..", "packages", "db", "migrations", "sqlite"),
});
return {
db,
localMountPath,
};
};
export type SqliteDatabase = BetterSQLite3Database<typeof sqliteSchema>;
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.0.0-beta.9",
"version": "1.0.0-beta.12",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -32,29 +32,29 @@
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.0",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.1",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.2",
"@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",
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^25.0.1",
"jsdom": "^26.0.0",
"prettier": "^3.4.2",
"semantic-release": "^24.2.0",
"semantic-release": "^24.2.1",
"testcontainers": "^10.16.0",
"turbo": "^2.3.3",
"typescript": "^5.7.2",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
},
"packageManager": "pnpm@9.15.1",
"packageManager": "pnpm@9.15.3",
"engines": {
"node": ">=22.12.0"
"node": ">=22.13.0"
},
"pnpm": {
"allowNonAppliedPatches": true,
+1 -1
View File
@@ -33,6 +33,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+10 -7
View File
@@ -8,7 +8,8 @@
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts"
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -40,19 +41,21 @@
"@trpc/client": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"next": "^14.2.22",
"react": "^19.0.0",
"dockerode": "^4.0.3",
"lodash.clonedeep": "^4.5.0",
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
"trpc-to-openapi": "^2.1.0"
"trpc-to-openapi": "^2.1.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.32",
"@types/dockerode": "^3.3.34",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+3 -39
View File
@@ -1,7 +1,10 @@
"use client";
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
import SuperJSON from "superjson";
import type { AppRouter } from ".";
import { createHeadersCallbackForSource } from "./shared";
export const clientApi = createTRPCReact<AppRouter>();
export const fetchApi = createTRPCClient<AppRouter>({
@@ -26,42 +29,3 @@ function getBaseUrl() {
export function getTrpcUrl() {
return `${getBaseUrl()}/api/trpc`;
}
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
* @param source trpc source request comes from
* @returns headers callback
*/
export function createHeadersCallbackForSource(source: string) {
return async () => {
const headers = new Headers();
headers.set("x-trpc-source", source);
const cookies = await importCookiesAsync();
// We need to set cookie for ssr requests (for example with useSuspenseQuery or middleware)
if (cookies) {
headers.set("cookie", cookies);
}
return headers;
};
}
/**
* This is a workarround as cookies are not passed to the server
* when using useSuspenseQuery or middleware
* @returns cookie string on server or null on client
*/
async function importCookiesAsync() {
if (typeof window === "undefined") {
return await import("next/headers").then(({ cookies }) =>
cookies()
.getAll()
.map(({ name, value }) => `${name}=${value}`)
.join(";"),
);
}
return null;
}
+38
View File
@@ -3,13 +3,36 @@ import { TRPCError } from "@trpc/server";
import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas";
import { getIconForName } from "@homarr/icons";
import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { canUserSeeAppAsync } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(validation.common.paginated)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
const totalCount = await ctx.db.$count(apps, whereQuery);
const dbApps = await ctx.db.query.apps.findMany({
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
orderBy: asc(apps.name),
});
return {
items: dbApps,
totalCount,
};
}),
all: protectedProcedure
.input(z.void())
.output(z.array(selectAppSchema))
@@ -98,6 +121,21 @@ export const appRouter = createTRPCRouter({
href: input.href,
});
}),
createMany: permissionRequiredProcedure
.requiresPermission("app-create")
.input(validation.app.createMany)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values(
input.map((app) => ({
id: createId(),
name: app.name,
description: app.description,
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
href: app.href,
})),
);
}),
update: permissionRequiredProcedure
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(validation.app.edit))
+108 -1
View File
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import type { Database, InferInsertModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
@@ -11,7 +11,9 @@ import {
boardUserPermissions,
groupMembers,
groupPermissions,
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
items,
sections,
users,
@@ -216,6 +218,111 @@ export const boardRouter = createTRPCRouter({
});
});
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.input(validation.board.duplicate)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await noBoardWithSimilarNameAsync(ctx.db, input.name);
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.id),
with: {
sections: {
with: {
items: {
with: {
integrations: true,
},
},
},
},
},
});
if (!board) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}
const { sections: boardSections, ...boardProps } = board;
const newBoardId = createId();
const sectionMap = new Map<string, string>(boardSections.map((section) => [section.id, createId()]));
const sectionsToInsert: InferInsertModel<typeof sections>[] = boardSections.map(({ items: _, ...section }) => ({
...section,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: sectionMap.get(section.id)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
parentSectionId: section.parentSectionId ? sectionMap.get(section.parentSectionId)! : null,
boardId: newBoardId,
}));
const flatItems = boardSections.flatMap((section) => section.items);
const itemMap = new Map<string, string>(flatItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = flatItems.map(({ integrations: _, ...item }) => ({
...item,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: itemMap.get(item.id)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(item.sectionId)!,
}));
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const itemIntegrationsToInsert = flatItems.flatMap((item) =>
item.integrations
// Restrict integrations to only those the user has access to
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
.map((integration) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
);
ctx.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 }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+6 -2
View File
@@ -1,4 +1,4 @@
import { count, like } from "@homarr/db";
import { and, count, like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { validation } from "@homarr/validation";
@@ -15,7 +15,11 @@ export const iconsRouter = createTRPCRouter({
name: true,
url: true,
},
where: (input.searchText?.length ?? 0) > 0 ? like(icons.name, `%${input.searchText}%`) : undefined,
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
},
@@ -11,9 +11,11 @@ import {
integrations,
integrationSecrets,
integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationDefs,
@@ -192,6 +194,18 @@ export const integrationRouter = createTRPCRouter({
})),
);
}
if (input.attemptSearchEngineCreation) {
const icon = getIconUrl(input.kind);
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
integrationId,
type: "fromIntegration",
iconUrl: icon,
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
});
}
}),
update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -411,6 +425,36 @@ interface AddSecretInput {
value: string;
kind: IntegrationSecretKind;
}
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
const searchEngines = await db.query.searchEngines.findMany({
columns: {
short: true,
},
});
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
if (!usedShortNames.includes(nameByIntegrationName)) {
return nameByIntegrationName;
}
// 8 is max length constraint
for (let i = 2; i < 9999999; i++) {
const generatedName = `${nameByIntegrationName}${i}`;
if (usedShortNames.includes(generatedName)) {
continue;
}
return generatedName;
}
throw new Error(
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
);
};
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
@@ -1,10 +1,13 @@
import { TRPCError } from "@trpc/server";
import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema";
import { validation } from "@homarr/validation";
import { asc, createId, eq, like, sql } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { integrationCreator } from "@homarr/integrations";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
@@ -27,6 +30,21 @@ export const searchEngineRouter = createTRPCRouter({
totalCount: searchEngineCount[0]?.count ?? 0,
};
}),
getSelectable: protectedProcedure
.input(z.object({ withIntegrations: z.boolean() }).default({ withIntegrations: true }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines
.findMany({
orderBy: asc(searchEngines.name),
where: input.withIntegrations ? undefined : eq(searchEngines.type, "generic"),
columns: {
id: true,
name: true,
},
})
.then((engines) => engines.map((engine) => ({ value: engine.id, label: engine.name })));
}),
byId: protectedProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
@@ -53,12 +71,83 @@ export const searchEngineRouter = createTRPCRouter({
urlTemplate: searchEngine.urlTemplate!,
};
}),
getDefaultSearchEngine: publicProcedure.query(async ({ ctx }) => {
const userDefaultId = ctx.session?.user.id
? ((await ctx.db.query.users
.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
defaultSearchEngineId: true,
},
})
.then((user) => user?.defaultSearchEngineId)) ?? null)
: null;
if (userDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, userDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
const serverDefaultId = await getServerSettingByKeyAsync(ctx.db, "search").then(
(setting) => setting.defaultSearchEngineId,
);
if (serverDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, serverDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
return null;
}),
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
limit: input.limit,
});
}),
getMediaRequestOptions: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(validation.common.mediaRequestOptions)
.query(async ({ ctx, input }) => {
const integration = integrationCreator(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(validation.common.requestMedia)
.mutation(async ({ ctx, input }) => {
const integration = integrationCreator(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure
.requiresPermission("search-engine-create")
.input(validation.searchEngine.manage)
@@ -179,6 +179,7 @@ describe("create should create a new integration", () => {
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
@@ -201,6 +202,48 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("with create integration access should create a new integration when creating search engine", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
url: "http://jellyseerr.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: true,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbSearchEngine = await db.query.searchEngines.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/jellyseerr.png",
);
});
test("without create integration access should throw permission error", async () => {
// Arrange
const db = createDb();
@@ -213,6 +256,7 @@ describe("create should create a new integration", () => {
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
};
// Act
+39
View File
@@ -211,6 +211,7 @@ export const userRouter = createTRPCRouter({
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -233,6 +234,7 @@ export const userRouter = createTRPCRouter({
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
},
where: eq(users.id, input.userId),
});
@@ -406,6 +408,43 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeDefaultSearchEngine: protectedProcedure
.input(
convertIntersectionToZodObject(validation.user.changeDefaultSearchEngine.and(z.object({ userId: z.string() }))),
)
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeSearchEngine", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
})
.where(eq(users.id, input.userId));
}),
changeColorScheme: protectedProcedure
.input(validation.user.changeColorScheme)
.output(z.void())
+2
View File
@@ -8,6 +8,7 @@ import { indexerManagerRouter } from "./indexer-manager";
import { mediaRequestsRouter } from "./media-requests";
import { mediaServerRouter } from "./media-server";
import { mediaTranscodingRouter } from "./media-transcoding";
import { minecraftRouter } from "./minecraft";
import { notebookRouter } from "./notebook";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
@@ -27,4 +28,5 @@ export const widgetRouter = createTRPCRouter({
indexerManager: indexerManagerRouter,
healthMonitoring: healthMonitoringRouter,
mediaTranscoding: mediaTranscodingRouter,
minecraft: minecraftRouter,
});
@@ -21,6 +21,7 @@ export const mediaServerRouter = createTRPCRouter({
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationKind: integration.kind,
sessions: data,
};
}),
@@ -0,0 +1,36 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const serverStatusInputSchema = z.object({
domain: z.string().nonempty(),
isBedrockServer: z.boolean(),
});
export const minecraftRouter = createTRPCRouter({
getServerStatus: publicProcedure.input(serverStatusInputSchema).query(async ({ input }) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
return observable<MinecraftServerStatus>((emit) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next(data);
});
return () => {
unsubscribe();
};
});
}),
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { auth } from "@homarr/auth/next";
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(headers());
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
+38
View File
@@ -0,0 +1,38 @@
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
* @param source trpc source request comes from
* @returns headers callback
*/
export function createHeadersCallbackForSource(source: string) {
return async () => {
const headers = new Headers();
headers.set("x-trpc-source", source);
const cookies = await importCookiesAsync();
// We need to set cookie for ssr requests (for example with useSuspenseQuery or middleware)
if (cookies) {
headers.set("cookie", cookies);
}
return headers;
};
}
/**
* This is a workarround as cookies are not passed to the server
* when using useSuspenseQuery or middleware
* @returns cookie string on server or null on client
*/
async function importCookiesAsync() {
if (typeof window !== "undefined") {
return null;
}
const { cookies } = await import("next/headers");
return (await cookies())
.getAll()
.map(({ name, value }) => `${name}=${value}`)
.join(";");
}
+4 -3
View File
@@ -74,7 +74,7 @@ export const createConfiguration = (
userId: user.id,
});
cookies().set(sessionTokenCookieName, sessionToken, {
(await cookies()).set(sessionTokenCookieName, sessionToken, {
path: "/",
expires: expires,
httpOnly: true,
@@ -99,8 +99,9 @@ export const createConfiguration = (
error: "/auth/login",
},
jwt: {
encode() {
const cookie = cookies().get(sessionTokenCookieName)?.value;
// eslint-disable-next-line no-restricted-syntax
async encode() {
const cookie = (await cookies()).get(sessionTokenCookieName)?.value;
return cookie ?? "";
},
+2 -2
View File
@@ -64,7 +64,6 @@ export const env = createEnv({
server: {
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
AUTH_PROVIDERS: authProvidersSchema,
...(authProviders.includes("oidc")
? {
@@ -75,6 +74,7 @@ export const env = createEnv({
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
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(),
}
: {}),
...(authProviders.includes("ldap")
@@ -98,7 +98,6 @@ export const env = createEnv({
runtimeEnv: {
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
@@ -119,6 +118,7 @@ export const env = createEnv({
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
},
skipValidation,
});
+14 -7
View File
@@ -9,6 +9,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { extractProfileName } from "./providers/oidc/oidc-provider";
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
return async ({ user, profile }) => {
@@ -43,18 +44,24 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
);
}
const profileUsername = profile?.preferred_username?.includes("@") ? profile.name : profile?.preferred_username;
if (profileUsername && dbUser.name !== profileUsername) {
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
logger.info(
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
);
if (profile) {
const profileUsername = extractProfileName(profile);
if (!profileUsername) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
}
if (dbUser.name !== profileUsername) {
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
logger.info(
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
);
}
}
logger.info(`User '${dbUser.name}' logged in at ${dayjs().format()}`);
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
cookies().set(colorSchemeCookieKey, dbUser.colorScheme, {
(await cookies()).set(colorSchemeCookieKey, dbUser.colorScheme, {
path: "/",
expires: dayjs().add(1, "year").toDate(),
});
+2 -2
View File
@@ -20,7 +20,7 @@ declare module "next-auth" {
export * from "./security";
// See why it's unknown in the [...nextauth]/route.ts file
export const createHandlers = (provider: SupportedAuthProvider | "unknown", useSecureCookies: boolean) =>
createConfiguration(provider, headers(), useSecureCookies);
export const createHandlersAsync = async (provider: SupportedAuthProvider | "unknown", useSecureCookies: boolean) =>
createConfiguration(provider, await headers(), useSecureCookies);
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";
+5 -5
View File
@@ -33,11 +33,11 @@
"@t3-oss/env-nextjs": "^0.11.1",
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.3.0",
"next": "^14.2.22",
"ldapts": "7.3.1",
"next": "15.1.4",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -47,6 +47,6 @@
"@types/cookies": "0.9.0",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+20 -12
View File
@@ -1,18 +1,10 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import type { OIDCConfig } from "next-auth/providers";
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { env } from "../../env.mjs";
import { createRedirectUri } from "../../redirect";
interface Profile {
sub: string;
name: string;
email: string;
groups: string[];
preferred_username: string;
email_verified: boolean;
}
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
id: "oidc",
name: env.AUTH_OIDC_CLIENT_NAME,
@@ -28,12 +20,28 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
},
},
profile(profile) {
if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
}
const name = extractProfileName(profile);
if (!name) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
}
return {
id: profile.sub,
// Use the name as the username if the preferred_username is an email address
name: profile.preferred_username.includes("@") ? profile.name : profile.preferred_username,
name,
email: profile.email,
provider: "oidc",
};
},
});
export const extractProfileName = (profile: Profile) => {
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
// Use the name as the username if the preferred_username is an email address
return profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username;
}
return profile[env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE as keyof typeof profile] as string;
};
+2 -2
View File
@@ -29,7 +29,7 @@ vi.mock("next/headers", async (importOriginal) => {
vi.spyOn(result, "set");
const cookies = () => result;
const cookies = () => Promise.resolve(result);
return { ...mod, cookies } satisfies HeadersExport;
});
@@ -238,7 +238,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
// Assert
expect(cookies().set).toHaveBeenCalledWith(
expect((await cookies()).set).toHaveBeenCalledWith(
colorSchemeCookieKey,
"dark",
expect.objectContaining({
+1 -1
View File
@@ -34,6 +34,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { randomBytes } from "crypto";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
export const env = createEnv({
server: {
SECRET_ENCRYPTION_KEY: z
.string({
required_error: `SECRET_ENCRYPTION_KEY is required${errorSuffix}`,
})
.min(64, {
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
})
.max(64, {
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
})
.regex(/^[0-9a-fA-F]{64}$/, {
message: `SECRET_ENCRYPTION_KEY must only contain hex characters${errorSuffix}`,
}),
},
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",
});
+6 -4
View File
@@ -8,7 +8,8 @@
".": "./index.ts",
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./client": "./src/client.ts"
"./client": "./src/client.ts",
"./env.mjs": "./env.mjs"
},
"typesVersions": {
"*": {
@@ -27,14 +28,15 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "^14.2.22",
"react": "^19.0.0"
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"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",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+4 -12
View File
@@ -1,20 +1,12 @@
import crypto from "crypto";
import { logger } from "@homarr/log";
import { env } from "../env.mjs";
const algorithm = "aes-256-cbc"; //Using AES encryption
const fallbackKey = "0000000000000000000000000000000000000000000000000000000000000000";
const encryptionKey = process.env.ENCRYPTION_KEY ?? fallbackKey; // Fallback to a default key for local development
if (encryptionKey === fallbackKey) {
logger.warn("Using a fallback encryption key, stored secrets are not secure");
// We never want to use the fallback key in production
if (process.env.NODE_ENV === "production" && process.env.CI !== "true") {
throw new Error("Encryption key is not set");
}
}
const key = Buffer.from(encryptionKey, "hex");
// We fallback to a key of 0s if the key was not provided because env validation was skipped
// This should only be the case in CI
const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex");
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);
+10
View File
@@ -1,3 +1,5 @@
import type { z } from "zod";
export type MaybePromise<T> = T | Promise<T>;
export type AtLeastOneOf<T> = [T, ...T[]];
@@ -16,3 +18,11 @@ export type Inverse<T extends Invertible> = {
};
type Invertible = Record<PropertyKey, PropertyKey>;
export type inferSearchParamsFromSchema<TSchema extends z.AnyZodObject> = inferSearchParamsFromSchemaInner<
z.infer<TSchema>
>;
type inferSearchParamsFromSchemaInner<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
+1 -1
View File
@@ -31,6 +31,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -30,6 +30,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -33,6 +33,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -46,6 +46,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}
+2
View File
@@ -9,6 +9,7 @@ import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -33,6 +34,7 @@ export const jobGroup = createCronJobGroup({
sessionCleanup: sessionCleanupJob,
updateChecker: updateCheckerJob,
mediaTranscoding: mediaTranscodingJob,
minecraftServerStatus: minecraftServerStatusJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
+47 -21
View File
@@ -1,7 +1,7 @@
import { splitToNChunks, Stopwatch } from "@homarr/common";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db";
import { db, inArray } from "@homarr/db";
import { db, inArray, sql } from "@homarr/db";
import { createId } from "@homarr/db/client";
import { iconRepositories, icons } from "@homarr/db/schema";
import { fetchIconsAsync } from "@homarr/icons";
@@ -22,13 +22,13 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
`Successfully fetched ${countIcons} icons from ${repositoryIconGroups.length} repositories within ${stopWatch.getElapsedInHumanWords()}`,
);
const databaseIconGroups = await db.query.iconRepositories.findMany({
const databaseIconRepositories = await db.query.iconRepositories.findMany({
with: {
icons: true,
},
});
const skippedChecksums: string[] = [];
const skippedChecksums: `${string}.${string}`[] = [];
let countDeleted = 0;
let countInserted = 0;
@@ -43,18 +43,24 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
continue;
}
const repositoryInDb = databaseIconGroups.find((dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug);
const repositoryIconGroupId: string = repositoryInDb?.id ?? createId();
const repositoryInDb = databaseIconRepositories.find(
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
);
const iconRepositoryId: string = repositoryInDb?.id ?? createId();
if (!repositoryInDb?.id) {
newIconRepositories.push({
id: repositoryIconGroupId,
id: iconRepositoryId,
slug: repositoryIconGroup.slug,
});
}
for (const icon of repositoryIconGroup.icons) {
if (databaseIconGroups.flatMap((group) => group.icons).some((dbIcon) => dbIcon.checksum === icon.checksum)) {
skippedChecksums.push(icon.checksum);
if (
databaseIconRepositories
.flatMap((repository) => repository.icons)
.some((dbIcon) => dbIcon.checksum === icon.checksum && dbIcon.iconRepositoryId === iconRepositoryId)
) {
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
continue;
}
@@ -63,34 +69,54 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
checksum: icon.checksum,
name: icon.fileNameWithExtension,
url: icon.imageUrl,
iconRepositoryId: repositoryIconGroupId,
iconRepositoryId,
});
countInserted++;
}
}
const deadIcons = databaseIconGroups
.flatMap((group) => group.icons)
.filter((icon) => !skippedChecksums.includes(icon.checksum));
const deadIcons = databaseIconRepositories
.flatMap((repository) => repository.icons)
.filter((icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`));
await db.transaction(async (transaction) => {
const deadIconRepositories = databaseIconRepositories.filter(
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
);
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
await transaction.insert(iconRepositories).values(newIconRepositories);
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
await transaction.insert(icons).values(chunck);
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
await transaction.delete(icons).where(
inArray(
icons.checksum,
deadIcons.map((icon) => icon.checksum),
),
);
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
countDeleted += deadIcons.length;
@@ -0,0 +1,25 @@
import SuperJSON from "superjson";
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema";
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
import type { WidgetComponentProps } from "../../../widgets/src";
import { createCronJob } from "../lib";
export const minecraftServerStatusJob = createCronJob("minecraftServerStatus", EVERY_5_MINUTES).withCallback(
async () => {
const dbItems = await db.query.items.findMany({
where: eq(items.kind, "minecraftServerStatus"),
});
await Promise.allSettled(
dbItems.map(async (item) => {
const options = SuperJSON.parse<WidgetComponentProps<"minecraftServerStatus">["options"]>(item.options);
const innerHandler = minecraftServerStatusRequestHandler.handler(options);
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
);
},
);
@@ -0,0 +1 @@
ALTER TABLE `search_engine` ADD CONSTRAINT `search_engine_short_unique` UNIQUE(`short`);
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `default_search_engine_id` varchar(64);--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_default_search_engine_id_search_engine_id_fk` FOREIGN KEY (`default_search_engine_id`) REFERENCES `search_engine`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more