Compare commits

...
48 Commits
Author SHA1 Message Date
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
181 changed files with 15772 additions and 1575 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
+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");
+20 -16
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"
}, },
@@ -90,13 +94,13 @@
"@types/chroma-js": "3.1.1", "@types/chroma-js": "3.1.1",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@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.20.1",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"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,
});
+2 -2
View File
@@ -47,8 +47,8 @@
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"tsx": "4.19.2", "tsx": "4.19.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+3 -3
View File
@@ -26,8 +26,8 @@
"@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",
@@ -35,7 +35,7 @@
"@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.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"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.1",
"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
@@ -57,7 +57,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",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+5 -2
View File
@@ -111,16 +111,19 @@ 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,
}); });
return { appId: id };
}), }),
createMany: permissionRequiredProcedure createMany: permissionRequiredProcedure
.requiresPermission("app-create") .requiresPermission("app-create")
+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)
@@ -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,
}); });
+2 -2
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",
@@ -48,7 +48,7 @@
"@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.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"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,
}); });
+2 -1
View File
@@ -6,10 +6,11 @@ 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";
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;
}; };
@@ -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,
}); });
+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",
+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();
} }
+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);
}
} }
@@ -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;
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,27 @@
"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
} }
] ]
} }
+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;
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,27 @@
"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
} }
] ]
} }
+4 -3
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",
@@ -59,8 +60,8 @@
"@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.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"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";
+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 } 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(),
}); });
@@ -499,6 +510,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 +595,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 }) => ({
+26
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(),
}); });
@@ -486,6 +496,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 +581,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
@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.12.0", "@homarr/env": "workspace:^0.1.0",
"dockerode": "^4.0.4" "dockerode": "^4.0.4"
}, },
"devDependencies": { "devDependencies": {
+2 -8
View File
@@ -1,7 +1,6 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation"; import { createEnv } from "@homarr/env";
export const env = createEnv({ export const env = createEnv({
server: { server: {
@@ -9,10 +8,5 @@ export const env = createEnv({
DOCKER_HOSTNAMES: z.string().optional(), DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.string().optional(), DOCKER_PORTS: z.string().optional(),
}, },
runtimeEnv: { experimental__runtimeEnv: process.env,
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
DOCKER_PORTS: process.env.DOCKER_PORTS,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
}); });
+9
View File
@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
+1
View File
@@ -0,0 +1 @@
export * from "./src";
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@homarr/env",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts",
"./schemas": "./src/schemas.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "^0.12.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1",
"typescript": "^5.7.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { createEnv as createEnvT3 } from "@t3-oss/env-nextjs";
export const defaultEnvOptions = {
emptyStringAsUndefined: true,
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
} satisfies Partial<Parameters<typeof createEnvT3>[0]>;
export const createEnv: typeof createEnvT3 = (options) => createEnvT3({ ...defaultEnvOptions, ...options });
+39
View File
@@ -0,0 +1,39 @@
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
export const createBooleanSchema = (defaultValue: boolean) =>
z
.string()
.default(defaultValue.toString())
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
export const createDurationSchema = (defaultValue: `${number}${"s" | "m" | "h" | "d"}`) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1] as "s" | "m" | "h" | "d";
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -26,7 +26,7 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.16.3", "@mantine/form": "^7.17.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];
+1
View File
@@ -0,0 +1 @@
export * from "./src";
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@homarr/forms-collection",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"react": "19.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1",
"typescript": "^5.7.3"
}
}
@@ -24,7 +24,7 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media"; import { UploadMedia } from "../upload-media/upload-media";
import classes from "./icon-picker.module.css"; import classes from "./icon-picker.module.css";
interface IconPickerProps { interface IconPickerProps {
@@ -124,12 +124,7 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur
<InputBase <InputBase
flex={1} flex={1}
rightSection={<Combobox.Chevron />} rightSection={<Combobox.Chevron />}
leftSection={ leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search} value={search}
onChange={(event) => { onChange={(event) => {
combobox.openDropdown(); combobox.openDropdown();
+6
View File
@@ -0,0 +1,6 @@
export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./icon-picker/icon-picker";
export * from "./upload-media/upload-media";
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more