Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3498fdaebb | ||
|
|
5dc5d9e04a | ||
|
|
4f09363823 | ||
|
|
c132a07213 | ||
|
|
86e778db1e | ||
|
|
ee1ed0d076 | ||
|
|
675ed5bdc1 | ||
|
|
fb467ac165 | ||
|
|
9d54e938c8 | ||
|
|
f6029fa79d | ||
|
|
039d881418 | ||
|
|
4f220ffca1 | ||
|
|
a54e0783e2 | ||
|
|
1cf36ed4c7 | ||
|
|
afd7bcea84 | ||
|
|
b58f64b05c | ||
|
|
118f19a4f2 | ||
|
|
52549a598d | ||
|
|
311ae80667 | ||
|
|
8b7596642a | ||
|
|
078aacd9fa | ||
|
|
bf600d0ae5 | ||
|
|
de5c34ab07 | ||
|
|
58babbf88f | ||
|
|
3cca0249a4 | ||
|
|
59b1cb48eb | ||
|
|
67d48e11d7 | ||
|
|
6420feee72 | ||
|
|
63e96230e0 | ||
|
|
8b7caf1d7d | ||
|
|
7705bc44ae | ||
|
|
8e71b882db | ||
|
|
e84cc5e6ae | ||
|
|
241037fc2a | ||
|
|
bd0d910137 | ||
|
|
146fe499db | ||
|
|
96a071725e | ||
|
|
91c61f8c06 | ||
|
|
c59fbfc677 | ||
|
|
602faf6221 | ||
|
|
cdc1d38be6 | ||
|
|
4764f41e1b | ||
|
|
f6450cb4b4 | ||
|
|
a4317f9479 | ||
|
|
a62aa09db6 | ||
|
|
f32af66a6a | ||
|
|
4152ca7366 | ||
|
|
ac941362de | ||
|
|
f8fedefde9 | ||
|
|
5154b6d17a | ||
|
|
f6c923017c | ||
|
|
22a341ea4d | ||
|
|
3257f6101b | ||
|
|
9c4e5f9842 | ||
|
|
ffe7259802 | ||
|
|
33ef9f6678 | ||
|
|
91bbc47b00 |
No files matched your search
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="typecheck" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="typecheck" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
import "@homarr/auth/env";
|
import "@homarr/auth/env";
|
||||||
import "@homarr/db/env";
|
import "@homarr/db/env";
|
||||||
import "@homarr/common/env";
|
import "@homarr/common/env";
|
||||||
|
import "@homarr/log/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import MillionLint from "@million/lint";
|
import MillionLint from "@million/lint";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
import "./src/env.ts";
|
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||||
|
|
||||||
|
|||||||
+22
-18
@@ -15,6 +15,10 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
@@ -26,6 +30,7 @@
|
|||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/docker": "workspace:^0.1.0",
|
"@homarr/docker": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
|
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||||
"@homarr/gridstack": "^1.12.0",
|
"@homarr/gridstack": "^1.12.0",
|
||||||
"@homarr/icons": "workspace:^0.1.0",
|
"@homarr/icons": "workspace:^0.1.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
@@ -43,18 +48,17 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.16.3",
|
"@mantine/colors-generator": "^7.17.0",
|
||||||
"@mantine/core": "^7.16.3",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/dropzone": "^7.16.3",
|
"@mantine/dropzone": "^7.17.0",
|
||||||
"@mantine/hooks": "^7.16.3",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^7.16.3",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/tiptap": "^7.16.3",
|
"@mantine/tiptap": "^7.17.0",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@tabler/icons-react": "^3.30.0",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.9",
|
||||||
"@tanstack/react-query-devtools": "^5.66.0",
|
"@tanstack/react-query-devtools": "^5.66.9",
|
||||||
"@tanstack/react-query-next-experimental": "^5.66.0",
|
"@tanstack/react-query-next-experimental": "^5.66.9",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -68,8 +72,8 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"flag-icons": "^7.3.2",
|
"flag-icons": "^7.3.2",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
"jotai": "^2.12.0",
|
"jotai": "^2.12.1",
|
||||||
"mantine-react-table": "2.0.0-beta.8",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.85.0",
|
"sass": "^1.85.0",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"swagger-ui-react": "^5.18.3",
|
"swagger-ui-react": "^5.19.0",
|
||||||
"use-deep-compare-effect": "^1.8.1",
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
@@ -88,15 +92,15 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "3.1.1",
|
"@types/chroma-js": "3.1.1",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.5",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "19.0.8",
|
"@types/react": "19.0.10",
|
||||||
"@types/react-dom": "19.0.3",
|
"@types/react-dom": "19.0.4",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"node-loader": "^2.1.0",
|
"node-loader": "^2.1.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,8 +20,7 @@ import type { SuperJSONResult } from "superjson";
|
|||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import { env } from "~/env";
|
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
@@ -66,7 +65,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
links: [
|
links: [
|
||||||
loggerLink({
|
loggerLink({
|
||||||
enabled: (opts) =>
|
enabled: (opts) =>
|
||||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
}),
|
}),
|
||||||
splitLink({
|
splitLink({
|
||||||
condition: ({ type }) => type === "subscription",
|
condition: ({ type }) => type === "subscription",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -33,7 +34,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
|||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { env } from "~/env";
|
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
|
|||||||
colors: {
|
colors: {
|
||||||
primaryColor: generateColors(board.primaryColor),
|
primaryColor: generateColors(board.primaryColor),
|
||||||
secondaryColor: generateColors(board.secondaryColor),
|
secondaryColor: generateColors(board.secondaryColor),
|
||||||
|
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||||
},
|
},
|
||||||
primaryColor: "primaryColor",
|
primaryColor: "primaryColor",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
|
|||||||
+23
@@ -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;
|
||||||
|
|||||||
+11
-6
@@ -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}> </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" />
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.5",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,16 +26,16 @@
|
|||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.3",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
<!-- Project Title -->
|
<!-- Project Title -->
|
||||||

|
[](https://homarr.dev/)
|
||||||
|
|
||||||
<!-- Badges -->
|
<!-- Badges -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||

|
[](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/>
|
||||||
|
|
||||||

|
[](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/>
|
||||||
|
|
||||||

|
[](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/>
|
||||||
|
|
||||||

|
[](https://opencollective.com/homarr)
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -40,20 +40,20 @@
|
|||||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
"@turbo/gen": "^2.4.2",
|
"@turbo/gen": "^2.4.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^3.0.5",
|
"@vitest/coverage-v8": "^3.0.6",
|
||||||
"@vitest/ui": "^3.0.5",
|
"@vitest/ui": "^3.0.6",
|
||||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"semantic-release": "^24.2.2",
|
"semantic-release": "^24.2.3",
|
||||||
"testcontainers": "^10.18.0",
|
"testcontainers": "^10.18.0",
|
||||||
"turbo": "^2.4.2",
|
"turbo": "^2.4.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.6"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.4.0",
|
"packageManager": "pnpm@10.4.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.14.0"
|
"node": ">=22.14.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,11 @@ export const appRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
selectable: protectedProcedure
|
selectable: protectedProcedure
|
||||||
.input(z.void())
|
.input(z.void())
|
||||||
.output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true })))
|
.output(
|
||||||
|
z.array(
|
||||||
|
selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -73,6 +77,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
iconUrl: true,
|
iconUrl: true,
|
||||||
description: true,
|
description: true,
|
||||||
href: true,
|
href: true,
|
||||||
|
pingUrl: true,
|
||||||
},
|
},
|
||||||
orderBy: asc(apps.name),
|
orderBy: asc(apps.name),
|
||||||
});
|
});
|
||||||
@@ -111,16 +116,20 @@ export const appRouter = createTRPCRouter({
|
|||||||
create: permissionRequiredProcedure
|
create: permissionRequiredProcedure
|
||||||
.requiresPermission("app-create")
|
.requiresPermission("app-create")
|
||||||
.input(validation.app.manage)
|
.input(validation.app.manage)
|
||||||
.output(z.void())
|
.output(z.object({ appId: z.string() }))
|
||||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const id = createId();
|
||||||
await ctx.db.insert(apps).values({
|
await ctx.db.insert(apps).values({
|
||||||
id: createId(),
|
id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
href: input.href,
|
href: input.href,
|
||||||
|
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { appId: id };
|
||||||
}),
|
}),
|
||||||
createMany: permissionRequiredProcedure
|
createMany: permissionRequiredProcedure
|
||||||
.requiresPermission("app-create")
|
.requiresPermission("app-create")
|
||||||
@@ -161,6 +170,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
href: input.href,
|
href: input.href,
|
||||||
|
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||||
})
|
})
|
||||||
.where(eq(apps.id, input.id));
|
.where(eq(apps.id, input.id));
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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[] = []) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
description: "React components and hooks library",
|
description: "React components and hooks library",
|
||||||
iconUrl: "https://mantine.dev/favicon.svg",
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
href: "https://mantine.dev",
|
href: "https://mantine.dev",
|
||||||
|
pingUrl: "https://mantine.dev/a",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -170,6 +171,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
expect(dbApp!.description).toBe(input.description);
|
expect(dbApp!.description).toBe(input.description);
|
||||||
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
||||||
expect(dbApp!.href).toBe(input.href);
|
expect(dbApp!.href).toBe(input.href);
|
||||||
|
expect(dbApp!.pingUrl).toBe(input.pingUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should create a new app only with required arguments", async () => {
|
test("should create a new app only with required arguments", async () => {
|
||||||
@@ -185,6 +187,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
description: null,
|
description: null,
|
||||||
iconUrl: "https://mantine.dev/favicon.svg",
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
href: null,
|
href: null,
|
||||||
|
pingUrl: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -197,6 +200,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
expect(dbApp!.description).toBe(input.description);
|
expect(dbApp!.description).toBe(input.description);
|
||||||
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
||||||
expect(dbApp!.href).toBe(input.href);
|
expect(dbApp!.href).toBe(input.href);
|
||||||
|
expect(dbApp!.pingUrl).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,6 +229,7 @@ describe("update should update an app", () => {
|
|||||||
description: "React components and hooks library",
|
description: "React components and hooks library",
|
||||||
iconUrl: "https://mantine.dev/favicon.svg2",
|
iconUrl: "https://mantine.dev/favicon.svg2",
|
||||||
href: "https://mantine.dev",
|
href: "https://mantine.dev",
|
||||||
|
pingUrl: "https://mantine.dev/a",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -257,6 +262,7 @@ describe("update should update an app", () => {
|
|||||||
iconUrl: "https://mantine.dev/favicon.svg",
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
description: null,
|
description: null,
|
||||||
href: null,
|
href: null,
|
||||||
|
pingUrl: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "group1",
|
name: "group1",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
@@ -1166,6 +1167,7 @@ describe("getBoardPermissions should return board permissions", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "group1",
|
name: "group1",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(boardGroupPermissions).values({
|
await db.insert(boardGroupPermissions).values({
|
||||||
@@ -1260,6 +1262,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "group1",
|
name: "group1",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
[1, 2, 3, 4, 5].map((number) => ({
|
[1, 2, 3, 4, 5].map((number) => ({
|
||||||
id: number.toString(),
|
id: number.toString(),
|
||||||
name: `Group ${number}`,
|
name: `Group ${number}`,
|
||||||
|
position: number,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
[1, 2, 3, 4, 5].map((number) => ({
|
[1, 2, 3, 4, 5].map((number) => ({
|
||||||
id: number.toString(),
|
id: number.toString(),
|
||||||
name: `Group ${number}`,
|
name: `Group ${number}`,
|
||||||
|
position: number,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
groupId,
|
groupId,
|
||||||
@@ -123,6 +126,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
||||||
id: index.toString(),
|
id: index.toString(),
|
||||||
name: key,
|
name: key,
|
||||||
|
position: index + 1,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,10 +167,12 @@ describe("byId should return group by id including members and permissions", ()
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Another group",
|
name: "Another group",
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
@@ -202,6 +208,7 @@ describe("byId should return group by id including members and permissions", ()
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: "2",
|
id: "2",
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -278,6 +285,7 @@ describe("create should create group in database", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: similarName,
|
name: similarName,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -314,10 +322,12 @@ describe("update should update name with value that is no duplicate", () => {
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: initialValue,
|
name: initialValue,
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Third",
|
name: "Third",
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -347,10 +357,12 @@ describe("update should update name with value that is no duplicate", () => {
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Something",
|
name: "Something",
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: initialDuplicate,
|
name: initialDuplicate,
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -373,6 +385,7 @@ describe("update should update name with value that is no duplicate", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "something",
|
name: "something",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -413,6 +426,7 @@ describe("savePermissions should save permissions for group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupPermissions).values({
|
await db.insert(groupPermissions).values({
|
||||||
groupId,
|
groupId,
|
||||||
@@ -442,6 +456,7 @@ describe("savePermissions should save permissions for group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -494,6 +509,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -518,6 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -559,10 +576,12 @@ describe("deleteGroup should delete group", () => {
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Another group",
|
name: "Another group",
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -586,6 +605,7 @@ describe("deleteGroup should delete group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -638,6 +658,7 @@ describe("addMember should add member to group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -715,6 +736,7 @@ describe("addMember should add member to group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -753,6 +775,7 @@ describe("removeMember should remove member from group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
groupId,
|
groupId,
|
||||||
@@ -833,6 +856,7 @@ describe("removeMember should remove member from group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
groupId,
|
groupId,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
});
|
});
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/env": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"ldapts": "7.3.1",
|
"ldapts": "7.3.1",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/cookies": "0.9.0",
|
"@types/cookies": "0.9.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,7 +272,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||||
await db.insert(groups).values({ id: "1", name: "" });
|
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||||
await db.insert(groups).values({ id: "1", name: "" });
|
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
];
|
];
|
||||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||||
await db.insert(boards).values(createMockBoard({ id: "2" }));
|
await db.insert(boards).values(createMockBoard({ id: "2" }));
|
||||||
await db.insert(groups).values({ id: "1", name: "" });
|
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
|
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
|
||||||
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
|
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ describe("authorizeWithLdapCredentials", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "homarr_example",
|
name: "homarr_example",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,13 +23,15 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
|
"cacheable-lookup": "^7.0.0",
|
||||||
"undici": "7.3.0"
|
"undici": "7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,20 @@ import { rootCertificates } from "node:tls";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { fetch } from "undici";
|
import { fetch } from "undici";
|
||||||
|
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import { LoggingAgent } from "@homarr/common/server";
|
import { LoggingAgent } from "@homarr/common/server";
|
||||||
|
import CacheableLookup from "cacheable-lookup";
|
||||||
|
import type { LookupOptions } from "node:dns";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
|
const cacheableLookup = new CacheableLookup({
|
||||||
|
maxTtl: 6_00,
|
||||||
|
errorTtl: 60,
|
||||||
|
fallbackDuration: 3600
|
||||||
|
});
|
||||||
|
|
||||||
const getCertificateFolder = () => {
|
const getCertificateFolder = () => {
|
||||||
return process.env.NODE_ENV === "production"
|
return env.NODE_ENV === "production"
|
||||||
? path.join("/appdata", "trusted-certificates")
|
? path.join("/appdata", "trusted-certificates")
|
||||||
: process.env.LOCAL_CERTIFICATE_PATH;
|
: process.env.LOCAL_CERTIFICATE_PATH;
|
||||||
};
|
};
|
||||||
@@ -65,15 +75,50 @@ export const createCertificateAgentAsync = async () => {
|
|||||||
return new LoggingAgent({
|
return new LoggingAgent({
|
||||||
connect: {
|
connect: {
|
||||||
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
||||||
|
// Override the DNS lookup with the https://www.npmjs.com/package/cacheable-lookup library lookup (see https://github.com/nodejs/undici/issues/2440#issuecomment-2181215481)
|
||||||
|
lookup(hostname, lookupOptions, callback) {
|
||||||
|
logger.info(`looking up ${hostname} with cacheable lookup`);
|
||||||
|
cacheableLookup.lookup(hostname, { all: lookupOptions.all, hints: lookupOptions.hints, family: convertToNodeJsIPFamily(lookupOptions.family) }, (err, address, family) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(`DNS Lookup failed for ${hostname}: ${err}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Cacheable Lookup for ${hostname}: ${JSON.stringify(address)}, family: ${family}`);
|
||||||
|
}
|
||||||
|
callback(err, address, family);
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
keepAliveTimeout: 10_000, // Keep connection open for 10s
|
||||||
|
keepAliveMaxTimeout: 30_000,
|
||||||
|
pipelining: 0
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the family
|
||||||
|
* @see https://nodejs.org/api/dns.html#dns_dns_lookup_hostname_options_callback
|
||||||
|
* @param lookupOptionsFamily the family that should be converted
|
||||||
|
*/
|
||||||
|
const convertToNodeJsIPFamily = (lookupOptionsFamily: LookupOptions['family']) => {
|
||||||
|
switch (lookupOptionsFamily) {
|
||||||
|
case 'IPv4': {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
case 'IPv6': {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const createAxiosCertificateInstanceAsync = async () => {
|
export const createAxiosCertificateInstanceAsync = async () => {
|
||||||
const customCertificates = await loadCustomRootCertificatesAsync();
|
const customCertificates = await loadCustomRootCertificatesAsync();
|
||||||
return axios.create({
|
return axios.create({
|
||||||
httpsAgent: new Agent({
|
httpsAgent: new Agent({
|
||||||
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
||||||
|
keepAlive: true
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { command } from "@drizzle-team/brocli";
|
||||||
|
|
||||||
|
import { db, eq } from "@homarr/db";
|
||||||
|
import { users } from "@homarr/db/schema";
|
||||||
|
|
||||||
|
export const fixUsernames = command({
|
||||||
|
name: "fix-usernames",
|
||||||
|
desc: "Changes all credentials usernames to lowercase",
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
handler: async () => {
|
||||||
|
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
|
||||||
|
console.error("Credentials provider is not enabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialUsers = await db.query.users.findMany({
|
||||||
|
where: eq(users.provider, "credentials"),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of credentialUsers) {
|
||||||
|
if (!user.name) continue;
|
||||||
|
if (user.name !== user.name.toLowerCase()) continue;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
name: user.name.toLowerCase(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
console.log(`Changed username from ${user.name} to ${user.name.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All usernames have been fixed");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/env": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import { createCronJob } from "../lib";
|
|||||||
|
|
||||||
export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||||
runOnStart: true,
|
runOnStart: true,
|
||||||
|
expectedMaximumDurationInMillis: 10 * 1000,
|
||||||
}).withCallback(async () => {
|
}).withCallback(async () => {
|
||||||
logger.info("Updating icon repository cache...");
|
logger.info("Updating icon repository cache...");
|
||||||
const stopWatch = new Stopwatch();
|
const stopWatch = new Stopwatch();
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
import { createEnv } from "@t3-oss/env-nextjs";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
import { env as commonEnv } from "@homarr/common/env";
|
||||||
|
import { createEnv } from "@homarr/env";
|
||||||
|
|
||||||
const drivers = {
|
const drivers = {
|
||||||
betterSqlite3: "better-sqlite3",
|
betterSqlite3: "better-sqlite3",
|
||||||
@@ -29,7 +29,7 @@ export const env = createEnv({
|
|||||||
? {
|
? {
|
||||||
DB_URL:
|
DB_URL:
|
||||||
// Fallback to the default sqlite file path in production
|
// Fallback to the default sqlite file path in production
|
||||||
process.env.NODE_ENV === "production" && isDriver("better-sqlite3")
|
commonEnv.NODE_ENV === "production" && isDriver("better-sqlite3")
|
||||||
? z.string().default("/appdata/db/db.sqlite")
|
? z.string().default("/appdata/db/db.sqlite")
|
||||||
: z.string().nonempty(),
|
: z.string().nonempty(),
|
||||||
}
|
}
|
||||||
@@ -49,18 +49,5 @@ export const env = createEnv({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
/**
|
experimental__runtimeEnv: process.env,
|
||||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
|
||||||
*/
|
|
||||||
runtimeEnv: {
|
|
||||||
DB_DRIVER: process.env.DB_DRIVER,
|
|
||||||
DB_URL: process.env.DB_URL,
|
|
||||||
DB_HOST: process.env.DB_HOST,
|
|
||||||
DB_USER: process.env.DB_USER,
|
|
||||||
DB_PASSWORD: process.env.DB_PASSWORD,
|
|
||||||
DB_NAME: process.env.DB_NAME,
|
|
||||||
DB_PORT: process.env.DB_PORT,
|
|
||||||
},
|
|
||||||
skipValidation: shouldSkipEnvValidation(),
|
|
||||||
emptyStringAsUndefined: true,
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
ALTER TABLE `group` ADD `home_board_id` varchar(64);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD `mobile_home_board_id` varchar(64);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD `position` smallint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `temp_group` (
|
||||||
|
`id` varchar(64) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`position` smallint NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `temp_group`(`id`, `name`, `position`) SELECT `id`, `name`, ROW_NUMBER() OVER(ORDER BY `name`) FROM `group` WHERE `name` != 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `group` SET `position`=(SELECT `position` FROM `temp_group` WHERE `temp_group`.`id`=`group`.`id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `temp_group`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `group` SET `position` = -1 WHERE `name` = 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` MODIFY `position` smallint NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD CONSTRAINT `group_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD CONSTRAINT `group_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `board` ADD `item_radius` text DEFAULT ('lg') NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `board` ADD `icon_color` text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `app` ADD `ping_url` text;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -176,6 +176,34 @@
|
|||||||
"when": 1738961147412,
|
"when": 1738961147412,
|
||||||
"tag": "0024_mean_vin_gonzales",
|
"tag": "0024_mean_vin_gonzales",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1739469710187,
|
||||||
|
"tag": "0025_add-group-home-board-settings",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1739907771355,
|
||||||
|
"tag": "0026_add-border-radius",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1739915526818,
|
||||||
|
"tag": "0027_acoustic_karma",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1740086765989,
|
||||||
|
"tag": "0028_add_app_ping_url",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ const seedEveryoneGroupAsync = async (db: Database) => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: everyoneGroup,
|
name: everyoneGroup,
|
||||||
|
position: -1,
|
||||||
});
|
});
|
||||||
console.log("Created group 'everyone' through seed");
|
console.log("Created group 'everyone' through seed");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
COMMIT TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
--> statement-breakpoint
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_group` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`owner_id` text,
|
||||||
|
`home_board_id` text,
|
||||||
|
`mobile_home_board_id` text,
|
||||||
|
`position` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", -1 FROM `group` WHERE "name" = 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", ROW_NUMBER() OVER(ORDER BY "name") FROM `group` WHERE "name" != 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `group`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_group` RENAME TO `group`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `group_name_unique` ON `group` (`name`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
--> statement-breakpoint
|
||||||
|
BEGIN TRANSACTION;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `board` ADD `item_radius` text DEFAULT 'lg' NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `board` ADD `icon_color` text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `app` ADD `ping_url` text;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -176,6 +176,34 @@
|
|||||||
"when": 1738961178990,
|
"when": 1738961178990,
|
||||||
"tag": "0024_bitter_scrambler",
|
"tag": "0024_bitter_scrambler",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1739468826756,
|
||||||
|
"tag": "0025_add-group-home-board-settings",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1739907755789,
|
||||||
|
"tag": "0026_add-border-radius",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1739915486467,
|
||||||
|
"tag": "0027_wooden_blizzard",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1740086746417,
|
||||||
|
"tag": "0028_add_app_ping_url",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -40,10 +40,11 @@
|
|||||||
"@auth/core": "^0.37.4",
|
"@auth/core": "^0.37.4",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/env": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
|
"@mantine/core": "^7.17.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
|
||||||
"@testcontainers/mysql": "^10.18.0",
|
"@testcontainers/mysql": "^10.18.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
@@ -58,9 +59,9 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,2 +1,3 @@
|
|||||||
export * from "./item";
|
export * from "./item";
|
||||||
export * from "./server-setting";
|
export * from "./server-setting";
|
||||||
|
export * from "./group";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AdapterAccount } from "@auth/core/adapters";
|
import type { AdapterAccount } from "@auth/core/adapters";
|
||||||
|
import type { MantineSize } from "@mantine/core";
|
||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
int,
|
int,
|
||||||
mysqlTable,
|
mysqlTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
smallint,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
tinyint,
|
tinyint,
|
||||||
@@ -150,6 +152,13 @@ export const groups = mysqlTable("group", {
|
|||||||
ownerId: varchar({ length: 64 }).references(() => users.id, {
|
ownerId: varchar({ length: 64 }).references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
homeBoardId: varchar({ length: 64 }).references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
mobileHomeBoardId: varchar({ length: 64 }).references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
position: smallint().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const groupPermissions = mysqlTable("groupPermission", {
|
export const groupPermissions = mysqlTable("groupPermission", {
|
||||||
@@ -272,6 +281,8 @@ export const boards = mysqlTable("board", {
|
|||||||
opacity: int().default(100).notNull(),
|
opacity: int().default(100).notNull(),
|
||||||
customCss: text(),
|
customCss: text(),
|
||||||
columnCount: int().default(10).notNull(),
|
columnCount: int().default(10).notNull(),
|
||||||
|
iconColor: text(),
|
||||||
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: boolean().default(false).notNull(),
|
disableStatus: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -365,6 +376,7 @@ export const apps = mysqlTable("app", {
|
|||||||
description: text(),
|
description: text(),
|
||||||
iconUrl: text().notNull(),
|
iconUrl: text().notNull(),
|
||||||
href: text(),
|
href: text(),
|
||||||
|
pingUrl: text(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const integrationItems = mysqlTable(
|
export const integrationItems = mysqlTable(
|
||||||
@@ -499,6 +511,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
|||||||
fields: [groups.ownerId],
|
fields: [groups.ownerId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
|
homeBoard: one(boards, {
|
||||||
|
fields: [groups.homeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: one(boards, {
|
||||||
|
fields: [groups.mobileHomeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||||
@@ -574,6 +596,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
}),
|
}),
|
||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
|
groupHomes: many(groups, {
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: many(groups, {
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AdapterAccount } from "@auth/core/adapters";
|
import type { AdapterAccount } from "@auth/core/adapters";
|
||||||
|
import type { MantineSize } from "@mantine/core";
|
||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
@@ -133,6 +134,13 @@ export const groups = sqliteTable("group", {
|
|||||||
ownerId: text().references(() => users.id, {
|
ownerId: text().references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
homeBoardId: text().references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
mobileHomeBoardId: text().references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
position: int().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const groupPermissions = sqliteTable("groupPermission", {
|
export const groupPermissions = sqliteTable("groupPermission", {
|
||||||
@@ -258,6 +266,8 @@ export const boards = sqliteTable("board", {
|
|||||||
opacity: int().default(100).notNull(),
|
opacity: int().default(100).notNull(),
|
||||||
customCss: text(),
|
customCss: text(),
|
||||||
columnCount: int().default(10).notNull(),
|
columnCount: int().default(10).notNull(),
|
||||||
|
iconColor: text(),
|
||||||
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -351,6 +361,7 @@ export const apps = sqliteTable("app", {
|
|||||||
description: text(),
|
description: text(),
|
||||||
iconUrl: text().notNull(),
|
iconUrl: text().notNull(),
|
||||||
href: text(),
|
href: text(),
|
||||||
|
pingUrl: text(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const integrationItems = sqliteTable(
|
export const integrationItems = sqliteTable(
|
||||||
@@ -486,6 +497,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
|||||||
fields: [groups.ownerId],
|
fields: [groups.ownerId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
|
homeBoard: one(boards, {
|
||||||
|
fields: [groups.homeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: one(boards, {
|
||||||
|
fields: [groups.mobileHomeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||||
@@ -561,6 +582,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
}),
|
}),
|
||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
|
groupHomes: many(groups, {
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: many(groups, {
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user