Compare commits

...
Author SHA1 Message Date
Manuel 3498fdaebb feat: implement cacheable lookup 2025-02-23 15:48:56 +01:00
Manuel 5dc5d9e04a fix: omv invalid temperature when not available (#2322) 2025-02-23 12:34:04 +01:00
homarr-crowdin[bot] 4f09363823 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-02-23 00:48:50 +00:00
Meier Lukas c132a07213 Merge branch 'main' into dev 2025-02-22 12:08:07 +01:00
homarr-renovate[bot] 86e778db1e fix(deps): update dependency prettier to ^3.5.2 (#2399)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-22 04:39:53 +00:00
homarr-crowdin[bot] ee1ed0d076 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-02-22 00:44:32 +00:00
homarr-renovate[bot] 675ed5bdc1 chore(deps): update dependency eslint to ^9.21.0 (#2395)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-21 22:17:43 +00:00
ManuelandMeier Lukas fb467ac165 feat: add app ping url (#2380)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-02-21 22:47:30 +01:00
homarr-renovate[bot] 9d54e938c8 chore(deps): update dependency @types/node to ^22.13.5 (#2394)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-21 20:44:17 +00:00
Releases Homarr f6029fa79d chore(release): 1.7.0 [skip ci] 2025-02-21 19:13:24 +00:00
homarr-releases[bot] 039d881418 chore(release): automatic release v1.7.0 2025-02-21 19:12:41 +00:00
Meier Lukas 4f220ffca1 ci: fix rebase for dev not working on release (#2392) 2025-02-21 19:31:02 +01:00
Meier Lukas a54e0783e2 fix(widgets): app icon not visible (#2390) 2025-02-21 16:57:19 +01:00
homarr-renovate[bot] 1cf36ed4c7 fix(deps): update tanstack-query monorepo to ^5.66.9 (#2391)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-21 13:42:06 +00:00
homarr-renovate[bot] afd7bcea84 fix(deps): update dependency next-intl to v3.26.5 (#2386)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-21 10:18:41 +00:00
homarr-renovate[bot] b58f64b05c fix(deps): update dependency ws to ^8.18.1 (#2387)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-21 09:55:24 +00:00
homarr-crowdin[bot] 118f19a4f2 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-02-21 00:46:35 +00:00
Meier Lukas 52549a598d fix(medias): svg files don't load in list (#2381) 2025-02-20 22:56:21 +01:00
Meier Lukas 311ae80667 feat: redirect to login screen when no global home board (#2379) 2025-02-20 21:46:08 +01:00
homarr-renovate[bot] 8b7596642a fix(deps): update tanstack-query monorepo to ^5.66.8 (#2376)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-20 13:27:36 +00:00
homarr-crowdin[bot] 078aacd9fa chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-02-20 00:45:49 +00:00
Jonathan Chemla bf600d0ae5 docs: Add links to all banners of readme (#2372) 2025-02-19 21:28:45 +01:00
Andre SilvaandAndre Silva de5c34ab07 feat(board): allow to set icon color of widgets (#2228)
Co-authored-by: Andre Silva <asilva01@acuitysso.com>
2025-02-19 20:45:12 +01:00
homarr-renovate[bot] 58babbf88f fix(deps): update dependency tsx to v4.19.3 (#2370)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-19 19:39:28 +00:00
homarr-crowdin[bot]andMeier Lukas 3cca0249a4 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-02-19 19:32:19 +00:00
Meier Lukas 59b1cb48eb test: pin pihole container for tests to v5 until v6 is implemented (#2368) 2025-02-19 20:20:38 +01:00
Meier Lukas 67d48e11d7 feat(logging): add log level env variable (#2299) 2025-02-18 22:54:15 +01:00
Manuel 6420feee72 feat: quick add app modal (#2248) 2025-02-18 22:53:44 +01:00
Yossi Hillali 63e96230e0 fix: omv integrations is unable to correctly retrieve the file system when using omv-zfs (#2337)
* fix: omv integrations is unable to correctly retrieve the file system when using omv-zfs
2025-02-18 22:05:09 +01:00
Meier Lukas 8b7caf1d7d feat(cli): add fix-username command to make all usernames lowercase (#2366)
* feat(cli): add fix-username command to make all usernames lowercase

* fix: add missing command
2025-02-18 22:02:42 +01:00
ManuelandMeier Lukas 7705bc44ae feat: add tile border radius (#2338)
* feat: add tile border radius

* fix: inconsistent mantine-core version

* fix: lockfile

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-02-18 21:53:45 +01:00
homarr-renovate[bot] 8e71b882db chore(deps): update vitest monorepo to ^3.0.6 (#2363)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-18 14:36:22 +00:00
homarr-renovate[bot] e84cc5e6ae fix(deps): update tanstack-query monorepo to ^5.66.7 (#2364)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-18 14:16:18 +00:00
homarr-renovate[bot] 241037fc2a fix(deps): update dependency next-intl to v3.26.4 (#2361)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-18 11:40:15 +00:00
homarr-renovate[bot] bd0d910137 fix(deps): update tanstack-query monorepo to ^5.66.6 (#2357)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-18 10:18:04 +00:00
homarr-renovate[bot] 146fe499db fix(deps): update dependency typescript-eslint to ^8.24.1 (#2360)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 17:34:10 +00:00
homarr-renovate[bot] 96a071725e fix(deps): update mantine monorepo to ^7.17.0 (#2359)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 16:59:56 +00:00
homarr-renovate[bot] 91c61f8c06 fix(deps): update dependency swagger-ui-react to ^5.19.0 (#2358)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 16:54:35 +00:00
homarr-renovate[bot] c59fbfc677 fix(deps): update dependency jotai to ^2.12.1 (#2354)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 13:45:08 +00:00
homarr-renovate[bot] 602faf6221 fix(deps): update tanstack-query monorepo to ^5.66.4 (#2355)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 13:28:29 +00:00
homarr-renovate[bot] cdc1d38be6 chore(deps): update react monorepo (#2351)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 09:46:54 +00:00
homarr-renovate[bot] 4764f41e1b fix(deps): update dependency mantine-react-table to v2.0.0-beta.9 (#2348)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 05:54:11 +00:00
homarr-renovate[bot] f6450cb4b4 chore(deps): update pnpm to v10.4.1 (#2345)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-17 00:57:59 +00:00
homarr-crowdin[bot] a4317f9479 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-02-17 00:47:25 +00:00
Jack Weller a62aa09db6 fix: typo in pr template (#2344) 2025-02-16 22:19:22 +01:00
homarr-renovate[bot] f32af66a6a fix(deps): update tanstack-query monorepo to ^5.66.3 (#2343)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-16 13:20:26 +00:00
homarr-renovate[bot] 4152ca7366 chore(deps): update dependency @types/react to v19.0.9 (#2342)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-16 12:59:57 +00:00
Meier Lukas ac941362de chore(issue-template): add missing homarr version 1.6.0 2025-02-16 11:42:00 +01:00
homarr-renovate[bot] f8fedefde9 fix(deps): update tanstack-query monorepo to ^5.66.2 (#2341)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-16 11:39:33 +01:00
homarr-crowdin[bot] 5154b6d17a chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-02-16 11:39:32 +01:00
homarr-renovate[bot]andMeier Lukas f6c923017c fix(deps): update dependency prettier to ^3.5.1 (#2284)
* fix(deps): update dependency prettier to ^3.5.1

* fix(deps): update dependency prettier to ^3.5.1

* fix: format issue

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-02-16 11:39:32 +01:00
Manuel 22a341ea4d feat: downgrade log level callback succeeded (#2329) 2025-02-16 11:39:32 +01:00
Manuel 3257f6101b fix: missing cover types media organizers (#2328) 2025-02-16 11:39:32 +01:00
homarr-renovate[bot] 9c4e5f9842 chore(deps): update dependency semantic-release to ^24.2.3 (#2339)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-16 11:39:31 +01:00
Meier Lukas ffe7259802 feat(groups): add home board settings (#2321) 2025-02-16 11:39:31 +01:00
homarr-renovate[bot] 33ef9f6678 fix(deps): update dependency octokit to ^4.1.2 (#2333)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-02-16 11:39:31 +01:00
homarr-crowdin[bot] 91bbc47b00 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-02-16 11:38:34 +01:00
210 changed files with 19926 additions and 1812 deletions

No files matched your search

+2
View File
@@ -12,6 +12,8 @@ AUTH_SECRET="supersecret"
# or starting the project without any (which will show a randomly generated one). # or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
LOG_LEVEL='info'
# This is how you can use the sqlite driver: # This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3' DB_DRIVER='better-sqlite3'
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
+1
View File
@@ -31,6 +31,7 @@ body:
label: Version label: Version
description: What version of Homarr are you running? description: What version of Homarr are you running?
options: options:
- 1.6.0
- 1.5.0 - 1.5.0
- 1.4.0 - 1.4.0
- 1.3.1 - 1.3.1
+1 -1
View File
@@ -6,7 +6,7 @@
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:** **Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``) - [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``)
- [ ] Pull request targets ``dev`` branch - [ ] Pull request targets ``dev`` branch
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation) - [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
@@ -96,10 +96,11 @@ jobs:
run: | run: |
git config user.name "Releases Homarr" git config user.name "Releases Homarr"
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com" git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
git fetch origin dev git fetch origin dev
git checkout dev git checkout dev
git pull origin dev git pull origin dev
git merge ${{ github.ref_name }} git rebase ${{ github.ref_name }}
git push origin dev git push origin dev
deploy: deploy:
name: Deploy docker image name: Deploy docker image
+13
View File
@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="typecheck" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="typecheck" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
+39
View File
@@ -1,3 +1,42 @@
## [1.7.0](https://github.com/homarr-labs/homarr/compare/v1.6.0...v1.7.0) (2025-02-21)
### Features
* add tile border radius ([#2338](https://github.com/homarr-labs/homarr/issues/2338)) ([7705bc4](https://github.com/homarr-labs/homarr/commit/7705bc44ae7c93751e718b019c98c11369c221af))
* **board:** allow to set icon color of widgets ([#2228](https://github.com/homarr-labs/homarr/issues/2228)) ([de5c34a](https://github.com/homarr-labs/homarr/commit/de5c34ab079c308c80cc3b1ba8f9c8a265e3a1c3))
* **cli:** add fix-username command to make all usernames lowercase ([#2366](https://github.com/homarr-labs/homarr/issues/2366)) ([8b7caf1](https://github.com/homarr-labs/homarr/commit/8b7caf1d7dfebb8121cefd301fa72ef59601d410))
* downgrade log level callback succeeded ([#2329](https://github.com/homarr-labs/homarr/issues/2329)) ([22a341e](https://github.com/homarr-labs/homarr/commit/22a341ea4d7365fb51781f96791394315afd8860))
* **groups:** add home board settings ([#2321](https://github.com/homarr-labs/homarr/issues/2321)) ([ffe7259](https://github.com/homarr-labs/homarr/commit/ffe72598026e5e27588b18feeaaf7d30065d5536))
* **logging:** add log level env variable ([#2299](https://github.com/homarr-labs/homarr/issues/2299)) ([67d48e1](https://github.com/homarr-labs/homarr/commit/67d48e11d7e1165540c66f86b299e55028d76788))
* quick add app modal ([#2248](https://github.com/homarr-labs/homarr/issues/2248)) ([6420fee](https://github.com/homarr-labs/homarr/commit/6420feee729aa641c0545fa39308772e4a4b1af8))
* redirect to login screen when no global home board ([#2379](https://github.com/homarr-labs/homarr/issues/2379)) ([311ae80](https://github.com/homarr-labs/homarr/commit/311ae80667715fccffbfa73699c0770d483c3434))
### Bug Fixes
* **deps:** update dependency jotai to ^2.12.1 ([#2354](https://github.com/homarr-labs/homarr/issues/2354)) ([c59fbfc](https://github.com/homarr-labs/homarr/commit/c59fbfc677b10ee20c2e698d9837af1aa098854b))
* **deps:** update dependency mantine-react-table to v2.0.0-beta.9 ([#2348](https://github.com/homarr-labs/homarr/issues/2348)) ([4764f41](https://github.com/homarr-labs/homarr/commit/4764f41e1bf77f7934840a84e25b2989fc1e7c7c))
* **deps:** update dependency next-intl to v3.26.4 ([#2361](https://github.com/homarr-labs/homarr/issues/2361)) ([241037f](https://github.com/homarr-labs/homarr/commit/241037fc2a699249f317e5e434ac603cee4e460d))
* **deps:** update dependency next-intl to v3.26.5 ([#2386](https://github.com/homarr-labs/homarr/issues/2386)) ([afd7bce](https://github.com/homarr-labs/homarr/commit/afd7bcea841e906003ec90516b033214ad412473))
* **deps:** update dependency octokit to ^4.1.2 ([#2333](https://github.com/homarr-labs/homarr/issues/2333)) ([33ef9f6](https://github.com/homarr-labs/homarr/commit/33ef9f66782a59047878a3e82330128fdcabfa93))
* **deps:** update dependency prettier to ^3.5.1 ([#2284](https://github.com/homarr-labs/homarr/issues/2284)) ([f6c9230](https://github.com/homarr-labs/homarr/commit/f6c923017c6b4ac9670843bba5dee2f52afd1918))
* **deps:** update dependency swagger-ui-react to ^5.19.0 ([#2358](https://github.com/homarr-labs/homarr/issues/2358)) ([91c61f8](https://github.com/homarr-labs/homarr/commit/91c61f8c06622893b90f668e30a615437d9bb549))
* **deps:** update dependency tsx to v4.19.3 ([#2370](https://github.com/homarr-labs/homarr/issues/2370)) ([58babbf](https://github.com/homarr-labs/homarr/commit/58babbf88fecc401bf4e7baa52636a4a937bead2))
* **deps:** update dependency typescript-eslint to ^8.24.1 ([#2360](https://github.com/homarr-labs/homarr/issues/2360)) ([146fe49](https://github.com/homarr-labs/homarr/commit/146fe499db0fe36c8822ad30a762738425fb6f50))
* **deps:** update dependency ws to ^8.18.1 ([#2387](https://github.com/homarr-labs/homarr/issues/2387)) ([b58f64b](https://github.com/homarr-labs/homarr/commit/b58f64b05c09070d8c5cdc7f89b23768b9997405))
* **deps:** update mantine monorepo to ^7.17.0 ([#2359](https://github.com/homarr-labs/homarr/issues/2359)) ([96a0717](https://github.com/homarr-labs/homarr/commit/96a071725e557e64d8951091063f047cb43a385b))
* **deps:** update tanstack-query monorepo to ^5.66.2 ([#2341](https://github.com/homarr-labs/homarr/issues/2341)) ([f8fedef](https://github.com/homarr-labs/homarr/commit/f8fedefde9ae65923369b46876009f9f6cb27683))
* **deps:** update tanstack-query monorepo to ^5.66.3 ([#2343](https://github.com/homarr-labs/homarr/issues/2343)) ([f32af66](https://github.com/homarr-labs/homarr/commit/f32af66a6ad4676530265bfd1052b56308d4d40f))
* **deps:** update tanstack-query monorepo to ^5.66.4 ([#2355](https://github.com/homarr-labs/homarr/issues/2355)) ([602faf6](https://github.com/homarr-labs/homarr/commit/602faf622140af1cc4ac92f8a54a56924d809f67))
* **deps:** update tanstack-query monorepo to ^5.66.6 ([#2357](https://github.com/homarr-labs/homarr/issues/2357)) ([bd0d910](https://github.com/homarr-labs/homarr/commit/bd0d910137194caef989f4dba360bcaf616e9ea4))
* **deps:** update tanstack-query monorepo to ^5.66.7 ([#2364](https://github.com/homarr-labs/homarr/issues/2364)) ([e84cc5e](https://github.com/homarr-labs/homarr/commit/e84cc5e6ae32c78d4e2be4d57e2b97ad84094f5e))
* **deps:** update tanstack-query monorepo to ^5.66.8 ([#2376](https://github.com/homarr-labs/homarr/issues/2376)) ([8b75966](https://github.com/homarr-labs/homarr/commit/8b7596642a8398af817eedbe2dd89e9bad3fd937))
* **deps:** update tanstack-query monorepo to ^5.66.9 ([#2391](https://github.com/homarr-labs/homarr/issues/2391)) ([1cf36ed](https://github.com/homarr-labs/homarr/commit/1cf36ed4c776d6c99e1e1058dde9129475069aaf))
* **medias:** svg files don't load in list ([#2381](https://github.com/homarr-labs/homarr/issues/2381)) ([52549a5](https://github.com/homarr-labs/homarr/commit/52549a598d039a9fa2165983698212564eb5233b))
* missing cover types media organizers ([#2328](https://github.com/homarr-labs/homarr/issues/2328)) ([3257f61](https://github.com/homarr-labs/homarr/commit/3257f6101bdd8967089655507144dc4368390b8c))
* omv integrations is unable to correctly retrieve the file system when using omv-zfs ([#2337](https://github.com/homarr-labs/homarr/issues/2337)) ([63e9623](https://github.com/homarr-labs/homarr/commit/63e96230e0e2831c787d3e520a6b8e9b4bddb2a7))
* typo in pr template ([#2344](https://github.com/homarr-labs/homarr/issues/2344)) ([a62aa09](https://github.com/homarr-labs/homarr/commit/a62aa09db6c87573d579bcebc9f5d5c3ac133af2))
* **widgets:** app icon not visible ([#2390](https://github.com/homarr-labs/homarr/issues/2390)) ([a54e078](https://github.com/homarr-labs/homarr/commit/a54e0783e2ad452c4db3d7ddb4e4bcd501a8687a))
## [1.6.0](https://github.com/homarr-labs/homarr/compare/v1.5.0...v1.6.0) (2025-02-14) ## [1.6.0](https://github.com/homarr-labs/homarr/compare/v1.5.0...v1.6.0) (2025-02-14)
### Features ### Features
+1 -2
View File
@@ -2,14 +2,13 @@
import "@homarr/auth/env"; import "@homarr/auth/env";
import "@homarr/db/env"; import "@homarr/db/env";
import "@homarr/common/env"; import "@homarr/common/env";
import "@homarr/log/env";
import "@homarr/docker/env"; import "@homarr/docker/env";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import MillionLint from "@million/lint"; import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.ts";
// Package path does not work... so we need to use relative path // Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts"); const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
+22 -18
View File
@@ -15,6 +15,10 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
@@ -26,6 +30,7 @@
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0", "@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0", "@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
@@ -43,18 +48,17 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.16.3", "@mantine/colors-generator": "^7.17.0",
"@mantine/core": "^7.16.3", "@mantine/core": "^7.17.0",
"@mantine/dropzone": "^7.16.3", "@mantine/dropzone": "^7.17.0",
"@mantine/hooks": "^7.16.3", "@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.16.3", "@mantine/modals": "^7.17.0",
"@mantine/tiptap": "^7.16.3", "@mantine/tiptap": "^7.17.0",
"@million/lint": "1.0.14", "@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.12.0",
"@tabler/icons-react": "^3.30.0", "@tabler/icons-react": "^3.30.0",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.9",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.9",
"@tanstack/react-query-next-experimental": "^5.66.0", "@tanstack/react-query-next-experimental": "^5.66.9",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -68,8 +72,8 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"flag-icons": "^7.3.2", "flag-icons": "^7.3.2",
"glob": "^11.0.1", "glob": "^11.0.1",
"jotai": "^2.12.0", "jotai": "^2.12.1",
"mantine-react-table": "2.0.0-beta.8", "mantine-react-table": "2.0.0-beta.9",
"next": "15.1.7", "next": "15.1.7",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
@@ -79,7 +83,7 @@
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.85.0", "sass": "^1.85.0",
"superjson": "2.2.2", "superjson": "2.2.2",
"swagger-ui-react": "^5.18.3", "swagger-ui-react": "^5.19.0",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
@@ -88,15 +92,15 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1", "@types/chroma-js": "3.1.1",
"@types/node": "^22.13.4", "@types/node": "^22.13.5",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "19.0.8", "@types/react": "19.0.10",
"@types/react-dom": "19.0.3", "@types/react-dom": "19.0.4",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
@@ -20,8 +20,7 @@ import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { env } from "~/env";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side // window is not defined on server side
@@ -66,7 +65,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) =>
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}), }),
splitLink({ splitLink({
condition: ({ type }) => type === "subscription", condition: ({ type }) => type === "subscription",
@@ -23,6 +23,7 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -33,7 +34,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; import type { MantineColorsTuple } from "@mantine/core";
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core"; import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
colors: { colors: {
primaryColor: generateColors(board.primaryColor), primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor), secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,
@@ -10,6 +10,7 @@ import {
Group, Group,
InputWrapper, InputWrapper,
isLightColor, isLightColor,
Select,
Slider, Slider,
Stack, Stack,
Text, Text,
@@ -39,6 +40,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
primaryColor: board.primaryColor, primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor, secondaryColor: board.secondaryColor,
opacity: board.opacity, opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
}, },
}); });
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
@@ -98,6 +101,26 @@ export const ColorSettingsContent = ({ board }: Props) => {
/> />
</InputWrapper> </InputWrapper>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("iconColor")}
/>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid> </Grid>
<Group justify="end"> <Group justify="end">
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -23,10 +23,10 @@ import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior"; import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access"; import { BoardAccessSettings } from "./_board-access";
import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss"; import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger"; import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general"; import { GeneralSettingsContent } from "./_general";
@@ -91,7 +91,7 @@ export default async function BoardSettingsPage(props: Props) {
<AccordionItemFor value="background" icon={IconPhoto}> <AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} /> <BackgroundSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="color" icon={IconBrush}> <AccordionItemFor value="appearance" icon={IconBrush}>
<ColorSettingsContent board={board} /> <ColorSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>
@@ -1,8 +1,9 @@
import type { JSX, PropsWithChildren } from "react"; import type { JSX, PropsWithChildren } from "react";
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context"; import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode"; import { EditModeProvider } from "@homarr/boards/edit-mode";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
@@ -32,8 +33,14 @@ export const createBoardLayout = <TParams extends Params>({
}: PropsWithChildren<{ }: PropsWithChildren<{
params: Promise<TParams>; params: Promise<TParams>;
}>) => { }>) => {
const session = await auth();
const initialBoard = await getInitialBoard(await params).catch((error) => { const initialBoard = await getInitialBoard(await params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
redirect("/auth/login");
}
logger.warn(error); logger.warn(error);
notFound(); notFound();
} }
@@ -7,12 +7,11 @@ import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation"; import type { validation } from "@homarr/validation";
import { AppForm } from "../../_form";
interface AppEditFormProps { interface AppEditFormProps {
app: RouterOutputs["app"]["byId"]; app: RouterOutputs["app"]["byId"];
} }
@@ -58,6 +57,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
initialValues={app} initialValues={app}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
isPending={isPending} isPending={isPending}
showBackToOverview
/> />
); );
}; };
@@ -2,10 +2,10 @@ import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { AppNewForm } from "@homarr/forms-collection";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth(); const session = await auth();
@@ -22,7 +22,7 @@ export default async function AppNewPage() {
<Container> <Container>
<Stack> <Stack>
<Title>{t("app.page.create.title")}</Title> <Title>{t("app.page.create.title")}</Title>
<AppNewForm /> <AppNewForm showBackToOverview showCreateAnother />
</Stack> </Stack>
</Container> </Container>
</> </>
@@ -1,15 +1,11 @@
"use client"; "use client";
import type { JSX } from "react"; import { Button } from "@mantine/core";
import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react"; import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { MaybePromise } from "@homarr/common/types"; import { UploadMedia } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMediaButton = () => { export const UploadMediaButton = () => {
const t = useI18n(); const t = useI18n();
@@ -27,45 +23,3 @@ export const UploadMediaButton = () => {
</UploadMedia> </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();
const handleFileUploadAsync = async (file: File | null) => {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
await mutateAsync(formData, {
async onSuccess(mediaId) {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
await onSuccess?.({
id: mediaId,
url: `/api/user-medias/${mediaId}`,
});
},
onError() {
showErrorNotification({
message: t("media.action.upload.notification.error.message"),
});
},
async onSettled() {
await onSettled?.();
},
});
};
return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => children({ onClick, loading: isPending })}
</FileButton>
);
};
@@ -1,10 +1,10 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
ActionIcon, ActionIcon,
Anchor, Anchor,
Group, Group,
Image,
Stack, Stack,
Table, Table,
TableTbody, TableTbody,
@@ -113,11 +113,12 @@ const Row = async ({ media }: RowProps) => {
<TableTr> <TableTr>
<TableTd w={64}> <TableTd w={64}>
<Image <Image
// Switched to mantine image because next/image doesn't support svgs
src={createLocalImageUrl(media.id)} src={createLocalImageUrl(media.id)}
alt={media.name} alt={media.name}
width={64} w={64}
height={64} h={64}
style={{ objectFit: "contain" }} fit="contain"
/> />
</TableTd> </TableTd>
<TableTd>{media.name}</TableTd> <TableTd>{media.name}</TableTd>
@@ -9,12 +9,11 @@ import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions"; import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.searchEngine.manage>; type FormType = z.infer<typeof validation.searchEngine.manage>;
interface SearchEngineFormProps { interface SearchEngineFormProps {
@@ -1,13 +1,12 @@
"use client"; "use client";
import { Group, Switch, Text } from "@mantine/core"; import { Switch, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings"; import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { BoardSelect } from "~/components/board/board-select";
import { CommonSettingsForm } from "./common-form"; import { CommonSettingsForm } from "./common-form";
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => { export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
@@ -18,42 +17,19 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}> <CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => ( {(form) => (
<> <>
<SelectWithCustomItems <BoardSelect
label={tBoard("homeBoard.label")} label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")} description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({ clearable
value: board.id, boards={selectableBoards}
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("homeBoardId")} {...form.getInputProps("homeBoardId")}
/> />
<SelectWithCustomItems
<BoardSelect
label={tBoard("homeBoard.mobileLabel")} label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")} description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({ clearable
value: board.id, boards={selectableBoards}
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("mobileHomeBoardId")} {...form.getInputProps("mobileHomeBoardId")}
/> />
@@ -1434,26 +1434,30 @@
} }
::-webkit-scrollbar-button:vertical:start:decrement { ::-webkit-scrollbar-button:vertical:start:decrement {
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), background:
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%); linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:vertical:end:increment { ::-webkit-scrollbar-button:vertical:end:increment {
background: linear-gradient(310deg, #696969 40%, transparent 41%), background:
linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%); linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%),
linear-gradient(180deg, #696969 40%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:end:increment { ::-webkit-scrollbar-button:horizontal:end:increment {
background: linear-gradient(210deg, #696969 40%, transparent 41%), background:
linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%); linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%),
linear-gradient(90deg, #696969 30%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:start:decrement { ::-webkit-scrollbar-button:horizontal:start:decrement {
background: linear-gradient(30deg, #696969 40%, transparent 41%), background:
linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%); linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%),
linear-gradient(270deg, #696969 30%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
@@ -1681,28 +1685,32 @@
} }
::-webkit-scrollbar-button:vertical:start:decrement { ::-webkit-scrollbar-button:vertical:start:decrement {
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), background:
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%); linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:vertical:end:increment { ::-webkit-scrollbar-button:vertical:end:increment {
background: linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%), background:
linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%); linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:end:increment { ::-webkit-scrollbar-button:horizontal:end:increment {
background: linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%), background:
linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%); linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:start:decrement { ::-webkit-scrollbar-button:horizontal:start:decrement {
background: linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%), background:
linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%); linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Button, Group, Select, Stack } from "@mantine/core"; import { Button, Group, Stack } from "@mantine/core";
import type { z } from "zod"; import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -11,9 +11,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
interface ChangeHomeBoardFormProps { interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"]; user: RouterOutputs["user"]["getById"];
boardsData: { value: string; label: string }[]; boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
} }
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => { export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Select <BoardSelect
label={t("management.page.user.setting.general.item.board.type.general")} label={t("management.page.user.setting.general.item.board.type.general")}
clearable
boards={boardsData}
w="100%" w="100%"
data={boardsData}
{...form.getInputProps("homeBoardId")} {...form.getInputProps("homeBoardId")}
/> />
<Select <BoardSelect
label={t("management.page.user.setting.general.item.board.type.mobile")} label={t("management.page.user.setting.general.item.board.type.mobile")}
clearable
boards={boardsData}
w="100%" w="100%"
data={boardsData}
{...form.getInputProps("mobileHomeBoardId")} {...form.getInputProps("mobileHomeBoardId")}
/> />
@@ -95,8 +95,9 @@ export default async function EditUserPage(props: Props) {
<ChangeHomeBoardForm <ChangeHomeBoardForm
user={user} user={user}
boardsData={boards.map((board) => ({ boardsData={boards.map((board) => ({
value: board.id, id: board.id,
label: board.name, name: board.name,
logoImageUrl: board.logoImageUrl,
}))} }))}
/> />
</Stack> </Stack>
@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core"; import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react"; import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
<NavigationLink <NavigationLink
href={`/manage/users/groups/${params.id}`} href={`/manage/users/groups/${params.id}`}
label={tGroup("setting.general.title")} label={tGroup("setting.general.title")}
icon={<IconId size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/groups/${params.id}/settings`}
label={tGroup("setting.setting.title")}
icon={<IconSettings size="1rem" stroke={1.5} />} icon={<IconSettings size="1rem" stroke={1.5} />}
/> />
<NavigationLink <NavigationLink
@@ -0,0 +1,81 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { BoardSelect } from "~/components/board/board-select";
interface GroupHomeBoardsProps {
homeBoardId: string | null;
mobileHomeBoardId: string | null;
groupId: string;
}
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
const t = useI18n();
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
initialValues: {
homeBoardId,
mobileHomeBoardId,
},
});
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
const handleSubmit = form.onSubmit(async (values) => {
await mutateAsync(
{
id: groupId,
settings: values,
},
{
onSuccess() {
form.setInitialValues(values);
showSuccessNotification({
title: t("group.action.settings.board.notification.success.title"),
message: t("group.action.settings.board.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("group.action.settings.board.notification.error.title"),
message: t("group.action.settings.board.notification.error.message"),
});
},
},
);
});
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<BoardSelect
label={t("group.field.homeBoard.label")}
description={t("group.field.homeBoard.description")}
clearable
boards={availableBoards}
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={t("group.field.mobileBoard.label")}
description={t("group.field.mobileBoard.description")}
clearable
boards={availableBoards}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { Alert, Stack, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { GroupHomeBoards } from "./_group-home-boards";
interface GroupSettingsPageProps {
params: Promise<{
id: string;
}>;
}
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const group = await api.group.getById({ id: params.id });
const t = await getI18n();
return (
<Stack>
<Title>{t("management.page.group.setting.setting.title")}</Title>
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t("management.page.group.setting.setting.alert")}
</Alert>
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
</Stack>
);
}
@@ -1,24 +0,0 @@
"use client";
import { useCallback } from "react";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const AddGroup = () => {
const t = useI18n();
const { openModal } = useModalAction(AddGroupModal);
const handleAddGroup = useCallback(() => {
openModal();
}, [openModal]);
return (
<MobileAffixButton onClick={handleAddGroup} color="teal">
{t("group.action.create.label")}
</MobileAffixButton>
);
};
@@ -0,0 +1,65 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Group, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { GroupsTable } from "./_groups-table";
interface GroupsListProps {
groups: RouterOutputs["group"]["getAll"];
}
export const GroupsList = ({ groups }: GroupsListProps) => {
const [search, setSearch] = useState("");
const initialGroupIds = useMemo(
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
[groups],
);
const filteredGroups = useMemo(
() =>
groups
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
.sort((groupA, groupB) => groupA.position - groupB.position),
[groups, search],
);
const t = useI18n();
return (
<>
<Group justify="space-between">
<TextInput
leftSection={<IconSearch size={20} stroke={1.5} />}
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={`${t("group.search")}...`}
style={{ flex: 1 }}
/>
<AddGroup />
</Group>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</>
);
};
const AddGroup = () => {
const t = useI18n();
const { openModal } = useModalAction(AddGroupModal);
const handleAddGroup = useCallback(() => {
openModal();
}, [openModal]);
return (
<MobileAffixButton onClick={handleAddGroup} color="teal">
{t("group.action.create.label")}
</MobileAffixButton>
);
};
@@ -0,0 +1,277 @@
"use client";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Anchor,
Box,
Button,
Card,
Flex,
Group,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Transition,
} from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatarGroup } from "@homarr/ui";
interface GroupsTableProps {
initialGroupIds: string[];
groups: RouterOutputs["group"]["getAll"];
hasFilter: boolean;
}
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
const t = useI18n();
const [activeId, setActiveId] = useState<string | null>(null);
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
const isDirty = useMemo(
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
[groupIds, initialGroupIds],
);
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
const handleSavePositionsAsync = async () => {
await mutateAsync(
{ positions: groupIds },
{
async onSuccess() {
showSuccessNotification({
message: t("group.action.changePosition.notification.success.message"),
});
await revalidatePathActionAsync("/manage/users/groups");
},
onError() {
showSuccessNotification({
message: t("group.action.changePosition.notification.error.message"),
});
},
},
);
};
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) {
setActiveId(null);
return;
}
setGroupIds((groupIds) => {
const oldIndex = groupIds.indexOf(active.id as string);
const newIndex = groupIds.indexOf(over.id as string);
return arrayMove(groupIds, oldIndex, newIndex);
});
}
function handleDragCancel() {
setActiveId(null);
}
const selectedRow = useMemo(() => {
if (!activeId) return null;
const current = groups.find((group) => group.id === activeId);
if (!current) return null;
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
}, [activeId, groups]);
return (
<>
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
id="groups-table"
>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>{t("group.field.name")}</TableTh>
<TableTh>{t("group.field.members")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
{groupIds.map((groupId) => {
const group = groups.find(({ id }) => id === groupId);
if (!group) return null;
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
})}
</SortableContext>
</TableTbody>
</Table>
<DragOverlay>
{activeId && (
<Table w="100%">
<TableTbody>{selectedRow}</TableTbody>
</Table>
)}
</DragOverlay>
</DndContext>
<SaveAffix
visible={isDirty}
onDiscard={() => setGroupIds(initialGroupIds)}
isPending={isPending}
onSave={handleSavePositionsAsync}
/>
</>
);
};
interface DraggableRowProps {
group: RouterOutputs["group"]["getAll"][number];
disabled?: boolean;
}
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id: group.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
if (isDragging) {
return (
<TableTr ref={setNodeRef} style={style}>
<TableTd colSpan={2}>&nbsp;</TableTd>
</TableTr>
);
}
return (
<Row
group={group}
setNodeRef={setNodeRef}
style={style}
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
/>
);
};
interface RowProps {
group: RouterOutputs["group"]["getAll"][number];
handle?: ReactNode;
setNodeRef?: (node: HTMLElement | null) => void;
style?: React.CSSProperties;
}
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
return (
<TableTr ref={setNodeRef} style={style}>
<TableTd>
<Group>
{handle}
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
{group.name}
</Anchor>
</Group>
</TableTd>
<TableTd>
<UserAvatarGroup users={group.members} size="sm" limit={5} />
</TableTd>
</TableTr>
);
};
interface DragHandleProps {
attributes: DraggableAttributes | undefined;
listeners: SyntheticListenerMap | undefined;
active: boolean;
disabled?: boolean;
}
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
if (disabled) {
return <Box w={40} h="100%" />;
}
return (
<Flex
align="center"
justify="center"
h="100%"
w={40}
style={{ cursor: active ? "grabbing" : "grab" }}
{...attributes}
{...listeners}
>
<IconGripVertical size={18} stroke={1.5} />
</Flex>
);
};
interface SaveAffixProps {
visible: boolean;
isPending: boolean;
onDiscard: () => void;
onSave: () => void;
}
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
const t = useI18n();
return (
<div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={visible}>
{(transitionStyles) => (
<Card style={transitionStyles} withBorder>
<Group justify="space-between">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} onClick={onDiscard}>
{t("common.action.discard")}
</Button>
<Button color="teal" loading={isPending} onClick={onSave}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</Transition>
</div>
);
};
@@ -0,0 +1,7 @@
.everyoneGroup {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.everyoneGroup:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
@@ -1,30 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core"; import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
import { z } from "zod"; import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { everyoneGroup } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AddGroup } from "./_add-group"; import { GroupsList } from "./_client";
import classes from "./groups.module.css";
const searchParamsSchema = z.object({ export default async function GroupsListPage() {
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
interface GroupsListPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("admin")) { if (!session?.user.permissions.includes("admin")) {
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
} }
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(await props.searchParams); const groups = await api.group.getAll();
const { items: groups, totalCount } = await api.group.getPaginated(searchParams); const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return ( return (
<ManageContainer size="xl"> <ManageContainer size="xl">
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("group.title")}</Title> <Title>{t("group.title")}</Title>
<Group justify="space-between">
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
<AddGroup />
</Group>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>{t("group.field.name")}</TableTh>
<TableTh>{t("group.field.members")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{groups.map((group) => (
<Row key={group.id} group={group} />
))}
</TableTbody>
</Table>
<Group justify="end"> {dbEveryoneGroup && (
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} /> <UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
</Group> <Card className={classes.everyoneGroup}>
<Group align="center">
<ThemeIcon radius="xl" variant="light">
<IconUsersGroup size={16} />
</ThemeIcon>
<Stack gap={0} flex={1}>
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
<Text size="sm" c="gray.6">
{t("group.defaultGroup.description", { name: everyoneGroup })}
</Text>
</Stack>
<IconChevronRight size={20} />
</Group>
</Card>
</UnstyledButton>
)}
<GroupsList groups={groupsWithoutEveryone} />
</Stack> </Stack>
</ManageContainer> </ManageContainer>
); );
} }
interface RowProps {
group: RouterOutputs["group"]["getPaginated"]["items"][number];
}
const Row = ({ group }: RowProps) => {
return (
<TableTr>
<TableTd>
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
{group.name}
</Anchor>
</TableTd>
<TableTd>
<UserAvatarGroup users={group.members} size="sm" limit={5} />
</TableTd>
</TableTr>
);
};
@@ -1,11 +1,11 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Center } from "@mantine/core"; import { Center } from "@mantine/core";
import { env } from "@homarr/common/env";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets";
import { env } from "~/env";
import { WidgetPreviewPageContent } from "./_content"; import { WidgetPreviewPageContent } from "./_content";
interface Props { interface Props {
@@ -0,0 +1,33 @@
import { Group, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import type { SelectWithCustomItemsProps } from "@homarr/ui";
import { SelectWithCustomItems } from "@homarr/ui";
import type { Board } from "~/app/[locale]/boards/_types";
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
}
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
return (
<SelectWithCustomItems
{...props}
data={boards.map((board) => ({
value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
/>
);
};
@@ -33,6 +33,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
"grid-stack-item-content", "grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "), item.advancedOptions.customCssClasses.join(" "),
)} )}
radius={board.itemRadius}
withBorder withBorder
styles={{ styles={{
root: { root: {
@@ -27,7 +27,13 @@ export const BoardCategorySection = ({ section }: Props) => {
}); });
return ( return (
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}> <Card
style={{ "--opacity": board.opacity / 100 }}
radius={board.itemRadius}
withBorder
p={0}
className={classes.itemCard}
>
<Stack> <Stack>
<Group wrap="nowrap" gap="sm"> <Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}> <UnstyledButton w="100%" p="sm" onClick={toggle}>
@@ -26,6 +26,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
overflow: "hidden", overflow: "hidden",
}, },
}} }}
radius={board.itemRadius}
p={0} p={0}
> >
<GridStack section={section} className="min-row" /> <GridStack section={section} className="min-row" />
-33
View File
@@ -1,33 +0,0 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
export const env = createEnv({
shared: {
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
},
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
PORT: process.env.PORT,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
});
+4 -4
View File
@@ -44,11 +44,11 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.13.4", "@types/node": "^22.13.5",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"tsx": "4.19.2", "tsx": "4.19.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+4 -4
View File
@@ -26,16 +26,16 @@
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"tsx": "4.19.2", "tsx": "4.19.3",
"ws": "^8.18.0" "ws": "^8.18.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+5 -5
View File
@@ -1,5 +1,5 @@
<!-- Project Title --> <!-- Project Title -->
![Banner](./banner.png) [![Banner](./banner.png)](https://homarr.dev/)
<!-- Badges --> <!-- Badges -->
<p align="center"> <p align="center">
@@ -36,7 +36,7 @@
</p> </p>
![Features Section](./section-features.png) [![Features Section](./section-features.png)](https://homarr.dev/)
- 🖌️ Highly customizable with an extensive drag and drop grid system - 🖌️ Highly customizable with an extensive drag and drop grid system
- ✨ Integrates seamlessly with your favorite self-hosted applications - ✨ Integrates seamlessly with your favorite self-hosted applications
@@ -53,7 +53,7 @@
<br/> <br/>
<br/> <br/>
![Widgets & Integrations Section](./section-widgets-and-integrations.png) [![Widgets & Integrations Section](./section-widgets-and-integrations.png)](https://homarr.dev/docs/category/widgets)
Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/category/integrations), that connect to your applications and enable you to control them directly from the dashboard. Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/category/integrations), that connect to your applications and enable you to control them directly from the dashboard.
@@ -88,7 +88,7 @@ Homarr has a [built-in collection of widgets and integrations](https://homarr.de
<br/> <br/>
<br/> <br/>
![Installation Section](./section-installation.png) [![Installation Section](./section-installation.png)](https://homarr.dev/docs/category/installation-1)
Since we are updating Homarr very frequently, we recommend reading our official installation guides: Since we are updating Homarr very frequently, we recommend reading our official installation guides:
@@ -101,7 +101,7 @@ Since we are updating Homarr very frequently, we recommend reading our official
<br/> <br/>
<br/> <br/>
![Contribute Section](./section-contribute.png) [![Contribute Section](./section-contribute.png)](https://opencollective.com/homarr)
<br/> <br/>
+1
View File
@@ -22,6 +22,7 @@ export class OnboardingActions {
await this.db.insert(sqliteSchema.groups).values({ await this.db.insert(sqliteSchema.groups).values({
id: createId(), id: createId(),
name: input.group, name: input.group,
position: 1,
}); });
} }
} }
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.6.0", "version": "1.7.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -40,20 +40,20 @@
"@semantic-release/release-notes-generator": "^14.0.3", "@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.4.2", "@turbo/gen": "^2.4.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.5", "@vitest/coverage-v8": "^3.0.6",
"@vitest/ui": "^3.0.5", "@vitest/ui": "^3.0.6",
"conventional-changelog-conventionalcommits": "^8.0.0", "conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"semantic-release": "^24.2.2", "semantic-release": "^24.2.3",
"testcontainers": "^10.18.0", "testcontainers": "^10.18.0",
"turbo": "^2.4.2", "turbo": "^2.4.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.5" "vitest": "^3.0.6"
}, },
"packageManager": "pnpm@10.4.0", "packageManager": "pnpm@10.4.1",
"engines": { "engines": {
"node": ">=22.14.0" "node": ">=22.14.0"
}, },
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+2 -2
View File
@@ -56,8 +56,8 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+13 -3
View File
@@ -56,7 +56,11 @@ export const appRouter = createTRPCRouter({
}), }),
selectable: protectedProcedure selectable: protectedProcedure
.input(z.void()) .input(z.void())
.output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true }))) .output(
z.array(
selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
),
)
.meta({ .meta({
openapi: { openapi: {
method: "GET", method: "GET",
@@ -73,6 +77,7 @@ export const appRouter = createTRPCRouter({
iconUrl: true, iconUrl: true,
description: true, description: true,
href: true, href: true,
pingUrl: true,
}, },
orderBy: asc(apps.name), orderBy: asc(apps.name),
}); });
@@ -111,16 +116,20 @@ export const appRouter = createTRPCRouter({
create: permissionRequiredProcedure create: permissionRequiredProcedure
.requiresPermission("app-create") .requiresPermission("app-create")
.input(validation.app.manage) .input(validation.app.manage)
.output(z.void()) .output(z.object({ appId: z.string() }))
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(apps).values({ await ctx.db.insert(apps).values({
id: createId(), id,
name: input.name, name: input.name,
description: input.description, description: input.description,
iconUrl: input.iconUrl, iconUrl: input.iconUrl,
href: input.href, href: input.href,
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
}); });
return { appId: id };
}), }),
createMany: permissionRequiredProcedure createMany: permissionRequiredProcedure
.requiresPermission("app-create") .requiresPermission("app-create")
@@ -161,6 +170,7 @@ export const appRouter = createTRPCRouter({
description: input.description, description: input.description,
iconUrl: input.iconUrl, iconUrl: input.iconUrl,
href: input.href, href: input.href,
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
}) })
.where(eq(apps.id, input.id)); .where(eq(apps.id, input.id));
}), }),
+81 -8
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server"; import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db"; import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db"; import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { import {
boardGroupPermissions, boardGroupPermissions,
@@ -13,6 +13,7 @@ import {
boardUserPermissions, boardUserPermissions,
groupMembers, groupMembers,
groupPermissions, groupPermissions,
groups,
integrationGroupPermissions, integrationGroupPermissions,
integrationItems, integrationItems,
integrationUserPermissions, integrationUserPermissions,
@@ -22,7 +23,7 @@ import {
users, users,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions"; import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import"; import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared"; import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema"; import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -57,6 +58,37 @@ export const boardRouter = createTRPCRouter({
where: eq(boards.isPublic, true), where: eq(boards.isPublic, true),
}); });
}), }),
getBoardsForGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ groupId: z.string() }))
.query(async ({ ctx, input }) => {
const dbEveryoneAndCurrentGroup = await ctx.db.query.groups.findMany({
where: or(eq(groups.name, everyoneGroup), eq(groups.id, input.groupId)),
with: {
boardPermissions: true,
permissions: true,
},
});
const distinctPermissions = new Set(
dbEveryoneAndCurrentGroup.flatMap((group) => group.permissions.map(({ permission }) => permission)),
);
const canViewAllBoards = getPermissionsWithChildren([...distinctPermissions]).includes("board-view-all");
const boardIds = dbEveryoneAndCurrentGroup.flatMap((group) =>
group.boardPermissions.map(({ boardId }) => boardId),
);
const boardWhere = canViewAllBoards ? undefined : or(eq(boards.isPublic, true), inArray(boards.id, boardIds));
return await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
},
where: boardWhere,
});
}),
getAllBoards: publicProcedure.query(async ({ ctx }) => { getAllBoards: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id; const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({ const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
@@ -89,6 +121,7 @@ export const boardRouter = createTRPCRouter({
columns: { columns: {
id: true, id: true,
name: true, name: true,
logoImageUrl: true,
isPublic: true, isPublic: true,
}, },
with: { with: {
@@ -478,12 +511,14 @@ export const boardRouter = createTRPCRouter({
primaryColor: input.primaryColor, primaryColor: input.primaryColor,
secondaryColor: input.secondaryColor, secondaryColor: input.secondaryColor,
opacity: input.opacity, opacity: input.opacity,
iconColor: input.iconColor,
// custom css // custom css
customCss: input.customCss, customCss: input.customCss,
// layout settings // layout settings
columnCount: input.columnCount, columnCount: input.columnCount,
itemRadius: input.itemRadius,
// Behavior settings // Behavior settings
disableStatus: input.disableStatus, disableStatus: input.disableStatus,
@@ -975,9 +1010,13 @@ export const boardRouter = createTRPCRouter({
* For an example of a user with deviceType = 'mobile' it would go through the following order: * For an example of a user with deviceType = 'mobile' it would go through the following order:
* 1. user.mobileHomeBoardId * 1. user.mobileHomeBoardId
* 2. user.homeBoardId * 2. user.homeBoardId
* 3. serverSettings.mobileHomeBoardId * 3. group.mobileHomeBoardId of the lowest positions group
* 4. serverSettings.homeBoardId * 4. group.homeBoardId of the lowest positions group
* 5. show NOT_FOUND error * 5. everyoneGroup.mobileHomeBoardId
* 6. everyoneGroup.homeBoardId
* 7. serverSettings.mobileHomeBoardId
* 8. serverSettings.homeBoardId
* 9. show NOT_FOUND error
*/ */
const getHomeIdBoardAsync = async ( const getHomeIdBoardAsync = async (
db: Database, db: Database,
@@ -985,12 +1024,46 @@ const getHomeIdBoardAsync = async (
deviceType: DeviceType, deviceType: DeviceType,
) => { ) => {
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId"; const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (user?.[settingKey] || user?.homeBoardId) {
return user[settingKey] ?? user.homeBoardId; if (!user) {
} else {
const boardSettings = await getServerSettingByKeyAsync(db, "board"); const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId; return boardSettings[settingKey] ?? boardSettings.homeBoardId;
} }
if (user[settingKey]) return user[settingKey];
if (user.homeBoardId) return user.homeBoardId;
const lowestGroupExceptEveryone = await db
.select({
homeBoardId: groups.homeBoardId,
mobileHomeBoardId: groups.mobileHomeBoardId,
})
.from(groups)
.leftJoin(groupMembers, eq(groups.id, groupMembers.groupId))
.where(
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
.limit(1)
.then((result) => result[0]);
if (lowestGroupExceptEveryone?.[settingKey]) return lowestGroupExceptEveryone[settingKey];
if (lowestGroupExceptEveryone?.homeBoardId) return lowestGroupExceptEveryone.homeBoardId;
const dbEveryoneGroup = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (dbEveryoneGroup?.[settingKey]) return dbEveryoneGroup[settingKey];
if (dbEveryoneGroup?.homeBoardId) return dbEveryoneGroup.homeBoardId;
const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
}; };
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => { const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
+69 -1
View File
@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db"; import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema"; import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions"; import { everyoneGroup } from "@homarr/definitions";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -12,6 +13,30 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({ export const groupRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
return dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
}));
}),
getPaginated: permissionRequiredProcedure getPaginated: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input(validation.common.paginated) .input(validation.common.paginated)
@@ -153,10 +178,13 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name); await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const groupId = createId(); const groupId = createId();
await ctx.db.insert(groups).values({ await ctx.db.insert(groups).values({
id: groupId, id: groupId,
name: input.name, name: input.name,
position: maxPosition + 1,
}); });
await ctx.db.insert(groupPermissions).values({ await ctx.db.insert(groupPermissions).values({
@@ -172,10 +200,13 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name); await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const id = createId(); const id = createId();
await ctx.db.insert(groups).values({ await ctx.db.insert(groups).values({
id, id,
name: input.name, name: input.name,
position: maxPosition + 1,
ownerId: ctx.session.user.id, ownerId: ctx.session.user.id,
}); });
@@ -197,6 +228,43 @@ export const groupRouter = createTRPCRouter({
}) })
.where(eq(groups.id, input.id)); .where(eq(groups.id, input.id));
}), }),
savePartialSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePartialSettings)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db
.update(groups)
.set({
homeBoardId: input.settings.homeBoardId,
mobileHomeBoardId: input.settings.mobileHomeBoardId,
})
.where(eq(groups.id, input.id));
}),
savePositions: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePositions)
.mutation(async ({ input, ctx }) => {
const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (trx) => {
for (const { id, position } of positions) {
await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
}
});
},
handleSync: (db) => {
db.transaction((trx) => {
for (const { id, position } of positions) {
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
}
});
},
});
}),
savePermissions: permissionRequiredProcedure savePermissions: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input(validation.group.savePermissions) .input(validation.group.savePermissions)
+6
View File
@@ -158,6 +158,7 @@ describe("create should create a new app with all arguments", () => {
description: "React components and hooks library", description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg", iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev", href: "https://mantine.dev",
pingUrl: "https://mantine.dev/a",
}; };
// Act // Act
@@ -170,6 +171,7 @@ describe("create should create a new app with all arguments", () => {
expect(dbApp!.description).toBe(input.description); expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl); expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href); expect(dbApp!.href).toBe(input.href);
expect(dbApp!.pingUrl).toBe(input.pingUrl);
}); });
test("should create a new app only with required arguments", async () => { test("should create a new app only with required arguments", async () => {
@@ -185,6 +187,7 @@ describe("create should create a new app with all arguments", () => {
description: null, description: null,
iconUrl: "https://mantine.dev/favicon.svg", iconUrl: "https://mantine.dev/favicon.svg",
href: null, href: null,
pingUrl: "",
}; };
// Act // Act
@@ -197,6 +200,7 @@ describe("create should create a new app with all arguments", () => {
expect(dbApp!.description).toBe(input.description); expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl); expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href); expect(dbApp!.href).toBe(input.href);
expect(dbApp!.pingUrl).toBe(null);
}); });
}); });
@@ -225,6 +229,7 @@ describe("update should update an app", () => {
description: "React components and hooks library", description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg2", iconUrl: "https://mantine.dev/favicon.svg2",
href: "https://mantine.dev", href: "https://mantine.dev",
pingUrl: "https://mantine.dev/a",
}; };
// Act // Act
@@ -257,6 +262,7 @@ describe("update should update an app", () => {
iconUrl: "https://mantine.dev/favicon.svg", iconUrl: "https://mantine.dev/favicon.svg",
description: null, description: null,
href: null, href: null,
pingUrl: "",
}); });
// Assert // Assert
@@ -205,6 +205,7 @@ describe("getAllBoards should return all boards accessable to the current user",
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "group1", name: "group1",
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
@@ -1166,6 +1167,7 @@ describe("getBoardPermissions should return board permissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "group1", name: "group1",
position: 1,
}); });
await db.insert(boardGroupPermissions).values({ await db.insert(boardGroupPermissions).values({
@@ -1260,6 +1262,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "group1", name: "group1",
position: 1,
}); });
const boardId = createId(); const boardId = createId();
@@ -43,6 +43,7 @@ describe("paginated should return a list of groups with pagination", () => {
[1, 2, 3, 4, 5].map((number) => ({ [1, 2, 3, 4, 5].map((number) => ({
id: number.toString(), id: number.toString(),
name: `Group ${number}`, name: `Group ${number}`,
position: number,
})), })),
); );
@@ -66,6 +67,7 @@ describe("paginated should return a list of groups with pagination", () => {
[1, 2, 3, 4, 5].map((number) => ({ [1, 2, 3, 4, 5].map((number) => ({
id: number.toString(), id: number.toString(),
name: `Group ${number}`, name: `Group ${number}`,
position: number,
})), })),
); );
@@ -89,6 +91,7 @@ describe("paginated should return a list of groups with pagination", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
groupId, groupId,
@@ -123,6 +126,7 @@ describe("paginated should return a list of groups with pagination", () => {
["first", "second", "third", "forth", "fifth"].map((key, index) => ({ ["first", "second", "third", "forth", "fifth"].map((key, index) => ({
id: index.toString(), id: index.toString(),
name: key, name: key,
position: index + 1,
})), })),
); );
@@ -163,10 +167,12 @@ describe("byId should return group by id including members and permissions", ()
{ {
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: "Another group", name: "Another group",
position: 2,
}, },
]); ]);
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
@@ -202,6 +208,7 @@ describe("byId should return group by id including members and permissions", ()
await db.insert(groups).values({ await db.insert(groups).values({
id: "2", id: "2",
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -278,6 +285,7 @@ describe("create should create group in database", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: similarName, name: similarName,
position: 1,
}); });
// Act // Act
@@ -314,10 +322,12 @@ describe("update should update name with value that is no duplicate", () => {
{ {
id: groupId, id: groupId,
name: initialValue, name: initialValue,
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: "Third", name: "Third",
position: 2,
}, },
]); ]);
@@ -347,10 +357,12 @@ describe("update should update name with value that is no duplicate", () => {
{ {
id: groupId, id: groupId,
name: "Something", name: "Something",
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: initialDuplicate, name: initialDuplicate,
position: 2,
}, },
]); ]);
@@ -373,6 +385,7 @@ describe("update should update name with value that is no duplicate", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "something", name: "something",
position: 1,
}); });
// Act // Act
@@ -413,6 +426,7 @@ describe("savePermissions should save permissions for group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}); });
await db.insert(groupPermissions).values({ await db.insert(groupPermissions).values({
groupId, groupId,
@@ -442,6 +456,7 @@ describe("savePermissions should save permissions for group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -494,6 +509,7 @@ describe("transferOwnership should transfer ownership of group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
// Act // Act
@@ -518,6 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -559,10 +576,12 @@ describe("deleteGroup should delete group", () => {
{ {
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: "Another group", name: "Another group",
position: 2,
}, },
]); ]);
@@ -586,6 +605,7 @@ describe("deleteGroup should delete group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -638,6 +658,7 @@ describe("addMember should add member to group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
// Act // Act
@@ -715,6 +736,7 @@ describe("addMember should add member to group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
// Act // Act
@@ -753,6 +775,7 @@ describe("removeMember should remove member from group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
groupId, groupId,
@@ -833,6 +856,7 @@ describe("removeMember should remove member from group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
groupId, groupId,
+3
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth"; import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, createId, eq, like } from "@homarr/db"; import { and, createId, eq, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema"; import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas"; import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions"; import { credentialsAdminGroup } from "@homarr/definitions";
@@ -31,12 +32,14 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled(); throwIfCredentialsDisabled();
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const userId = await createUserAsync(ctx.db, input); const userId = await createUserAsync(ctx.db, input);
const groupId = createId(); const groupId = createId();
await ctx.db.insert(groups).values({ await ctx.db.insert(groups).values({
id: groupId, id: groupId,
name: credentialsAdminGroup, name: credentialsAdminGroup,
ownerId: userId, ownerId: userId,
position: maxPosition + 1,
}); });
await ctx.db.insert(groupPermissions).values({ await ctx.db.insert(groupPermissions).values({
groupId, groupId,
+4 -32
View File
@@ -1,8 +1,8 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
import { supportedAuthProviders } from "@homarr/definitions"; import { supportedAuthProviders } from "@homarr/definitions";
import { createEnv } from "@homarr/env";
import { createBooleanSchema, createDurationSchema } from "@homarr/env/schemas";
const authProvidersSchema = z const authProvidersSchema = z
.string() .string()
@@ -22,8 +22,7 @@ const authProvidersSchema = z
) )
.default("credentials"); .default("credentials");
const skipValidation = shouldSkipEnvValidation(); const authProviders = authProvidersSchema.safeParse(process.env.AUTH_PROVIDERS).data ?? [];
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
export const env = createEnv({ export const env = createEnv({
server: { server: {
@@ -59,32 +58,5 @@ export const env = createEnv({
} }
: {}), : {}),
}, },
client: {}, experimental__runtimeEnv: process.env,
runtimeEnv: {
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG,
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
AUTH_LDAP_SEARCH_SCOPE: process.env.AUTH_LDAP_SEARCH_SCOPE,
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
AUTH_OIDC_ISSUER: process.env.AUTH_OIDC_ISSUER,
AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE,
AUTH_OIDC_GROUPS_ATTRIBUTE: process.env.AUTH_OIDC_GROUPS_ATTRIBUTE,
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
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,
emptyStringAsUndefined: true,
}); });
+3 -3
View File
@@ -28,9 +28,9 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.12.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "7.3.1", "ldapts": "7.3.1",
@@ -47,8 +47,8 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0", "@types/cookies": "0.9.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
@@ -272,7 +272,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
}, },
]; ];
await db.insert(boards).values(createMockBoard({ id: "1" })); await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(groups).values({ id: "1", name: "" }); await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" }); await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
@@ -325,7 +325,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
}, },
]; ];
await db.insert(boards).values(createMockBoard({ id: "1" })); await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(groups).values({ id: "1", name: "" }); await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" }); await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
@@ -379,7 +379,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
]; ];
await db.insert(boards).values(createMockBoard({ id: "1" })); await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(boards).values(createMockBoard({ id: "2" })); await db.insert(boards).values(createMockBoard({ id: "2" }));
await db.insert(groups).values({ id: "1", name: "" }); await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" }); await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" }); await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
@@ -301,6 +301,7 @@ describe("authorizeWithLdapCredentials", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "homarr_example", name: "homarr_example",
position: 1,
}); });
// Act // Act
+3
View File
@@ -25,6 +25,7 @@ describe("getCurrentUserPermissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: "2", id: "2",
name: "test", name: "test",
position: 1,
}); });
await db.insert(groupPermissions).values({ await db.insert(groupPermissions).values({
groupId: "2", groupId: "2",
@@ -51,6 +52,7 @@ describe("getCurrentUserPermissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: "2", id: "2",
name: "test", name: "test",
position: 1,
}); });
await db.insert(groupPermissions).values({ await db.insert(groupPermissions).values({
groupId: "2", groupId: "2",
@@ -81,6 +83,7 @@ describe("getCurrentUserPermissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: mockId, id: mockId,
name: "test", name: "test",
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
userId: mockId, userId: mockId,
+1
View File
@@ -259,4 +259,5 @@ const createGroupAsync = async (db: Database, name = "test") =>
await db.insert(groups).values({ await db.insert(groups).values({
id: "1", id: "1",
name, name,
position: 1,
}); });
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+3 -1
View File
@@ -23,13 +23,15 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"cacheable-lookup": "^7.0.0",
"undici": "7.3.0" "undici": "7.3.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+46 -1
View File
@@ -6,10 +6,20 @@ import { rootCertificates } from "node:tls";
import axios from "axios"; import axios from "axios";
import { fetch } from "undici"; import { fetch } from "undici";
import { env } from "@homarr/common/env";
import { LoggingAgent } from "@homarr/common/server"; import { LoggingAgent } from "@homarr/common/server";
import CacheableLookup from "cacheable-lookup";
import type { LookupOptions } from "node:dns";
import { logger } from "@homarr/log";
const cacheableLookup = new CacheableLookup({
maxTtl: 6_00,
errorTtl: 60,
fallbackDuration: 3600
});
const getCertificateFolder = () => { const getCertificateFolder = () => {
return process.env.NODE_ENV === "production" return env.NODE_ENV === "production"
? path.join("/appdata", "trusted-certificates") ? path.join("/appdata", "trusted-certificates")
: process.env.LOCAL_CERTIFICATE_PATH; : process.env.LOCAL_CERTIFICATE_PATH;
}; };
@@ -65,15 +75,50 @@ export const createCertificateAgentAsync = async () => {
return new LoggingAgent({ return new LoggingAgent({
connect: { connect: {
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)), ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
// Override the DNS lookup with the https://www.npmjs.com/package/cacheable-lookup library lookup (see https://github.com/nodejs/undici/issues/2440#issuecomment-2181215481)
lookup(hostname, lookupOptions, callback) {
logger.info(`looking up ${hostname} with cacheable lookup`);
cacheableLookup.lookup(hostname, { all: lookupOptions.all, hints: lookupOptions.hints, family: convertToNodeJsIPFamily(lookupOptions.family) }, (err, address, family) => {
if (err) {
logger.error(`DNS Lookup failed for ${hostname}: ${err}`);
} else {
logger.info(`Cacheable Lookup for ${hostname}: ${JSON.stringify(address)}, family: ${family}`);
}
callback(err, address, family);
});
}
}, },
keepAliveTimeout: 10_000, // Keep connection open for 10s
keepAliveMaxTimeout: 30_000,
pipelining: 0
}); });
}; };
/**
* Converts the family
* @see https://nodejs.org/api/dns.html#dns_dns_lookup_hostname_options_callback
* @param lookupOptionsFamily the family that should be converted
*/
const convertToNodeJsIPFamily = (lookupOptionsFamily: LookupOptions['family']) => {
switch (lookupOptionsFamily) {
case 'IPv4': {
return 4;
}
case 'IPv6': {
return 6;
}
default: {
return undefined;
}
}
}
export const createAxiosCertificateInstanceAsync = async () => { export const createAxiosCertificateInstanceAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync(); const customCertificates = await loadCustomRootCertificatesAsync();
return axios.create({ return axios.create({
httpsAgent: new Agent({ httpsAgent: new Agent({
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)), ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
keepAlive: true
}), }),
}); });
}; };
+1 -1
View File
@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
@@ -0,0 +1,36 @@
import { command } from "@drizzle-team/brocli";
import { db, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
export const fixUsernames = command({
name: "fix-usernames",
desc: "Changes all credentials usernames to lowercase",
// eslint-disable-next-line no-restricted-syntax
handler: async () => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
const credentialUsers = await db.query.users.findMany({
where: eq(users.provider, "credentials"),
});
for (const user of credentialUsers) {
if (!user.name) continue;
if (user.name !== user.name.toLowerCase()) continue;
await db
.update(users)
.set({
name: user.name.toLowerCase(),
})
.where(eq(users.id, user.id));
console.log(`Changed username from ${user.name} to ${user.name.toLowerCase()}`);
}
console.log("All usernames have been fixed");
},
});
+2 -1
View File
@@ -1,8 +1,9 @@
import { run } from "@drizzle-team/brocli"; import { run } from "@drizzle-team/brocli";
import { fixUsernames } from "./commands/fix-usernames";
import { resetPassword } from "./commands/reset-password"; import { resetPassword } from "./commands/reset-password";
const commands = [resetPassword]; const commands = [resetPassword, fixUsernames];
void run(commands, { void run(commands, {
name: "homarr-cli", name: "homarr-cli",
+5 -4
View File
@@ -1,12 +1,14 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { shouldSkipEnvValidation } from "./src/env-validation"; import { createEnv } from "@homarr/env";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`; const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
export const env = createEnv({ export const env = createEnv({
shared: {
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
},
server: { server: {
SECRET_ENCRYPTION_KEY: z SECRET_ENCRYPTION_KEY: z
.string({ .string({
@@ -24,7 +26,6 @@ export const env = createEnv({
}, },
runtimeEnv: { runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY, SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
NODE_ENV: process.env.NODE_ENV,
}, },
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
}); });
+2 -1
View File
@@ -27,6 +27,7 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "15.1.7", "next": "15.1.7",
@@ -39,7 +40,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+7
View File
@@ -1,9 +1,12 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale"; import updateLocale from "dayjs/plugin/updateLocale";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.extend(updateLocale); dayjs.extend(updateLocale);
dayjs.extend(duration);
dayjs.updateLocale("en", { dayjs.updateLocale("en", {
relativeTime: { relativeTime: {
future: "in %s", future: "in %s",
@@ -38,6 +41,10 @@ export class Stopwatch {
return dayjs().millisecond(this.startTime).fromNow(true); return dayjs().millisecond(this.startTime).fromNow(true);
} }
getElapsedInMilliseconds() {
return performance.now() - this.startTime;
}
reset() { reset() {
this.startTime = performance.now(); this.startTime = performance.now();
} }
+1 -1
View File
@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+10 -1
View File
@@ -16,6 +16,7 @@ export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
interface CreateCronJobOptions { interface CreateCronJobOptions {
runOnStart?: boolean; runOnStart?: boolean;
expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>; beforeStart?: () => MaybePromise<void>;
} }
@@ -25,6 +26,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
options: CreateCronJobOptions, options: CreateCronJobOptions,
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>, creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => { ) => {
const expectedMaximumDurationInMillis = options.expectedMaximumDurationInMillis ?? 1000;
return (callback: () => MaybePromise<void>) => { return (callback: () => MaybePromise<void>) => {
const catchingCallbackAsync = async () => { const catchingCallbackAsync = async () => {
try { try {
@@ -34,9 +36,16 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
const beforeCallbackTook = stopwatch.getElapsedInHumanWords(); const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
await callback(); await callback();
const callbackTook = stopwatch.getElapsedInHumanWords(); const callbackTook = stopwatch.getElapsedInHumanWords();
creatorOptions.logger.logInfo( creatorOptions.logger.logDebug(
`The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`, `The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`,
); );
const durationInMillis = stopwatch.getElapsedInMilliseconds();
if (durationInMillis > expectedMaximumDurationInMillis) {
creatorOptions.logger.logWarning(
`The callback of '${name}' succeeded but took ${(durationInMillis - expectedMaximumDurationInMillis).toFixed(2)}ms longer than expected (${expectedMaximumDurationInMillis}ms). This may indicate that your network performance, host performance or something else is too slow. If this happens too often, it should be looked into.`,
);
}
await creatorOptions.onCallbackSuccess?.(name); await creatorOptions.onCallbackSuccess?.(name);
} catch (error) { } catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+5
View File
@@ -2,6 +2,7 @@ export interface Logger {
logDebug(message: string): void; logDebug(message: string): void;
logInfo(message: string): void; logInfo(message: string): void;
logError(error: unknown): void; logError(error: unknown): void;
logWarning(message: string): void;
} }
export class ConsoleLogger implements Logger { export class ConsoleLogger implements Logger {
@@ -16,4 +17,8 @@ export class ConsoleLogger implements Logger {
public logError(error: unknown) { public logError(error: unknown) {
console.error(error); console.error(error);
} }
public logWarning(message: string) {
console.warn(message);
}
} }
+1 -1
View File
@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
@@ -11,6 +11,7 @@ import { createCronJob } from "../lib";
export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, { export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
runOnStart: true, runOnStart: true,
expectedMaximumDurationInMillis: 10 * 1000,
}).withCallback(async () => { }).withCallback(async () => {
logger.info("Updating icon repository cache..."); logger.info("Updating icon repository cache...");
const stopWatch = new Stopwatch(); const stopWatch = new Stopwatch();
+4
View File
@@ -16,6 +16,10 @@ class WinstonCronJobLogger implements Logger {
logError(error: unknown) { logError(error: unknown) {
logger.error(error); logger.error(error);
} }
logWarning(message: string) {
logger.warn(message);
}
} }
export const { createCronJob, createCronJobGroup } = createCronJobFunctions< export const { createCronJob, createCronJobGroup } = createCronJobFunctions<
+4 -17
View File
@@ -1,7 +1,7 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation"; import { env as commonEnv } from "@homarr/common/env";
import { createEnv } from "@homarr/env";
const drivers = { const drivers = {
betterSqlite3: "better-sqlite3", betterSqlite3: "better-sqlite3",
@@ -29,7 +29,7 @@ export const env = createEnv({
? { ? {
DB_URL: DB_URL:
// Fallback to the default sqlite file path in production // Fallback to the default sqlite file path in production
process.env.NODE_ENV === "production" && isDriver("better-sqlite3") commonEnv.NODE_ENV === "production" && isDriver("better-sqlite3")
? z.string().default("/appdata/db/db.sqlite") ? z.string().default("/appdata/db/db.sqlite")
: z.string().nonempty(), : z.string().nonempty(),
} }
@@ -49,18 +49,5 @@ export const env = createEnv({
} }
: {}), : {}),
}, },
/** experimental__runtimeEnv: process.env,
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
DB_DRIVER: process.env.DB_DRIVER,
DB_URL: process.env.DB_URL,
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
DB_PORT: process.env.DB_PORT,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
}); });
@@ -0,0 +1,25 @@
ALTER TABLE `group` ADD `home_board_id` varchar(64);
--> statement-breakpoint
ALTER TABLE `group` ADD `mobile_home_board_id` varchar(64);
--> statement-breakpoint
ALTER TABLE `group` ADD `position` smallint;
--> statement-breakpoint
CREATE TABLE `temp_group` (
`id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`position` smallint NOT NULL
);
--> statement-breakpoint
INSERT INTO `temp_group`(`id`, `name`, `position`) SELECT `id`, `name`, ROW_NUMBER() OVER(ORDER BY `name`) FROM `group` WHERE `name` != 'everyone';
--> statement-breakpoint
UPDATE `group` SET `position`=(SELECT `position` FROM `temp_group` WHERE `temp_group`.`id`=`group`.`id`);
--> statement-breakpoint
DROP TABLE `temp_group`;
--> statement-breakpoint
UPDATE `group` SET `position` = -1 WHERE `name` = 'everyone';
--> statement-breakpoint
ALTER TABLE `group` MODIFY `position` smallint NOT NULL;
--> statement-breakpoint
ALTER TABLE `group` ADD CONSTRAINT `group_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `group` ADD CONSTRAINT `group_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
@@ -0,0 +1 @@
ALTER TABLE `board` ADD `item_radius` text DEFAULT ('lg') NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `board` ADD `icon_color` text;
@@ -0,0 +1 @@
ALTER TABLE `app` ADD `ping_url` text;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -176,6 +176,34 @@
"when": 1738961147412, "when": 1738961147412,
"tag": "0024_mean_vin_gonzales", "tag": "0024_mean_vin_gonzales",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "5",
"when": 1739469710187,
"tag": "0025_add-group-home-board-settings",
"breakpoints": true
},
{
"idx": 26,
"version": "5",
"when": 1739907771355,
"tag": "0026_add-border-radius",
"breakpoints": true
},
{
"idx": 27,
"version": "5",
"when": 1739915526818,
"tag": "0027_acoustic_karma",
"breakpoints": true
},
{
"idx": 28,
"version": "5",
"when": 1740086765989,
"tag": "0028_add_app_ping_url",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -28,6 +28,7 @@ const seedEveryoneGroupAsync = async (db: Database) => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: everyoneGroup, name: everyoneGroup,
position: -1,
}); });
console.log("Created group 'everyone' through seed"); console.log("Created group 'everyone' through seed");
}; };
@@ -0,0 +1,33 @@
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys = OFF;
--> statement-breakpoint
BEGIN TRANSACTION;
--> statement-breakpoint
CREATE TABLE `__new_group` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`owner_id` text,
`home_board_id` text,
`mobile_home_board_id` text,
`position` integer NOT NULL,
FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", -1 FROM `group` WHERE "name" = 'everyone';
--> statement-breakpoint
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", ROW_NUMBER() OVER(ORDER BY "name") FROM `group` WHERE "name" != 'everyone';
--> statement-breakpoint
DROP TABLE `group`;
--> statement-breakpoint
ALTER TABLE `__new_group` RENAME TO `group`;
--> statement-breakpoint
CREATE UNIQUE INDEX `group_name_unique` ON `group` (`name`);
--> statement-breakpoint
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys = ON;
--> statement-breakpoint
BEGIN TRANSACTION;
@@ -0,0 +1 @@
ALTER TABLE `board` ADD `item_radius` text DEFAULT 'lg' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `board` ADD `icon_color` text;
@@ -0,0 +1 @@
ALTER TABLE `app` ADD `ping_url` text;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -176,6 +176,34 @@
"when": 1738961178990, "when": 1738961178990,
"tag": "0024_bitter_scrambler", "tag": "0024_bitter_scrambler",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1739468826756,
"tag": "0025_add-group-home-board-settings",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1739907755789,
"tag": "0026_add-border-radius",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1739915486467,
"tag": "0027_wooden_blizzard",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1740086746417,
"tag": "0028_add_app_ping_url",
"breakpoints": true
} }
] ]
} }
+5 -4
View File
@@ -40,10 +40,11 @@
"@auth/core": "^0.37.4", "@auth/core": "^0.37.4",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@t3-oss/env-nextjs": "^0.12.0",
"@testcontainers/mysql": "^10.18.0", "@testcontainers/mysql": "^10.18.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@@ -58,9 +59,9 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.12", "@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"tsx": "4.19.2", "tsx": "4.19.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+11
View File
@@ -0,0 +1,11 @@
import { max } from "drizzle-orm";
import type { HomarrDatabase } from "../driver";
import { groups } from "../schema";
export const getMaxGroupPositionAsync = async (db: HomarrDatabase) => {
return await db
.select({ value: max(groups.position) })
.from(groups)
.then((result) => result[0]?.value ?? 1);
};
+1
View File
@@ -1,2 +1,3 @@
export * from "./item"; export * from "./item";
export * from "./server-setting"; export * from "./server-setting";
export * from "./group";
+28
View File
@@ -1,4 +1,5 @@
import type { AdapterAccount } from "@auth/core/adapters"; import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineSize } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates"; import type { DayOfWeek } from "@mantine/dates";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core"; import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
@@ -9,6 +10,7 @@ import {
int, int,
mysqlTable, mysqlTable,
primaryKey, primaryKey,
smallint,
text, text,
timestamp, timestamp,
tinyint, tinyint,
@@ -150,6 +152,13 @@ export const groups = mysqlTable("group", {
ownerId: varchar({ length: 64 }).references(() => users.id, { ownerId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
homeBoardId: varchar({ length: 64 }).references(() => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: varchar({ length: 64 }).references(() => boards.id, {
onDelete: "set null",
}),
position: smallint().notNull(),
}); });
export const groupPermissions = mysqlTable("groupPermission", { export const groupPermissions = mysqlTable("groupPermission", {
@@ -272,6 +281,8 @@ export const boards = mysqlTable("board", {
opacity: int().default(100).notNull(), opacity: int().default(100).notNull(),
customCss: text(), customCss: text(),
columnCount: int().default(10).notNull(), columnCount: int().default(10).notNull(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
disableStatus: boolean().default(false).notNull(), disableStatus: boolean().default(false).notNull(),
}); });
@@ -365,6 +376,7 @@ export const apps = mysqlTable("app", {
description: text(), description: text(),
iconUrl: text().notNull(), iconUrl: text().notNull(),
href: text(), href: text(),
pingUrl: text(),
}); });
export const integrationItems = mysqlTable( export const integrationItems = mysqlTable(
@@ -499,6 +511,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
fields: [groups.ownerId], fields: [groups.ownerId],
references: [users.id], references: [users.id],
}), }),
homeBoard: one(boards, {
fields: [groups.homeBoardId],
references: [boards.id],
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: one(boards, {
fields: [groups.mobileHomeBoardId],
references: [boards.id],
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({ export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
@@ -574,6 +596,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
}), }),
userPermissions: many(boardUserPermissions), userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions), groupPermissions: many(boardGroupPermissions),
groupHomes: many(groups, {
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: many(groups, {
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const sectionRelations = relations(sections, ({ many, one }) => ({ export const sectionRelations = relations(sections, ({ many, one }) => ({
+27
View File
@@ -1,4 +1,5 @@
import type { AdapterAccount } from "@auth/core/adapters"; import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineSize } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates"; import type { DayOfWeek } from "@mantine/dates";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core"; import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
@@ -133,6 +134,13 @@ export const groups = sqliteTable("group", {
ownerId: text().references(() => users.id, { ownerId: text().references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
homeBoardId: text().references(() => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: text().references(() => boards.id, {
onDelete: "set null",
}),
position: int().notNull(),
}); });
export const groupPermissions = sqliteTable("groupPermission", { export const groupPermissions = sqliteTable("groupPermission", {
@@ -258,6 +266,8 @@ export const boards = sqliteTable("board", {
opacity: int().default(100).notNull(), opacity: int().default(100).notNull(),
customCss: text(), customCss: text(),
columnCount: int().default(10).notNull(), columnCount: int().default(10).notNull(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
disableStatus: int({ mode: "boolean" }).default(false).notNull(), disableStatus: int({ mode: "boolean" }).default(false).notNull(),
}); });
@@ -351,6 +361,7 @@ export const apps = sqliteTable("app", {
description: text(), description: text(),
iconUrl: text().notNull(), iconUrl: text().notNull(),
href: text(), href: text(),
pingUrl: text(),
}); });
export const integrationItems = sqliteTable( export const integrationItems = sqliteTable(
@@ -486,6 +497,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
fields: [groups.ownerId], fields: [groups.ownerId],
references: [users.id], references: [users.id],
}), }),
homeBoard: one(boards, {
fields: [groups.homeBoardId],
references: [boards.id],
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: one(boards, {
fields: [groups.mobileHomeBoardId],
references: [boards.id],
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({ export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
@@ -561,6 +582,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
}), }),
userPermissions: many(boardUserPermissions), userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions), groupPermissions: many(boardGroupPermissions),
groupHomes: many(groups, {
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: many(groups, {
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const sectionRelations = relations(sections, ({ many, one }) => ({ export const sectionRelations = relations(sections, ({ many, one }) => ({
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more