Compare commits
223
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63c0b0e65 | ||
|
|
8a43bf47aa | ||
|
|
d4bb014a9b | ||
|
|
e01d74f4f8 | ||
|
|
ec3bda34e0 | ||
|
|
5c16fb8d6c | ||
|
|
986e013ca3 | ||
|
|
1fd3fd8dfb | ||
|
|
a03a01b964 | ||
|
|
4d94fce523 | ||
|
|
75c791801e | ||
|
|
3e67b411b6 | ||
|
|
ff1a003037 | ||
|
|
99c69ba65d | ||
|
|
f5cf92c49c | ||
|
|
05dc51b38e | ||
|
|
a53e0f7cee | ||
|
|
f3a91ca37f | ||
|
|
1e406a8a58 | ||
|
|
2ea763c231 | ||
|
|
579bb2a33e | ||
|
|
feecdd36d4 | ||
|
|
56755d1ab4 | ||
|
|
6b36ec3103 | ||
|
|
fc6dbc92df | ||
|
|
467d5e21ae | ||
|
|
b399cb6979 | ||
|
|
4d69b81676 | ||
|
|
a1fc9902a5 | ||
|
|
0649220080 | ||
|
|
e502fbc533 | ||
|
|
f8c3dab7f9 | ||
|
|
039800d94d | ||
|
|
9a7e421f68 | ||
|
|
e695fb64d3 | ||
|
|
62da953356 | ||
|
|
39171ac76a | ||
|
|
546c824888 | ||
|
|
a12dd10269 | ||
|
|
80c02ef9f2 | ||
|
|
7d1b1d8342 | ||
|
|
dc3d27e1a1 | ||
|
|
ec8ad483ca | ||
|
|
8edf922e30 | ||
|
|
86a925939c | ||
|
|
b034dcbb11 | ||
|
|
93d7d99e70 | ||
|
|
bfe5e9a8e1 | ||
|
|
e056f620da | ||
|
|
3860af31b6 | ||
|
|
a69c64d88d | ||
|
|
34a6c65ead | ||
|
|
e33e0a4d2b | ||
|
|
ffdc8d3496 | ||
|
|
64cf3d02c4 | ||
|
|
f4f02ca539 | ||
|
|
6327ca073c | ||
|
|
7807b28d24 | ||
|
|
02386ffcd8 | ||
|
|
690cc303bf | ||
|
|
9da50e518b | ||
|
|
eb424d757b | ||
|
|
65befa22ba | ||
|
|
6a68ccfee4 | ||
|
|
e46b1c49f9 | ||
|
|
64cc078b09 | ||
|
|
be4e75321a | ||
|
|
e0eab425e6 | ||
|
|
2dec74e5ff | ||
|
|
39ea2baff3 | ||
|
|
a160f16772 | ||
|
|
ccb19e06c1 | ||
|
|
bf12944768 | ||
|
|
1e9098d052 | ||
|
|
1562eae4d7 | ||
|
|
c349bf8371 | ||
|
|
1f0116d725 | ||
|
|
49d10f7ad0 | ||
|
|
d98552540a | ||
|
|
92f4f9421e | ||
|
|
53f07f87b8 | ||
|
|
812469db82 | ||
|
|
e2b211cf34 | ||
|
|
2358f6f5eb | ||
|
|
024ec9235d | ||
|
|
58fdea0245 | ||
|
|
4ead238462 | ||
|
|
6305c74f90 | ||
|
|
0780080681 | ||
|
|
aa15a705d7 | ||
|
|
cc70175125 | ||
|
|
bc5e25e9cd | ||
|
|
d43557e165 | ||
|
|
b6c96f73a5 | ||
|
|
ee8831affe | ||
|
|
6e21863d04 | ||
|
|
ca39ca66f0 | ||
|
|
cc7d53e84e | ||
|
|
99c409cbb7 | ||
|
|
1fcf9d0146 | ||
|
|
a234fc844e | ||
|
|
168b587c18 | ||
|
|
dc12a82805 | ||
|
|
f5076454cd | ||
|
|
aeb681a858 | ||
|
|
56b57ad171 | ||
|
|
91e1cef611 | ||
|
|
e53b06e8c2 | ||
|
|
0ebf4bc55e | ||
|
|
b8a155dd08 | ||
|
|
63786eed06 | ||
|
|
598f0047eb | ||
|
|
eacda4c397 | ||
|
|
5cb726f793 | ||
|
|
e8d29420e0 | ||
|
|
23c7d0bbf0 | ||
|
|
75aefcb0d5 | ||
|
|
b52a8a7935 | ||
|
|
6d90209e00 | ||
|
|
8f0945f51a | ||
|
|
5b04b93f23 | ||
|
|
c8228b920c | ||
|
|
abf7942fb7 | ||
|
|
50746eb3e4 | ||
|
|
76abfca11b | ||
|
|
fcab6a347d | ||
|
|
c91aae53ac | ||
|
|
4943e7b851 | ||
|
|
88c423fca6 | ||
|
|
f238663ec7 | ||
|
|
e9870e7ee7 | ||
|
|
9c0a0ced04 | ||
|
|
4c27635066 | ||
|
|
3d66ff9148 | ||
|
|
ec257e5f60 | ||
|
|
ef24370a8c | ||
|
|
1fa0fc50c5 | ||
|
|
e9a1dae857 | ||
|
|
e37279fe80 | ||
|
|
82c6ce7b77 | ||
|
|
555212e2dc | ||
|
|
c831539772 | ||
|
|
3e2ffbd7bf | ||
|
|
22d7f676eb | ||
|
|
23bf262f4e | ||
|
|
ecae80d741 | ||
|
|
162e8adf4c | ||
|
|
620ec0315f | ||
|
|
8df398c3c7 | ||
|
|
425359be02 | ||
|
|
8893b72fe3 | ||
|
|
b9483379dd | ||
|
|
0336803550 | ||
|
|
e220087e96 | ||
|
|
f127c9325c | ||
|
|
c410b06a15 | ||
|
|
ce755efe4d | ||
|
|
8b416a6dd3 | ||
|
|
fe95334aa7 | ||
|
|
b0facfd77a | ||
|
|
01db27c34f | ||
|
|
999cf58625 | ||
|
|
9223dd06ee | ||
|
|
42da2b9b06 | ||
|
|
a73eed63eb | ||
|
|
44dcc72d43 | ||
|
|
d02ded5511 | ||
|
|
dfacb08dbd | ||
|
|
4e85bbbad0 | ||
|
|
d78c6ed529 | ||
|
|
210ce3669f | ||
|
|
44e5346d75 | ||
|
|
d34d665229 | ||
|
|
b6dc38483d | ||
|
|
a7e387e54c | ||
|
|
189f35f3cb | ||
|
|
3c8d4f5018 | ||
|
|
2b59f19a7f | ||
|
|
fff1da88bd | ||
|
|
720fdb772c | ||
|
|
a225bdd70c | ||
|
|
e40c348352 | ||
|
|
28e1175565 | ||
|
|
b450b00934 | ||
|
|
8e0bf87c56 | ||
|
|
9e061f8fc7 | ||
|
|
10855bd84c | ||
|
|
93d5ca683b | ||
|
|
d2584b63c5 | ||
|
|
8c1156240c | ||
|
|
bb7f751481 | ||
|
|
1e8a5359b7 | ||
|
|
550bca6dee | ||
|
|
2ae3d938ee | ||
|
|
2b4bc8352e | ||
|
|
c0f681637d | ||
|
|
92cd1c5bd0 | ||
|
|
3b7e6cc083 | ||
|
|
61afab518f | ||
|
|
a7bd664c5c | ||
|
|
a2c3bee66c | ||
|
|
b6dad438c2 | ||
|
|
044c60bdec | ||
|
|
de8485d5c3 | ||
|
|
26b6ca2445 | ||
|
|
63a7be35a4 | ||
|
|
1baa9fc74c | ||
|
|
a51999457b | ||
|
|
5fbbf037ce | ||
|
|
3c0f272707 | ||
|
|
d2b4bf5ae4 | ||
|
|
d803087345 | ||
|
|
d3a883a252 | ||
|
|
694350583a | ||
|
|
ecc4586d02 | ||
|
|
ce4fa5ff67 | ||
|
|
649506b56a | ||
|
|
9ab36f8956 | ||
|
|
bf59a2ad87 | ||
|
|
463607c805 | ||
|
|
50697eb21f | ||
|
|
70e0a11819 | ||
|
|
b384ebd342 |
No files matched your search
+2
-1
@@ -6,4 +6,5 @@ README.md
|
||||
.next
|
||||
.git
|
||||
dev
|
||||
.build
|
||||
.build
|
||||
e2e
|
||||
+8
-5
@@ -4,6 +4,14 @@
|
||||
# This file will be committed to version control, so make sure not to have any secrets in it.
|
||||
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
|
||||
|
||||
# The below secret is not used anywhere but required for Auth.js (Would encrypt JWTs and Mail hashes, both not used)
|
||||
AUTH_SECRET="supersecret"
|
||||
|
||||
# The below secret is used to encrypt integration secrets in the database.
|
||||
# It should be a 32-byte string, generated by running `openssl rand -hex 32` on Unix
|
||||
# or starting the project without any (which will show a randomly generated one).
|
||||
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
@@ -20,11 +28,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
# DB_PASSWORD='password'
|
||||
# DB_NAME='name-of-database'
|
||||
|
||||
|
||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||
# @see https://next-auth.js.org/configuration/options#secret
|
||||
AUTH_SECRET='supersecret'
|
||||
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Configure logging to use winston logger
|
||||
|
||||
@@ -1,37 +1,78 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a bug report to help us improve
|
||||
description: Report that something is broken, not working as intended or causes side-effects
|
||||
title: "bug: "
|
||||
labels: ["🐞❔ unconfirmed bug"]
|
||||
labels: ["needs triage"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Provide environment information
|
||||
description: |
|
||||
Run this command in your project root and paste the results in a code block:
|
||||
```bash
|
||||
npx envinfo --system --binaries
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Link to reproduction
|
||||
description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored.
|
||||
label: Steps to reproduce
|
||||
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To reproduce
|
||||
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
|
||||
label: Impact
|
||||
description: How big is the impact of this bug? Does it make Homarr unusable? Is there any workaround that you're aware of?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the bug here, screenshots if applicable.
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Homarr are you running?
|
||||
options:
|
||||
- 1.0.0-beta
|
||||
- Other (describe in "additional information")
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: installationMethod
|
||||
attributes:
|
||||
label: Installation method
|
||||
description: How do you run Homarr? Post docker-compose, configs or screenshots if applicable.
|
||||
options:
|
||||
- Docker Run
|
||||
- Docker Compose
|
||||
- Portainer
|
||||
- Helm
|
||||
- QNAP
|
||||
- Saltbox
|
||||
- EasyPanel
|
||||
- Unraid Apps
|
||||
- TrueNAS Apps
|
||||
- Synology
|
||||
- HomeAssistant Addon
|
||||
- From Source
|
||||
- Other (describe in "additional information")
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: If relevant, what browser do you use?
|
||||
options:
|
||||
- Firefox
|
||||
- Edge (Chromium)
|
||||
- Edge (Proprietary)
|
||||
- Chrome
|
||||
- Safari
|
||||
- Vivaldi
|
||||
- Brave
|
||||
- Samsung Internet
|
||||
- Other (describe in "additional information")
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
@@ -2,26 +2,16 @@
|
||||
# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml
|
||||
|
||||
name: 🛠 Feature Request
|
||||
description: Create a feature request for the core packages
|
||||
description: Request a new feature that you would like to have implemented
|
||||
title: "feat: "
|
||||
labels: ["✨ enhancement"]
|
||||
labels: ["needs triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature you'd like to request
|
||||
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like to see
|
||||
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: 🛠 Integration request
|
||||
description: Request support for a new integration (eg. Sonarr, Radarr)
|
||||
title: "feat: "
|
||||
labels: ["needs triage"]
|
||||
body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Project Website
|
||||
description: Post the link to the website of the application. Paste the official link.
|
||||
placeholder: ex. https://sonarr.tv/
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe what data should be consumed by Homarr
|
||||
description: Please describe what data Homarr should fetch from the integration. Specify in what interval data should be fetched and whether the user can also perform write operations (eg. deleting a movie or adding a user).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the integration.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Public API available?
|
||||
description: Is there a public API available, that we can consume in Homarr?
|
||||
options:
|
||||
- Yes, available on a website
|
||||
- Yes, available in the application itself
|
||||
- No
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Are you willing to contribute this yourself?
|
||||
options:
|
||||
- Yes
|
||||
- No
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,30 @@
|
||||
name: 🛠 Widget request
|
||||
description: Request a new widget (eg. Clock, Calendar, ...)
|
||||
title: "feat: "
|
||||
labels: ["needs triage"]
|
||||
body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Compatible integrations
|
||||
description: Post a list of the integrations that should be compatible with this widget. Divide using comma. Leave empty if no integration is needed.
|
||||
placeholder: ex. Sonarr, Radarr, Lidarr, Readarr, Nextcloud
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe what data should be displayed
|
||||
description: Please describe what data Homarr should display. Describe how elements should be intractable and what actions the user can perform.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the widget.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Are you willing to contribute this yourself?
|
||||
options:
|
||||
- Yes
|
||||
- No
|
||||
validations:
|
||||
required: true
|
||||
@@ -91,6 +91,8 @@ jobs:
|
||||
network: host
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Install playwright browsers
|
||||
run: pnpm exec playwright install chromium
|
||||
- name: Run E2E Tests
|
||||
shell: bash
|
||||
run: pnpm test:e2e
|
||||
|
||||
@@ -13,11 +13,6 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
description: Send notifications
|
||||
push-image:
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
description: Push Docker Image
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -40,6 +35,7 @@ jobs:
|
||||
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
|
||||
outputs:
|
||||
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
|
||||
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
|
||||
|
||||
steps:
|
||||
- run: echo "Skipping release for workflow_dispatch event"
|
||||
@@ -87,10 +83,19 @@ jobs:
|
||||
run: |
|
||||
git fetch --tags
|
||||
echo "version=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
|
||||
- name: Read git ref
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
id: read-git-ref
|
||||
run: |
|
||||
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Update dev branch
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
continue-on-error: true # Prevent pipeline from failing when merge fails
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
git config user.name "Releases Homarr"
|
||||
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
@@ -103,9 +108,11 @@ jobs:
|
||||
env:
|
||||
NEXT_VERSION: ${{ needs.release.outputs.version }}
|
||||
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
|
||||
PUSH_IMAGE: ${{ github.event_name != 'workflow_dispatch' || github.events.inputs.push-image == 'true' }}
|
||||
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications != false }}
|
||||
env:
|
||||
@@ -129,15 +136,16 @@ jobs:
|
||||
with:
|
||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
tags: |
|
||||
${{ env.DEPLOY_LATEST == true && 'type=raw,value=latest' || null }}
|
||||
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
|
||||
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
|
||||
type=raw,value=${{ env.NEXT_VERSION }}
|
||||
- name: Build and maybe push
|
||||
- name: Build and push
|
||||
id: buildPushAction
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: ${{ env.PUSH_IMAGE}}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
network: host
|
||||
@@ -148,4 +156,4 @@ jobs:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. ${{ env.PUSH_IMAGE == true && '' || 'This was a dry run' }}"
|
||||
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
||||
@@ -54,10 +54,14 @@ yarn-error.log*
|
||||
*.log
|
||||
|
||||
apps/tasks/tasks.cjs
|
||||
apps/tasks/tasks.css
|
||||
apps/websocket/wssServer.cjs
|
||||
apps/websocket/wssServer.css
|
||||
apps/nextjs/.million/
|
||||
packages/cli/cli.cjs
|
||||
|
||||
# e2e mounts
|
||||
e2e/shared/tmp
|
||||
|
||||
#personal backgrounds
|
||||
apps/nextjs/public/images/background.png
|
||||
Vendored
+1
-1
@@ -39,5 +39,5 @@
|
||||
"i18n-ally.localesPaths": [
|
||||
"packages/translation/src/lang",
|
||||
],
|
||||
"i18n-ally.keystyle": "auto",
|
||||
"i18n-ally.keystyle": "nested",
|
||||
}
|
||||
+189
@@ -1,3 +1,192 @@
|
||||
## [1.0.0-beta.13](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2025-01-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **board:** add mobile home board ([#1910](https://github.com/homarr-labs/homarr/issues/1910)) ([e01d74f](https://github.com/homarr-labs/homarr/commit/e01d74f4f89aee9a122b660e6303c356871c6ebb))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **app:** missing noreferer attribute for anchor tag ([#1915](https://github.com/homarr-labs/homarr/issues/1915)) ([986e013](https://github.com/homarr-labs/homarr/commit/986e013ca3eaf402d9014c5586536e05d93e9adc))
|
||||
* **category:** remove not always working and ignoring dynamic sections ([#1939](https://github.com/homarr-labs/homarr/issues/1939)) ([d4bb014](https://github.com/homarr-labs/homarr/commit/d4bb014a9ba83770fd7880b48a4a5ab5362f6f75))
|
||||
* **deps:** update dependency @ianvs/prettier-plugin-sort-imports to ^4.4.1 ([#1936](https://github.com/homarr-labs/homarr/issues/1936)) ([1e406a8](https://github.com/homarr-labs/homarr/commit/1e406a8a58ebdf51f59b5aa0e079baf46c6d02cf))
|
||||
* **deps:** update dependency eslint-config-prettier to v10 ([#1937](https://github.com/homarr-labs/homarr/issues/1937)) ([75c7918](https://github.com/homarr-labs/homarr/commit/75c791801e593e88deb3abcacf2b91aade0871d8))
|
||||
* **deps:** update dependency eslint-plugin-react to ^7.37.4 ([#1934](https://github.com/homarr-labs/homarr/issues/1934)) ([2ea763c](https://github.com/homarr-labs/homarr/commit/2ea763c23161753619a204f9bb522ab04c37a2ab))
|
||||
* **deps:** update dependency sass to ^1.83.3 ([#1945](https://github.com/homarr-labs/homarr/issues/1945)) ([99c69ba](https://github.com/homarr-labs/homarr/commit/99c69ba65ddb5f8cf6bdd61518649fc21405ac87))
|
||||
* **deps:** update dependency sass to ^1.83.4 ([#1946](https://github.com/homarr-labs/homarr/issues/1946)) ([ff1a003](https://github.com/homarr-labs/homarr/commit/ff1a00303728b15b8e709ce57d77a78967abab78))
|
||||
* **deps:** update dependency typescript-eslint to ^8.20.0 ([#1938](https://github.com/homarr-labs/homarr/issues/1938)) ([feecdd3](https://github.com/homarr-labs/homarr/commit/feecdd36d49890022e8f479efe6cefa3f77d265a))
|
||||
* **deps:** update mantine monorepo to ^7.16.0 ([#1948](https://github.com/homarr-labs/homarr/issues/1948)) ([a03a01b](https://github.com/homarr-labs/homarr/commit/a03a01b964e3ab30af6078326b91fb10817b3b98))
|
||||
* **deps:** update tanstack-query monorepo to ^5.64.0 ([#1922](https://github.com/homarr-labs/homarr/issues/1922)) ([b399cb6](https://github.com/homarr-labs/homarr/commit/b399cb697945cd1bb3da2ad5222aceb9b0e71d2b))
|
||||
* **deps:** update tanstack-query monorepo to ^5.64.1 ([#1935](https://github.com/homarr-labs/homarr/issues/1935)) ([a53e0f7](https://github.com/homarr-labs/homarr/commit/a53e0f7ceed9cf771609be7b04090df9744ec4e6))
|
||||
* **media-request:** media and media-request status can have missing values ([#1911](https://github.com/homarr-labs/homarr/issues/1911)) ([ec3bda3](https://github.com/homarr-labs/homarr/commit/ec3bda34e0c1adacf0cfc5d7af272f9f2617fbe5))
|
||||
* trpc removed export anything from client in react-query ([#1941](https://github.com/homarr-labs/homarr/issues/1941)) ([56755d1](https://github.com/homarr-labs/homarr/commit/56755d1ab4194de04faeb569020f6d19623bd29a))
|
||||
|
||||
## [1.0.0-beta.12](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-01-10)
|
||||
|
||||
### Features
|
||||
|
||||
* **app:** add search and pagination ([#1860](https://github.com/homarr-labs/homarr/issues/1860)) ([ccb19e0](https://github.com/homarr-labs/homarr/commit/ccb19e06c192029a81c851900982d52b045185a6))
|
||||
* **auth:** add env variable for oidc-name-attribute-overwrite ([#1850](https://github.com/homarr-labs/homarr/issues/1850)) ([c349bf8](https://github.com/homarr-labs/homarr/commit/c349bf83714739ca1bc6057f2d43c9e3964fa205))
|
||||
* **board:** add board duplication ([#1856](https://github.com/homarr-labs/homarr/issues/1856)) ([49d10f7](https://github.com/homarr-labs/homarr/commit/49d10f7ad0f58b577ed72c2a46a062af263ff225))
|
||||
* docker add to homarr ([#1825](https://github.com/homarr-labs/homarr/issues/1825)) ([64cc078](https://github.com/homarr-labs/homarr/commit/64cc078b095e64edca17e07a09a4f5c340665f84))
|
||||
* **icons:** add upload button to icon picker ([#1859](https://github.com/homarr-labs/homarr/issues/1859)) ([1e9098d](https://github.com/homarr-labs/homarr/commit/1e9098d052801fe7d53ce15e9f22ed89de04d644))
|
||||
* **items:** add search to selection ([#1887](https://github.com/homarr-labs/homarr/issues/1887)) ([62da953](https://github.com/homarr-labs/homarr/commit/62da953356ff76694ddc50980a2b879a01bcbdd6))
|
||||
* **spotlight:** add default search engine ([#1807](https://github.com/homarr-labs/homarr/issues/1807)) ([65befa2](https://github.com/homarr-labs/homarr/commit/65befa22ba91c36a3734ac6dc027175dc9fca892))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency @tabler/icons-react to ^3.27.1 ([#1875](https://github.com/homarr-labs/homarr/issues/1875)) ([e46b1c4](https://github.com/homarr-labs/homarr/commit/e46b1c49f9bb1320f82b2de0585f7e2c7bc72c54))
|
||||
* **deps:** update dependency @tabler/icons-react to ^3.28.1 ([#1891](https://github.com/homarr-labs/homarr/issues/1891)) ([ffdc8d3](https://github.com/homarr-labs/homarr/commit/ffdc8d34960dea0172c50a1d93a4cd14997b13ac))
|
||||
* **deps:** update dependency better-sqlite3 to ^11.7.2 ([#1858](https://github.com/homarr-labs/homarr/issues/1858)) ([1f0116d](https://github.com/homarr-labs/homarr/commit/1f0116d725d76845df4b9cce74ef87134edc627a))
|
||||
* **deps:** update dependency dockerode to ^4.0.3 ([#1854](https://github.com/homarr-labs/homarr/issues/1854)) ([92f4f94](https://github.com/homarr-labs/homarr/commit/92f4f9421e8f693e6546716f5f04922161e29cdc))
|
||||
* **deps:** update dependency glob to ^11.0.1 ([#1908](https://github.com/homarr-labs/homarr/issues/1908)) ([7d1b1d8](https://github.com/homarr-labs/homarr/commit/7d1b1d83429acaa9846479a7a5c48092d4614b75))
|
||||
* **deps:** update dependency ldapts to v7.3.1 ([#1896](https://github.com/homarr-labs/homarr/issues/1896)) ([a69c64d](https://github.com/homarr-labs/homarr/commit/a69c64d88d57293eba8adbdf88ce694194268492))
|
||||
* **deps:** update dependency mantine-react-table to v2.0.0-beta.8 ([#1902](https://github.com/homarr-labs/homarr/issues/1902)) ([86a9259](https://github.com/homarr-labs/homarr/commit/86a925939cb8ef92fe0bca5a762e688887916b04))
|
||||
* **deps:** update dependency octokit to ^4.1.0 ([#1898](https://github.com/homarr-labs/homarr/issues/1898)) ([3860af3](https://github.com/homarr-labs/homarr/commit/3860af31b64e8a74e7e1c91470a9de5450f9e5e7))
|
||||
* **deps:** update dependency sass to ^1.83.1 ([#1852](https://github.com/homarr-labs/homarr/issues/1852)) ([812469d](https://github.com/homarr-labs/homarr/commit/812469db827badee63c73a8c819922c24fdefd13))
|
||||
* **deps:** update dependency trpc-to-openapi to ^2.1.2 ([#1900](https://github.com/homarr-labs/homarr/issues/1900)) ([8edf922](https://github.com/homarr-labs/homarr/commit/8edf922e3050ba9d8c951180ae68f628067c190e))
|
||||
* **deps:** update dependency typescript-eslint to ^8.19.1 ([#1874](https://github.com/homarr-labs/homarr/issues/1874)) ([be4e753](https://github.com/homarr-labs/homarr/commit/be4e75321a0a6a05c6ac6c4ba97ffb5068aed4aa))
|
||||
* **deps:** update dependency undici to v7.2.1 ([#1904](https://github.com/homarr-labs/homarr/issues/1904)) ([b034dcb](https://github.com/homarr-labs/homarr/commit/b034dcbb11017a016e30db0e2bf8c1a0765174ca))
|
||||
* **deps:** update mantine monorepo to ^7.15.3 ([#1880](https://github.com/homarr-labs/homarr/issues/1880)) ([7807b28](https://github.com/homarr-labs/homarr/commit/7807b28d24327db89514da533cb06f4c296b579d))
|
||||
* **deps:** update nextjs monorepo to ^15.1.4 ([#1885](https://github.com/homarr-labs/homarr/issues/1885)) ([f4f02ca](https://github.com/homarr-labs/homarr/commit/f4f02ca539609705b7192c4327272f88415e1594))
|
||||
* **deps:** update nextjs monorepo to v15 (major) ([#1844](https://github.com/homarr-labs/homarr/issues/1844)) ([d985525](https://github.com/homarr-labs/homarr/commit/d98552540a9eb3c24ba92a490f921c2b1911b1f6))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.14 ([#1846](https://github.com/homarr-labs/homarr/issues/1846)) ([2358f6f](https://github.com/homarr-labs/homarr/commit/2358f6f5ebf4bbb9eb4ccd91990f967d9cc44e3b))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.15 ([#1853](https://github.com/homarr-labs/homarr/issues/1853)) ([53f07f8](https://github.com/homarr-labs/homarr/commit/53f07f87b8d600ce57fa94d2ffb4d6f0105102d0))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.16 ([#1877](https://github.com/homarr-labs/homarr/issues/1877)) ([eb424d7](https://github.com/homarr-labs/homarr/commit/eb424d757b7934d5a6bce9de943528f17e678b80))
|
||||
* **deps:** update tanstack-query monorepo to ^5.63.0 ([#1893](https://github.com/homarr-labs/homarr/issues/1893)) ([e33e0a4](https://github.com/homarr-labs/homarr/commit/e33e0a4d2bd91d610c6d29522a98cae8b9953478))
|
||||
* **deps:** update tiptap monorepo to v2.11.1 ([#1909](https://github.com/homarr-labs/homarr/issues/1909)) ([dc3d27e](https://github.com/homarr-labs/homarr/commit/dc3d27e1a1b1611c1997235bf51daf00e3d1f239))
|
||||
* **deps:** update tiptap monorepo to v2.11.2 ([#1913](https://github.com/homarr-labs/homarr/issues/1913)) ([9a7e421](https://github.com/homarr-labs/homarr/commit/9a7e421f6827a5a0ac3627318e51537840295e72))
|
||||
* **dns-hole-summary:** calculation for domains and percentage for multiple integrations ([#1894](https://github.com/homarr-labs/homarr/issues/1894)) ([546c824](https://github.com/homarr-labs/homarr/commit/546c824888a2a6909b831b69ce1a4e283099bda5))
|
||||
* **iframe:** remove second scrollbar from item card ([#1828](https://github.com/homarr-labs/homarr/issues/1828)) ([bf12944](https://github.com/homarr-labs/homarr/commit/bf1294476867fabc896f94c9326a1acfe6bffa50))
|
||||
* **security:** restrict link protocols to http and https ([#1888](https://github.com/homarr-labs/homarr/issues/1888)) ([a12dd10](https://github.com/homarr-labs/homarr/commit/a12dd102692ee237e9695bd852dc894a2856b30e))
|
||||
* **translation:** query parameter in search with escaped ([#1895](https://github.com/homarr-labs/homarr/issues/1895)) ([80c02ef](https://github.com/homarr-labs/homarr/commit/80c02ef9f24cec74025a0fcc983c6db37f7fd457))
|
||||
* **widget:** set-options from widget components should be partial ([#1882](https://github.com/homarr-labs/homarr/issues/1882)) ([e695fb6](https://github.com/homarr-labs/homarr/commit/e695fb64d317ecb47b6844dc2a185502244f7db1))
|
||||
|
||||
## [1.0.0-beta.11](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-01-03)
|
||||
|
||||
### Features
|
||||
|
||||
* remove import beta badge ([#1829](https://github.com/homarr-labs/homarr/issues/1829)) ([6305c74](https://github.com/homarr-labs/homarr/commit/6305c74f90d943722c915ffbf54b5fd7e68a7dca))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency zod-form-data to ^2.0.5 ([#1822](https://github.com/homarr-labs/homarr/issues/1822)) ([a234fc8](https://github.com/homarr-labs/homarr/commit/a234fc844e5edcef9935efb0725b6ccceadfe69d))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.12 ([#1843](https://github.com/homarr-labs/homarr/issues/1843)) ([bc5e25e](https://github.com/homarr-labs/homarr/commit/bc5e25e9cd3269a3d8cc8ef521ae75aff50088be))
|
||||
* **icons:** picker does not allow multiple words ([#1827](https://github.com/homarr-labs/homarr/issues/1827)) ([6e21863](https://github.com/homarr-labs/homarr/commit/6e21863d04f7d0598e7e7ca3fb707f9beb09d7ef))
|
||||
* login page in light mode is always black ([#1821](https://github.com/homarr-labs/homarr/issues/1821)) ([cc7d53e](https://github.com/homarr-labs/homarr/commit/cc7d53e84ec8c2dd3698a178ca6f35099194b5d7))
|
||||
* prowlarr integration indexers status changed from id to indexerId ([#1837](https://github.com/homarr-labs/homarr/issues/1837)) ([ee8831a](https://github.com/homarr-labs/homarr/commit/ee8831affe34a33b80a0993aa538c2b16c035f11))
|
||||
|
||||
## [1.0.0-beta.10](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2024-12-31)
|
||||
|
||||
### Features
|
||||
|
||||
* add request media ([#1811](https://github.com/homarr-labs/homarr/issues/1811)) ([91e1cef](https://github.com/homarr-labs/homarr/commit/91e1cef611a47d57b42c91c0c6d49fcf2e6a10bb))
|
||||
* **docker:** add support for pgid and puid env variables ([#1759](https://github.com/homarr-labs/homarr/issues/1759)) ([aeb681a](https://github.com/homarr-labs/homarr/commit/aeb681a85839e31284e8f1566adc4191a68ea385))
|
||||
* **integration:** add search engine creation ([#1816](https://github.com/homarr-labs/homarr/issues/1816)) ([f507645](https://github.com/homarr-labs/homarr/commit/f5076454cd644a99439b697f24bb0b1f871b2205))
|
||||
* **widget:** add minecraft server status widget ([#1801](https://github.com/homarr-labs/homarr/issues/1801)) ([0ebf4bc](https://github.com/homarr-labs/homarr/commit/0ebf4bc55e62caf5917340496abb350d564df255))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency octokit to ^4.0.3 ([#1806](https://github.com/homarr-labs/homarr/issues/1806)) ([75aefcb](https://github.com/homarr-labs/homarr/commit/75aefcb0d5210a53e8a00b675dbbce847aa8add4))
|
||||
* **deps:** update dependency trpc-to-openapi to ^2.1.1 ([#1788](https://github.com/homarr-labs/homarr/issues/1788)) ([4943e7b](https://github.com/homarr-labs/homarr/commit/4943e7b851cd29291b5e8f2f9d0e084b4deeffc0))
|
||||
* **deps:** update dependency typescript-eslint to ^8.19.0 ([#1814](https://github.com/homarr-labs/homarr/issues/1814)) ([598f004](https://github.com/homarr-labs/homarr/commit/598f0047eb13d6d0d6b68c9032a7a6648f4b6aa3))
|
||||
* **deps:** update tiptap monorepo to v2.11.0 ([#1813](https://github.com/homarr-labs/homarr/issues/1813)) ([eacda4c](https://github.com/homarr-labs/homarr/commit/eacda4c397cb384a35e9eec0305f9b26f2a22a4a))
|
||||
* **docker:** replace anonymous docker volume with env variable for encrypting secrets ([#1809](https://github.com/homarr-labs/homarr/issues/1809)) ([56b57ad](https://github.com/homarr-labs/homarr/commit/56b57ad171da170c2d8edd6112092cef0b586852))
|
||||
* **health-monitoring:** add months to uptime, fix status update ([#1792](https://github.com/homarr-labs/homarr/issues/1792)) ([c91aae5](https://github.com/homarr-labs/homarr/commit/c91aae53acf27eab96a9150281f98a2156e9e2af))
|
||||
* **media-server:** redesign widget ([#1775](https://github.com/homarr-labs/homarr/issues/1775)) ([23c7d0b](https://github.com/homarr-labs/homarr/commit/23c7d0bbf01215b680f346e7eef8072d8b9db09e))
|
||||
|
||||
## [1.0.0-beta.9](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2024-12-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update nextjs monorepo to ^14.2.22 ([#1787](https://github.com/homarr-labs/homarr/issues/1787)) ([3d66ff9](https://github.com/homarr-labs/homarr/commit/3d66ff914883a665c0329c8fc4fd087f4d220692))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.11 ([#1791](https://github.com/homarr-labs/homarr/issues/1791)) ([e9870e7](https://github.com/homarr-labs/homarr/commit/e9870e7ee78227d3be773d9d93aa732760cc8e21))
|
||||
|
||||
## [1.0.0-beta.8](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2024-12-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency @ctrl/deluge to ^7.1.0 ([#1776](https://github.com/homarr-labs/homarr/issues/1776)) ([ecae80d](https://github.com/homarr-labs/homarr/commit/ecae80d741213f12ff08f87992cc4d906cbe0085))
|
||||
* **deps:** update dependency @ctrl/transmission to ^7.2.0 ([#1777](https://github.com/homarr-labs/homarr/issues/1777)) ([3e2ffbd](https://github.com/homarr-labs/homarr/commit/3e2ffbd7bf2793ff58136ed75b38e0d96b4a5ca8))
|
||||
* **deps:** update dependency drizzle-orm to ^0.38.3 ([#1783](https://github.com/homarr-labs/homarr/issues/1783)) ([82c6ce7](https://github.com/homarr-labs/homarr/commit/82c6ce7b77023881bd4c74fb43437e777f9b219e))
|
||||
* **deps:** update dependency drizzle-zod to ^0.6.1 ([#1782](https://github.com/homarr-labs/homarr/issues/1782)) ([555212e](https://github.com/homarr-labs/homarr/commit/555212e2dc82c4b71b8c25a5f20964b3441b5ab9))
|
||||
* oldmarr import user casing wrong ([#1784](https://github.com/homarr-labs/homarr/issues/1784)) ([e37279f](https://github.com/homarr-labs/homarr/commit/e37279fe8071773f1a9773e3233bc81cbb336798))
|
||||
|
||||
## [1.0.0-beta.7](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2024-12-24)
|
||||
|
||||
### Features
|
||||
|
||||
* **apps:** remove url variables ([#1771](https://github.com/homarr-labs/homarr/issues/1771)) ([8df398c](https://github.com/homarr-labs/homarr/commit/8df398c3c706369f6990719603a357c161606f81))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** oidc redirect does not respect https protocol ([#1763](https://github.com/homarr-labs/homarr/issues/1763)) ([0336803](https://github.com/homarr-labs/homarr/commit/033680355044b1ebfa5d8fe07b59929bbcfcb3a0))
|
||||
* **board:** item background and border overridden by mantine card styles ([#1769](https://github.com/homarr-labs/homarr/issues/1769)) ([425359b](https://github.com/homarr-labs/homarr/commit/425359be02f66242f7db0dc77bed0355495e21d5))
|
||||
* **board:** tiles order not respected on firefox ([#1770](https://github.com/homarr-labs/homarr/issues/1770)) ([8893b72](https://github.com/homarr-labs/homarr/commit/8893b72fe363691b91b6aca75d4ad37f4b8e9f6a))
|
||||
* copy media link ([#1750](https://github.com/homarr-labs/homarr/issues/1750)) ([d78c6ed](https://github.com/homarr-labs/homarr/commit/d78c6ed5299e1f975c2e85ec3ecad38e00457677))
|
||||
* **deps:** update dependency @ctrl/qbittorrent to ^9.2.0 ([#1768](https://github.com/homarr-labs/homarr/issues/1768)) ([8b416a6](https://github.com/homarr-labs/homarr/commit/8b416a6dd354b22a3e4f527b793dde2c538b1e6a))
|
||||
* **deps:** update dependency eslint-plugin-react to ^7.37.3 ([#1767](https://github.com/homarr-labs/homarr/issues/1767)) ([fe95334](https://github.com/homarr-labs/homarr/commit/fe95334aa70c95faefcf904591fa9d33159b0905))
|
||||
* **deps:** update dependency jotai to ^2.11.0 ([#1756](https://github.com/homarr-labs/homarr/issues/1756)) ([4e85bbb](https://github.com/homarr-labs/homarr/commit/4e85bbbad034f08ab84e331d376b7a9c6f54e507))
|
||||
* **deps:** update dependency mysql2 to v3.12.0 ([#1758](https://github.com/homarr-labs/homarr/issues/1758)) ([d02ded5](https://github.com/homarr-labs/homarr/commit/d02ded5511aa2d54307db0ac7f4e0a5dee5a707d))
|
||||
* **deps:** update dependency react-error-boundary to v5 ([#1748](https://github.com/homarr-labs/homarr/issues/1748)) ([01db27c](https://github.com/homarr-labs/homarr/commit/01db27c34f3daf6e67e35bbd357df31dcb154b41))
|
||||
* **deps:** update dependency typescript-eslint to ^8.18.2 ([#1762](https://github.com/homarr-labs/homarr/issues/1762)) ([9223dd0](https://github.com/homarr-labs/homarr/commit/9223dd06ee0c2b51e036f607d7b3252c61ab77ea))
|
||||
* **deps:** update mantine monorepo to ^7.15.2 ([#1757](https://github.com/homarr-labs/homarr/issues/1757)) ([dfacb08](https://github.com/homarr-labs/homarr/commit/dfacb08dbddb6864646ece2fca6978207f0c58ac))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.10 ([#1772](https://github.com/homarr-labs/homarr/issues/1772)) ([c410b06](https://github.com/homarr-labs/homarr/commit/c410b06a15ed5426931ad764cc861f66f11a40da))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.9 ([#1761](https://github.com/homarr-labs/homarr/issues/1761)) ([42da2b9](https://github.com/homarr-labs/homarr/commit/42da2b9b062bd8a08c37e45ab129953e8b0e3624))
|
||||
* **icons:** local icon repository does not work with medias ([#1765](https://github.com/homarr-labs/homarr/issues/1765)) ([e220087](https://github.com/homarr-labs/homarr/commit/e220087e96c7096303b1d245ccf3941118e692f3))
|
||||
* **onboard:** missing admin permission assignment for external group ([#1764](https://github.com/homarr-labs/homarr/issues/1764)) ([b948337](https://github.com/homarr-labs/homarr/commit/b9483379dd56a8dab2b9b636b66b970da4cf9d83))
|
||||
|
||||
## [1.0.0-beta.6](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2024-12-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency ioredis to v5.4.2 ([#1739](https://github.com/homarr-labs/homarr/issues/1739)) ([720fdb7](https://github.com/homarr-labs/homarr/commit/720fdb772c2142e23b2bfa2ecaddf0063fbe192c))
|
||||
* **deps:** update dependency next-intl to v3.26.3 ([#1740](https://github.com/homarr-labs/homarr/issues/1740)) ([fff1da8](https://github.com/homarr-labs/homarr/commit/fff1da88bd44162b08a76f8b2f49e2b929fc56e8))
|
||||
|
||||
## [1.0.0-beta.5](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2024-12-19)
|
||||
|
||||
### Features
|
||||
|
||||
* Add quick filters for integration and status + minor UI improvement from feedback ([#1641](https://github.com/homarr-labs/homarr/issues/1641)) ([b6dad43](https://github.com/homarr-labs/homarr/commit/b6dad438c2f44aa8d7a740c1a22c9d4c39edf43e))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* about version is one behind because of missing package-json update in deploy ([#1725](https://github.com/homarr-labs/homarr/issues/1725)) ([8c11562](https://github.com/homarr-labs/homarr/commit/8c1156240c8a0d332301ae6dd0a4521f3db19d9a))
|
||||
* allow additional file-type for oldmarr import ([#1723](https://github.com/homarr-labs/homarr/issues/1723)) ([1e8a535](https://github.com/homarr-labs/homarr/commit/1e8a5359b78e28db7683ba837a962685446c6c32))
|
||||
* always require db-user and password for mysql ([#1730](https://github.com/homarr-labs/homarr/issues/1730)) ([d2584b6](https://github.com/homarr-labs/homarr/commit/d2584b63c55ca143a2b5dc6a71cb00fd5612b78a))
|
||||
* **deps:** update dependency tldts to ^6.1.69 ([#1726](https://github.com/homarr-labs/homarr/issues/1726)) ([c0f6816](https://github.com/homarr-labs/homarr/commit/c0f681637db1d6e7abdf3326b3a3c17283440d09))
|
||||
* **deps:** update dependency undici to v7.2.0 ([#1718](https://github.com/homarr-labs/homarr/issues/1718)) ([61afab5](https://github.com/homarr-labs/homarr/commit/61afab518f6193a700a547faffa4619b28fb0837))
|
||||
* **deps:** update dependency zod-form-data to ^2.0.3 ([#1717](https://github.com/homarr-labs/homarr/issues/1717)) ([a7bd664](https://github.com/homarr-labs/homarr/commit/a7bd664c5cee578574bf37b7149a2142be9f8b7a))
|
||||
* **deps:** update dependency zod-form-data to ^2.0.4 ([#1719](https://github.com/homarr-labs/homarr/issues/1719)) ([3b7e6cc](https://github.com/homarr-labs/homarr/commit/3b7e6cc083220a0e3a0cf4f5243c073067ba5bc7))
|
||||
* **deps:** update nextjs monorepo to ^14.2.21 ([#1727](https://github.com/homarr-labs/homarr/issues/1727)) ([2b4bc83](https://github.com/homarr-labs/homarr/commit/2b4bc8352efa1e2cffb71a6d4d6e42f941358f02))
|
||||
* **deps:** update tiptap monorepo to v2.10.4 ([#1729](https://github.com/homarr-labs/homarr/issues/1729)) ([2ae3d93](https://github.com/homarr-labs/homarr/commit/2ae3d938eeea5bd49a74d9c09b4cb2c680b059d0))
|
||||
* mysql operations not working ([#1728](https://github.com/homarr-labs/homarr/issues/1728)) ([550bca6](https://github.com/homarr-labs/homarr/commit/550bca6dee743a4ed68ec15677304ecfd7a9a385))
|
||||
* update of dev branch has no credentials ([#1724](https://github.com/homarr-labs/homarr/issues/1724)) ([bb7f751](https://github.com/homarr-labs/homarr/commit/bb7f7514814d49a77ac79f63d68923d08148573e))
|
||||
|
||||
## [1.0.0-beta.4](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2024-12-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* about version reads nextjs package-json ([#1706](https://github.com/homarr-labs/homarr/issues/1706)) ([d803087](https://github.com/homarr-labs/homarr/commit/d8030873452d2248d79d16a2fc4464ff2fbb478d))
|
||||
* conflicts between common admin group for ldap or oidc and credentials default group ([#1710](https://github.com/homarr-labs/homarr/issues/1710)) ([3c0f272](https://github.com/homarr-labs/homarr/commit/3c0f272707ee66fd00bffb0d16e30df98b4f1aff))
|
||||
* **deps:** update dependency ldapts to v7.3.0 ([#1696](https://github.com/homarr-labs/homarr/issues/1696)) ([6943505](https://github.com/homarr-labs/homarr/commit/694350583a0e53014a950c4a5bed4d487ad4ca7b))
|
||||
* **deps:** update dependency next-intl to v3.26.2 ([#1703](https://github.com/homarr-labs/homarr/issues/1703)) ([d3a883a](https://github.com/homarr-labs/homarr/commit/d3a883a252167612f3f64579730c8f26453897e3))
|
||||
* docker statup permission issue ([#1709](https://github.com/homarr-labs/homarr/issues/1709)) ([1baa9fc](https://github.com/homarr-labs/homarr/commit/1baa9fc74cf15df8ead7bbe6d1615f7bf0895813))
|
||||
* import sidebar issues ([#1712](https://github.com/homarr-labs/homarr/issues/1712)) ([a519994](https://github.com/homarr-labs/homarr/commit/a51999457b57c042b543768bc1ef43647d62a85d))
|
||||
* jellyfin integration does not allow username password auth ([#1714](https://github.com/homarr-labs/homarr/issues/1714)) ([63a7be3](https://github.com/homarr-labs/homarr/commit/63a7be35a4cf0f11610c2097afc32d2ced0ef9e1))
|
||||
* permission required for home page ([#1711](https://github.com/homarr-labs/homarr/issues/1711)) ([5fbbf03](https://github.com/homarr-labs/homarr/commit/5fbbf037ce8a06642533c7f68c1c34ed8b01e4e4))
|
||||
|
||||
## [1.0.0-beta.3](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.2...v1.0.0-beta.3) (2024-12-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* boolean checks do not work correctly in deploy workflow ([649506b](https://github.com/homarr-labs/homarr/commit/649506b56a5f3cbbee9b782b637730f2196785cc))
|
||||
|
||||
## [1.0.0-beta.2](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.1...v1.0.0-beta.2) (2024-12-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing branch-name to discord deploy notification ([50697eb](https://github.com/homarr-labs/homarr/commit/50697eb21f923bbe9a6811c964cfc313f79a2806))
|
||||
|
||||
## 1.0.0-beta.1 (2024-12-17)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
+23
-35
@@ -1,4 +1,4 @@
|
||||
FROM node:22.12.0-alpine AS base
|
||||
FROM node:22.13.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -12,9 +12,6 @@ COPY . .
|
||||
|
||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||
|
||||
# Install sharp for image optimization
|
||||
RUN corepack enable pnpm && pnpm install sharp -w
|
||||
|
||||
# Copy static data as it is not part of the build
|
||||
COPY static-data ./static-data
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
@@ -25,51 +22,41 @@ RUN corepack enable pnpm && pnpm build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# gettext is required for envsubst
|
||||
RUN apk add --no-cache redis nginx bash gettext
|
||||
# gettext is required for envsubst, openssl for generating AUTH_SECRET, su-exec for running application as non-root
|
||||
RUN apk add --no-cache redis nginx bash gettext su-exec openssl
|
||||
RUN mkdir /appdata
|
||||
VOLUME /appdata
|
||||
RUN mkdir /secrets
|
||||
VOLUME /secrets
|
||||
|
||||
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Enable homarr cli
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
|
||||
COPY --from=builder /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
|
||||
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
|
||||
RUN chmod +x /usr/bin/homarr
|
||||
|
||||
# Don't run production as root
|
||||
RUN chown -R nextjs:nodejs /appdata
|
||||
RUN chown -R nextjs:nodejs /secrets
|
||||
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
|
||||
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
|
||||
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
|
||||
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
|
||||
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
|
||||
USER nextjs
|
||||
RUN mkdir -p /var/cache/nginx && \
|
||||
mkdir -p /var/log/nginx && \
|
||||
mkdir -p /var/lib/nginx && \
|
||||
touch /run/nginx/nginx.pid && \
|
||||
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
|
||||
|
||||
COPY --from=builder /app/apps/nextjs/next.config.mjs .
|
||||
COPY --from=builder /app/apps/nextjs/next.config.ts .
|
||||
COPY --from=builder /app/apps/nextjs/package.json .
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
|
||||
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
|
||||
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
|
||||
COPY --from=builder /app/packages/db/migrations ./db/migrations
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
||||
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||
COPY --chown=nextjs:nodejs scripts/generateRandomSecureKey.js ./generateRandomSecureKey.js
|
||||
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
||||
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
|
||||
COPY --from=builder /app/apps/nextjs/.next/standalone ./
|
||||
COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||
COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public
|
||||
COPY scripts/run.sh ./run.sh
|
||||
COPY --chmod=777 scripts/entrypoint.sh ./entrypoint.sh
|
||||
COPY packages/redis/redis.conf /app/redis.conf
|
||||
COPY nginx.conf /etc/nginx/templates/nginx.conf
|
||||
|
||||
|
||||
ENV DB_URL='/appdata/db/db.sqlite'
|
||||
@@ -77,4 +64,5 @@ ENV DB_DIALECT='sqlite'
|
||||
ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
|
||||
CMD ["sh", "run.sh"]
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -1,22 +1,33 @@
|
||||
// Importing env files here to validate on build
|
||||
import "@homarr/auth/env.mjs";
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/db/env";
|
||||
import "@homarr/common/env";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
import "./src/env.mjs";
|
||||
import "./src/env.ts";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
interface WebpackConfig {
|
||||
module: {
|
||||
rules: {
|
||||
test: RegExp;
|
||||
loader: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
webpack: (config, { isServer }) => {
|
||||
webpack: (config: WebpackConfig, { isServer }) => {
|
||||
if (isServer) {
|
||||
config.module.rules.push({
|
||||
test: /\.node$/,
|
||||
@@ -36,6 +47,7 @@ const nextConfig = {
|
||||
};
|
||||
|
||||
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
+27
-27
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"dev": "pnpm with-env next dev --turbopack",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.11.2",
|
||||
"@homarr/gridstack": "^1.11.3",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
@@ -38,18 +38,18 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.15.1",
|
||||
"@mantine/core": "^7.15.1",
|
||||
"@mantine/dropzone": "^7.15.1",
|
||||
"@mantine/hooks": "^7.15.1",
|
||||
"@mantine/modals": "^7.15.1",
|
||||
"@mantine/tiptap": "^7.15.1",
|
||||
"@mantine/colors-generator": "^7.16.0",
|
||||
"@mantine/core": "^7.16.0",
|
||||
"@mantine/dropzone": "^7.16.0",
|
||||
"@mantine/hooks": "^7.16.0",
|
||||
"@mantine/modals": "^7.16.0",
|
||||
"@mantine/tiptap": "^7.16.0",
|
||||
"@million/lint": "1.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.26.0",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"@tanstack/react-query-devtools": "^5.62.8",
|
||||
"@tanstack/react-query-next-experimental": "5.62.8",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
"@tanstack/react-query": "^5.64.1",
|
||||
"@tanstack/react-query-devtools": "^5.64.1",
|
||||
"@tanstack/react-query-next-experimental": "5.64.1",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -62,17 +62,17 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"flag-icons": "^7.2.3",
|
||||
"glob": "^11.0.0",
|
||||
"jotai": "^2.10.4",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.20",
|
||||
"glob": "^11.0.1",
|
||||
"jotai": "^2.11.0",
|
||||
"mantine-react-table": "2.0.0-beta.8",
|
||||
"next": "15.1.4",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.83.0",
|
||||
"sass": "^1.83.4",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.18.2",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
@@ -81,16 +81,16 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/chroma-js": "3.1.0",
|
||||
"@types/node": "^22.10.6",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.17.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.18.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ import superjson from "superjson";
|
||||
import type { SuperJSONResult } from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
||||
import { clientApi, getTrpcUrl } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource } from "@homarr/api/shared";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
import { env } from "~/env";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
|
||||
@@ -4,22 +4,24 @@ import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema/sqlite";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
|
||||
interface InviteUsagePageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
searchParams: {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
token: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
||||
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
if (!isProviderEnabled("credentials")) notFound();
|
||||
|
||||
const session = await auth();
|
||||
@@ -57,7 +59,7 @@ export default async function InviteUsagePage({ params, searchParams }: InviteUs
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<RegistrationForm invite={invite} />
|
||||
</Card>
|
||||
<Text size="xs" c="gray.5" ta="center">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -9,12 +9,13 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { LoginForm } from "./_login-form";
|
||||
|
||||
interface LoginProps {
|
||||
searchParams: {
|
||||
searchParams: Promise<{
|
||||
callbackUrl?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function Login({ searchParams }: LoginProps) {
|
||||
export default async function Login(props: LoginProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
|
||||
if (session) {
|
||||
@@ -35,7 +36,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { env } from "~/env.mjs";
|
||||
import { env } from "~/env";
|
||||
import { useEditMode, useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IconHomeOff } from "@tabler/icons-react";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema/sqlite";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import type { BoardNotFoundProps } from "~/components/board/not-found";
|
||||
|
||||
@@ -31,15 +31,15 @@ import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
params: Promise<{
|
||||
name: string;
|
||||
};
|
||||
searchParams: {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
||||
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
||||
try {
|
||||
const board = await api.board.getBoardByName({ name: params.name });
|
||||
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||
@@ -63,7 +63,9 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
||||
export default async function BoardSettingsPage(props: Props) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
||||
@@ -99,7 +101,11 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
|
||||
<DangerZoneSettingsContent
|
||||
hideVisibility={
|
||||
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||
}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -28,9 +28,9 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
params,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
params: TParams;
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const initialBoard = await getInitialBoard(params).catch((error) => {
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
logger.warn(error);
|
||||
notFound();
|
||||
|
||||
@@ -24,10 +24,19 @@ export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) =>
|
||||
}}
|
||||
acceptColor="blue.6"
|
||||
rejectColor="red.6"
|
||||
accept={[MIME_TYPES.zip]}
|
||||
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
||||
loading={loading}
|
||||
multiple={false}
|
||||
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
|
||||
onReject={(rejections) => {
|
||||
console.error(
|
||||
"Rejected files",
|
||||
rejections.map(
|
||||
(rejection) =>
|
||||
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
|
||||
<Dropzone.Accept>
|
||||
|
||||
@@ -9,11 +9,12 @@ import "~/styles/scroll-area.scss";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18nMessages } from "@homarr/translation/server";
|
||||
|
||||
@@ -63,14 +64,17 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
|
||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||
if (!isLocaleSupported(props.params.locale)) {
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: SupportedLanguage }>;
|
||||
}) {
|
||||
if (!isLocaleSupported((await props.params).locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
const i18nMessages = await getI18nMessages();
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
@@ -89,7 +93,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
lang={props.params.locale}
|
||||
lang={(await props.params).locale}
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
|
||||
@@ -5,29 +5,29 @@ import { splitToNChunks } from "@homarr/common";
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
const icons = [
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/dashdot.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/readarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tdarr.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
|
||||
];
|
||||
|
||||
const countIconGroups = 3;
|
||||
|
||||
@@ -37,16 +37,16 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
const getHost = () => {
|
||||
const getHostAsync = async () => {
|
||||
if (process.env.HOSTNAME) {
|
||||
return `${process.env.HOSTNAME}:3000`;
|
||||
}
|
||||
|
||||
return headers().get("host");
|
||||
return (await headers()).get("host");
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const baseServerUrl = `http://${getHost()}`;
|
||||
const baseServerUrl = `http://${await getHostAsync()}`;
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const attributes = await getPackageAttributesAsync();
|
||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||
|
||||
@@ -9,10 +9,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppEditForm } from "./_app-edit-form";
|
||||
|
||||
interface AppEditPageProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||
export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
|
||||
@@ -6,8 +6,10 @@ import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
@@ -15,22 +17,35 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
export default async function AppsPage() {
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
interface AppsPageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const apps = await api.app.all();
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
|
||||
{session.user.permissions.includes("app-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
@@ -45,6 +60,10 @@ export default async function AppsPage() {
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
@@ -82,8 +101,8 @@ const AppCard = async ({ app }: AppCardProps) => {
|
||||
</Text>
|
||||
)}
|
||||
{app.href && (
|
||||
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
|
||||
{parseAppHrefWithVariablesServer(app.href)}
|
||||
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
|
||||
{app.href}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
+36
-2
@@ -3,12 +3,14 @@
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
@@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||
|
||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
@@ -39,6 +43,12 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
await revalidatePathActionAsync("/");
|
||||
},
|
||||
});
|
||||
const setMobileHomeBoardMutation = clientApi.board.setMobileHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||
await revalidatePathActionAsync("/");
|
||||
},
|
||||
});
|
||||
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
@@ -64,11 +74,35 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
}, [board.id, setHomeBoardMutation]);
|
||||
|
||||
const handleSetMobileHomeBoard = useCallback(async () => {
|
||||
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
}, [board.id, setMobileHomeBoardMutation]);
|
||||
|
||||
const handleDuplicateBoard = useCallback(() => {
|
||||
openDuplicateModal({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, openDuplicateModal]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
{t("setHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("duplicate.label")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasChangeAccess && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||
import { Affix, Button, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BetaBadge } from "@homarr/ui";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
@@ -26,10 +25,7 @@ export const CreateBoardButton = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
<BetaBadge size="xs" />
|
||||
</Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -88,6 +88,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{t("action.setMobileHomeBoard.badge.label")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Group gap="xs">
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
|
||||
@@ -11,10 +11,11 @@ import { IntegrationAccessSettings } from "../../_components/integration-access-
|
||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||
|
||||
interface EditIntegrationPageProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
||||
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
|
||||
const params = await props.params;
|
||||
const editT = await getScopedI18n("integration.page.edit");
|
||||
const t = await getI18n();
|
||||
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions, getIntegrationName, integrationDefs } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||
@@ -38,6 +38,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: true,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||
@@ -78,6 +79,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
<Stack>
|
||||
@@ -104,6 +107,16 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
kind: getIntegrationName(searchParams.kind),
|
||||
})}
|
||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
|
||||
@@ -13,12 +13,15 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
interface NewIntegrationPageProps {
|
||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
searchParams: Promise<
|
||||
Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("integration-create")) {
|
||||
notFound();
|
||||
|
||||
@@ -46,12 +46,13 @@ import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: {
|
||||
searchParams: Promise<{
|
||||
tab?: IntegrationKind;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||
export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
|
||||
@@ -13,10 +13,7 @@ interface CopyMediaProps {
|
||||
export const CopyMedia = ({ media }: CopyMediaProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
|
||||
: "";
|
||||
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
|
||||
|
||||
return (
|
||||
<CopyButton value={url}>
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMedia = () => {
|
||||
export const UploadMediaButton = () => {
|
||||
const t = useI18n();
|
||||
const onSettledAsync = async () => {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
};
|
||||
|
||||
return (
|
||||
<UploadMedia onSettled={onSettledAsync}>
|
||||
{({ onClick, loading }) => (
|
||||
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
</UploadMedia>
|
||||
);
|
||||
};
|
||||
|
||||
interface UploadMediaProps {
|
||||
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
||||
onSettled?: () => MaybePromise<void>;
|
||||
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
@@ -18,10 +43,14 @@ export const UploadMedia = () => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
onSuccess() {
|
||||
async onSuccess(mediaId) {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
await onSuccess?.({
|
||||
id: mediaId,
|
||||
url: `/api/user-medias/${mediaId}`,
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -29,18 +58,14 @@ export const UploadMedia = () => {
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
await onSettled?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => (
|
||||
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
{({ onClick }) => children({ onClick, loading: isPending })}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -16,7 +17,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CopyMedia } from "./_actions/copy-media";
|
||||
import { DeleteMedia } from "./_actions/delete-media";
|
||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||
import { UploadMedia } from "./_actions/upload-media";
|
||||
import { UploadMediaButton } from "./_actions/upload-media";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
@@ -29,12 +30,8 @@ const searchParamsSchema = z.object({
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface MediaListPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
@@ -45,7 +42,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
|
||||
return (
|
||||
@@ -61,7 +58,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{session.user.permissions.includes("media-upload") && <UploadMedia />}
|
||||
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
|
||||
@@ -10,10 +10,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { SearchEngineEditForm } from "./_search-engine-edit-form";
|
||||
|
||||
interface SearchEngineEditPageProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
||||
export default async function SearchEngineEditPage(props: SearchEngineEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("search-engine-modify-all")) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -22,12 +23,8 @@ const searchParamsSchema = z.object({
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface SearchEnginesPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
@@ -37,7 +34,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||
|
||||
const tEngine = await getScopedI18n("search.engine");
|
||||
|
||||
@@ -37,6 +37,25 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
)}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SelectWithCustomItems
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
@@ -41,6 +42,10 @@ export default async function SettingsPage() {
|
||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function ApiPage() {
|
||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
|
||||
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
|
||||
const apiKeys = await api.apiKeys.getAll();
|
||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCategoryPlus,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
@@ -10,6 +17,8 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { DockerContainerState } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -125,6 +134,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
);
|
||||
},
|
||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
return (
|
||||
<Group gap={"sm"}>
|
||||
{groupedAlert}
|
||||
@@ -134,7 +144,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
totalCount: table.getRowCount(),
|
||||
})}
|
||||
</Text>
|
||||
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
|
||||
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -151,16 +161,29 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
}
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedIds: string[];
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const handleClick = () => {
|
||||
openModal({
|
||||
selectedContainers,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -174,9 +197,10 @@ interface ContainerActionBarButtonProps {
|
||||
|
||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ ids: props.selectedIds },
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
interface ChangeDefaultSearchEngineFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
|
||||
initialValues: {
|
||||
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
userId: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;
|
||||
+17
-4
@@ -18,13 +18,14 @@ interface ChangeHomeBoardFormProps {
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
|
||||
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
homeBoardId: variables.homeBoardId,
|
||||
mobileHomeBoardId: variables.mobileHomeBoardId,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeHomeBoard.notification.success.message"),
|
||||
@@ -36,9 +37,10 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(validation.user.changeHomeBoard, {
|
||||
const form = useZodForm(validation.user.changeHomeBoards, {
|
||||
initialValues: {
|
||||
homeBoardId: user.homeBoardId ?? "",
|
||||
mobileHomeBoardId: user.mobileHomeBoardId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +54,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
|
||||
<Select
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<Select
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
@@ -64,4 +77,4 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
|
||||
type FormType = z.infer<typeof validation.user.changeHomeBoards>;
|
||||
@@ -11,6 +11,7 @@ import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
|
||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||
@@ -19,12 +20,13 @@ import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||
import { UserProfileForm } from "./_components/_profile-form";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
params: Promise<{
|
||||
userId: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props) {
|
||||
export async function generateMetadata(props: Props) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
const user = await api.user
|
||||
.getById({
|
||||
@@ -43,7 +45,8 @@ export async function generateMetadata({ params }: Props) {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EditUserPage({ params }: Props) {
|
||||
export default async function EditUserPage(props: Props) {
|
||||
const params = await props.params;
|
||||
const t = await getI18n();
|
||||
const tGeneral = await getScopedI18n("management.page.user.setting.general");
|
||||
const session = await auth();
|
||||
@@ -58,6 +61,7 @@ export default async function EditUserPage({ params }: Props) {
|
||||
}
|
||||
|
||||
const boards = await api.board.getAllBoards();
|
||||
const searchEngines = await api.searchEngine.getSelectable();
|
||||
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
|
||||
@@ -85,7 +89,7 @@ export default async function EditUserPage({ params }: Props) {
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.board")}</Title>
|
||||
<Title order={2}>{tGeneral("item.board.title")}</Title>
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
@@ -95,6 +99,11 @@ export default async function EditUserPage({ params }: Props) {
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
|
||||
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<FirstDayOfWeek user={user} />
|
||||
|
||||
@@ -15,10 +15,14 @@ import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
interface LayoutProps {
|
||||
params: { userId: string };
|
||||
params: Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
|
||||
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const session = await auth();
|
||||
const t = await getI18n();
|
||||
const tUser = await getScopedI18n("management.page.user");
|
||||
|
||||
@@ -10,12 +10,13 @@ import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
params: Promise<{
|
||||
userId: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function UserSecurityPage({ params }: Props) {
|
||||
export default async function UserSecurityPage(props: Props) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
const tSecurity = await getScopedI18n("management.page.user.setting.security");
|
||||
const user = await api.user
|
||||
|
||||
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { groups } from "@homarr/db/schema/sqlite";
|
||||
import { groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@ import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { NavigationLink } from "./_navigation";
|
||||
|
||||
interface LayoutProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
|
||||
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getI18n();
|
||||
const tGroup = await getScopedI18n("management.page.group");
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
@@ -17,15 +17,17 @@ import { AddGroupMember } from "./_add-group-member";
|
||||
import { RemoveGroupMember } from "./_remove-group-member";
|
||||
|
||||
interface GroupsDetailPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
searchParams: {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
search: string | undefined;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
|
||||
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
|
||||
@@ -14,12 +14,13 @@ import { ReservedGroupAlert } from "./_reserved-group-alert";
|
||||
import { TransferGroupOwnership } from "./_transfer-group-ownership";
|
||||
|
||||
interface GroupsDetailPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
|
||||
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
|
||||
@@ -12,12 +12,13 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
||||
|
||||
interface GroupPermissionsPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
|
||||
export default async function GroupPermissionsPage(props: GroupPermissionsPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead,
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -19,12 +20,8 @@ const searchParamsSchema = z.object({
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface GroupsListPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
@@ -35,7 +32,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,15 +5,15 @@ import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
import { env } from "~/env";
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
interface Props {
|
||||
params: { kind: string };
|
||||
params: Promise<{ kind: string }>;
|
||||
}
|
||||
|
||||
export default async function WidgetPreview(props: Props) {
|
||||
if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function WidgetPreview(props: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const sort = props.params.kind as WidgetKind;
|
||||
const sort = (await props.params).kind as WidgetKind;
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { headers } from "next/headers";
|
||||
import { userAgent } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { userAgent } from "next/server";
|
||||
import { createOpenApiFetchHandler } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import { createSessionAsync } from "@homarr/auth/server";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { apiKeys } from "@homarr/db/schema/sqlite";
|
||||
import { apiKeys } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
const handlerAsync = async (req: NextRequest) => {
|
||||
const apiKeyHeaderValue = req.headers.get("ApiKey");
|
||||
const ipAddress = req.ip ?? headers().get("x-forwarded-for");
|
||||
const ipAddress = req.headers.get("x-forwarded-for");
|
||||
const { ua } = userAgent(req);
|
||||
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
|
||||
|
||||
@@ -88,9 +87,9 @@ const getSessionOrDefaultFromHeadersAsync = async (
|
||||
};
|
||||
|
||||
export {
|
||||
handlerAsync as DELETE,
|
||||
handlerAsync as GET,
|
||||
handlerAsync as PATCH,
|
||||
handlerAsync as POST,
|
||||
handlerAsync as PUT,
|
||||
handlerAsync as DELETE,
|
||||
handlerAsync as PATCH,
|
||||
};
|
||||
@@ -1,16 +1,17 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { createHandlers } from "@homarr/auth";
|
||||
import { createHandlersAsync } from "@homarr/auth";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.GET(reqWithTrustedOrigin(req));
|
||||
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
|
||||
|
||||
return await handlers.GET(reqWithTrustedOrigin(req));
|
||||
};
|
||||
export const POST = async (req: NextRequest) => {
|
||||
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.POST(
|
||||
reqWithTrustedOrigin(req),
|
||||
);
|
||||
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
|
||||
return await handlers.POST(reqWithTrustedOrigin(req));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,9 +3,10 @@ import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema/sqlite";
|
||||
import { medias } from "@homarr/db/schema";
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) {
|
||||
const params = await props.params;
|
||||
const image = await db.query.medias.findFirst({
|
||||
where: eq(medias.id, params.id),
|
||||
columns: {
|
||||
|
||||
@@ -36,6 +36,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: item.kind === "iframe" ? "hidden" : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
@@ -87,7 +88,14 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={updateOptions}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...partialNewOptions.newOptions,
|
||||
...options,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,21 +1,67 @@
|
||||
import { Button, Card, Center, Grid, Stack, Text } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetDefinition } from "@homarr/widgets";
|
||||
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { createItem } = useItemActions();
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
|
||||
[t],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((item) => item.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[items, search],
|
||||
);
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
createItem({ kind });
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{objectEntries(widgetImports).map(([key, value]) => {
|
||||
return <WidgetItem key={key} kind={key} definition={value.definition} closeModal={actions.closeModal} />;
|
||||
})}
|
||||
</Grid>
|
||||
<Stack>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch />}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
// Add item if there is only one item in the list and user presses Enter
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
@@ -23,20 +69,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
kind,
|
||||
definition,
|
||||
closeModal,
|
||||
item,
|
||||
onSelect,
|
||||
}: {
|
||||
kind: WidgetKind;
|
||||
definition: WidgetDefinition;
|
||||
closeModal: () => void;
|
||||
item: {
|
||||
kind: WidgetKind;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { createItem } = useItemActions();
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
createItem({ kind });
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
@@ -44,25 +88,16 @@ const WidgetItem = ({
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<definition.icon />
|
||||
<item.icon />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t(`widget.${kind}.name`)}
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{t(`widget.${kind}.description`)}
|
||||
{item.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleAdd(kind);
|
||||
}}
|
||||
variant="light"
|
||||
size="xs"
|
||||
mt="auto"
|
||||
radius="md"
|
||||
fullWidth
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveCategoryInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeCategoryCallback =
|
||||
(input: RemoveCategoryInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const emptySectionsAbove = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
|
||||
);
|
||||
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
|
||||
|
||||
const emptySectionsBelow = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
|
||||
);
|
||||
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
|
||||
|
||||
if (!aboveSection || !removedSection) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
||||
const aboveYOffset = Math.max(
|
||||
calculateYHeightWithOffsetForItems(aboveSection),
|
||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
|
||||
);
|
||||
const categoryYOffset = Math.max(
|
||||
calculateYHeightWithOffsetForItems(currentCategory),
|
||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
|
||||
);
|
||||
|
||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset,
|
||||
}));
|
||||
const previousBelowWrapperItems = removedSection.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
|
||||
{
|
||||
...aboveSection,
|
||||
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
||||
},
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.yOffset - 2,
|
||||
})),
|
||||
...previous.sections
|
||||
.filter((section): section is DynamicSection => section.kind === "dynamic")
|
||||
.map((dynamicSection) => {
|
||||
// Move dynamic sections from removed section to above section with required yOffset
|
||||
if (dynamicSection.parentSectionId === removedSection.id) {
|
||||
return {
|
||||
...dynamicSection,
|
||||
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Move dynamic sections from category to above section with required yOffset
|
||||
if (dynamicSection.parentSectionId === currentCategory.id) {
|
||||
return {
|
||||
...dynamicSection,
|
||||
yOffset: dynamicSection.yOffset + aboveYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return dynamicSection;
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
|
||||
return sections.reduce((acc, section) => {
|
||||
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const yHeightWithOffset = section.yOffset + section.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForItems = (section: Section) =>
|
||||
section.items.reduce((acc, item) => {
|
||||
const yHeightWithOffset = item.yOffset + item.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { removeCategoryCallback } from "../remove-category";
|
||||
|
||||
describe("Remove Category", () => {
|
||||
test.each([
|
||||
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
|
||||
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
|
||||
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
|
||||
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
|
||||
])(
|
||||
"should remove category",
|
||||
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||
const sections = createSections(initialYOffsets);
|
||||
|
||||
const input = removeId.toString();
|
||||
|
||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
||||
expectedRemovals.forEach((expectedRemoval) => {
|
||||
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
||||
});
|
||||
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
|
||||
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
|
||||
expect.arrayContaining(expectedRemovals),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("should correctly move items to above empty section", () => {
|
||||
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
||||
const sections: Section[] = createSections(initialYOffsets);
|
||||
const aboveSection = sections.find((section) => section.yOffset === 2)!;
|
||||
aboveSection.items = [
|
||||
createItem({ id: "above-1" }),
|
||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
];
|
||||
const removedCategory = sections.find((section) => section.yOffset === 3)!;
|
||||
removedCategory.items = [
|
||||
createItem({ id: "category-1" }),
|
||||
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
|
||||
];
|
||||
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
|
||||
removedEmptySection.items = [
|
||||
createItem({ id: "below-1", xOffset: 5 }),
|
||||
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
|
||||
];
|
||||
sections.push(
|
||||
createDynamicSection({
|
||||
id: "7",
|
||||
parentSectionId: "3",
|
||||
yOffset: 7,
|
||||
height: 3,
|
||||
items: [createItem({ id: "dynamic-1" })],
|
||||
}),
|
||||
);
|
||||
|
||||
const input = "3";
|
||||
|
||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
||||
const aboveSectionResult = result.sections.find((section) => section.id === "2")!;
|
||||
expect(aboveSectionResult.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
createItem({ id: "above-1" }),
|
||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
createItem({ id: "category-1", yOffset: 5 }),
|
||||
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||
]),
|
||||
);
|
||||
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
||||
expect(dynamicSection.yOffset).toBe(12);
|
||||
expect(dynamicSection.parentSectionId).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
|
||||
return {
|
||||
id: item.id ?? "0",
|
||||
kind: "app",
|
||||
options: {},
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
},
|
||||
height: item.height ?? 1,
|
||||
width: item.width ?? 1,
|
||||
yOffset: item.yOffset ?? 0,
|
||||
xOffset: item.xOffset ?? 0,
|
||||
integrationIds: [],
|
||||
} satisfies Item;
|
||||
};
|
||||
|
||||
const createDynamicSection = (
|
||||
section: Partial<
|
||||
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
|
||||
>,
|
||||
) => {
|
||||
return {
|
||||
id: section.id ?? "0",
|
||||
kind: "dynamic",
|
||||
height: section.height ?? 1,
|
||||
width: section.width ?? 1,
|
||||
yOffset: section.yOffset ?? 0,
|
||||
xOffset: section.xOffset ?? 0,
|
||||
parentSectionId: section.parentSectionId ?? "0",
|
||||
items: section.items ?? [],
|
||||
} satisfies DynamicSection;
|
||||
};
|
||||
|
||||
const createSections = (initialYOffsets: number[]) => {
|
||||
return initialYOffsets.map((yOffset, index) => ({
|
||||
id: yOffset.toString(),
|
||||
kind: index % 2 === 0 ? "empty" : "category",
|
||||
name: "Category",
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
items: [createItem({ id: yOffset.toString() })],
|
||||
})) satisfies Section[];
|
||||
};
|
||||
@@ -2,10 +2,12 @@ import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import type { MoveCategoryInput } from "./actions/move-category";
|
||||
import { moveCategoryCallback } from "./actions/move-category";
|
||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||
import { removeCategoryCallback } from "./actions/remove-category";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
@@ -17,10 +19,6 @@ interface RenameCategory {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RemoveCategory {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const useCategoryActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
@@ -132,57 +130,8 @@ export const useCategoryActions = () => {
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(
|
||||
({ id: categoryId }: RemoveCategory) => {
|
||||
updateBoard((previous) => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === categoryId,
|
||||
);
|
||||
if (!currentCategory) return previous;
|
||||
|
||||
const aboveWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset - 1,
|
||||
);
|
||||
|
||||
const removedWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset + 1,
|
||||
);
|
||||
|
||||
if (!aboveWrapper || !removedWrapper) return previous;
|
||||
|
||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
||||
const aboveYOffset = calculateYHeightWithOffset(aboveWrapper);
|
||||
const categoryYOffset = calculateYHeightWithOffset(currentCategory);
|
||||
|
||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset,
|
||||
}));
|
||||
const previousBelowWrapperItems = removedWrapper.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections.filter((section) => section.yOffset < currentCategory.yOffset - 1),
|
||||
{
|
||||
...aboveWrapper,
|
||||
items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
||||
},
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection => section.yOffset >= currentCategory.yOffset + 2,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.yOffset - 2,
|
||||
})),
|
||||
],
|
||||
};
|
||||
});
|
||||
(input: RemoveCategoryInput) => {
|
||||
updateBoard(removeCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
@@ -195,10 +144,3 @@ export const useCategoryActions = () => {
|
||||
removeCategory,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffset = (section: Section) =>
|
||||
section.items.reduce((acc, item) => {
|
||||
const yHeightWithOffset = item.yOffset + item.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -10,6 +10,12 @@ import { useSectionContext } from "./section-context";
|
||||
export const SectionContent = () => {
|
||||
const { section, innerSections, refs } = useSectionContext();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
* THE ITEMS RENDERED WITH GRIDSTACK, OTHERWISE THE ITEMS WILL BE MIXED UP
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
const sortedItems = useMemo(() => {
|
||||
return [
|
||||
...section.items.map((item) => ({ ...item, type: "item" as const })),
|
||||
@@ -19,7 +25,7 @@ export const SectionContent = () => {
|
||||
return itemA.xOffset - itemB.xOffset;
|
||||
}
|
||||
|
||||
return itemA.yOffset - itemB.xOffset;
|
||||
return itemA.yOffset - itemB.yOffset;
|
||||
});
|
||||
}, [section.items, innerSections]);
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
|
||||
--border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
|
||||
}
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Indicator,
|
||||
InputBase,
|
||||
@@ -16,10 +18,13 @@ import {
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media";
|
||||
import classes from "./icon-picker.module.css";
|
||||
|
||||
interface IconPickerProps {
|
||||
@@ -34,6 +39,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
const [value, setValue] = useState<string>(initialValue ?? "");
|
||||
const [search, setSearch] = useState(initialValue ?? "");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
@@ -105,40 +111,61 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
||||
) : null
|
||||
}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
setPreviewUrl(null);
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setPreviewUrl(value);
|
||||
setSearch(value || "");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={tCommon("iconPicker.label")}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
|
||||
/>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
||||
) : null
|
||||
}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
setPreviewUrl(null);
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setPreviewUrl(value);
|
||||
setSearch(value || "");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={tCommon("iconPicker.label")}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={({ url }) => {
|
||||
startTransition(() => {
|
||||
setValue(url);
|
||||
setPreviewUrl(url);
|
||||
setSearch(url);
|
||||
onChange(url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
|
||||
@@ -7,13 +7,13 @@ import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
|
||||
export const UserButton = async () => {
|
||||
const data = await api.updateChecker.getAvailableUpdates();
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const data = isAdmin ? await api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdates={isAdmin ? data : undefined}>
|
||||
<UserAvatarMenu availableUpdates={data}>
|
||||
<UnstyledButton>
|
||||
<Indicator disabled={data.length === 0 || !isAdmin} size={15} processing withBorder>
|
||||
<Indicator disabled={data?.length === 0 || !isAdmin} size={15} processing withBorder>
|
||||
<CurrentUserAvatar size="md" />
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false}>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
@@ -113,7 +113,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item onClick={() => router.push("/auth/login")} leftSection={<IconLogin size="1rem" />}>
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const isUsingDbUrl = Boolean(process.env.DB_URL);
|
||||
const isUsingDbHost = Boolean(process.env.DB_HOST);
|
||||
const isUsingDbCredentials = process.env.DB_DRIVER === "mysql2";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
VERCEL_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((url) => (url ? `https://${url}` : undefined)),
|
||||
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: {
|
||||
DB_DRIVER: z.enum(["better-sqlite3", "mysql2"]).default("better-sqlite3"),
|
||||
// If the DB_HOST is set, the DB_URL is optional
|
||||
DB_URL: isUsingDbHost ? z.string().optional() : z.string(),
|
||||
DB_HOST: isUsingDbUrl ? z.string().optional() : z.string(),
|
||||
DB_PORT: isUsingDbUrl
|
||||
? z.string().regex(/\d+/).transform(Number).optional()
|
||||
: z
|
||||
.string()
|
||||
.regex(/\d+/)
|
||||
.transform(Number)
|
||||
.refine((number) => number >= 1)
|
||||
.default("3306"),
|
||||
DB_USER: isUsingDbCredentials ? z.string() : z.string().optional(),
|
||||
DB_PASSWORD: isUsingDbCredentials ? z.string() : z.string().optional(),
|
||||
DB_NAME: isUsingDbUrl ? z.string().optional() : z.string(),
|
||||
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
|
||||
DOCKER_HOSTNAMES: z.string().optional(),
|
||||
DOCKER_PORTS: z.number().optional(),
|
||||
},
|
||||
/**
|
||||
* 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: {
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
PORT: process.env.PORT,
|
||||
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,
|
||||
DB_DRIVER: process.env.DB_DRIVER,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
|
||||
DOCKER_PORTS: process.env.DOCKER_PORTS,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation:
|
||||
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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: {
|
||||
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
|
||||
DOCKER_HOSTNAMES: z.string().optional(),
|
||||
DOCKER_PORTS: z.string().optional(),
|
||||
},
|
||||
/**
|
||||
* 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,
|
||||
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
|
||||
DOCKER_PORTS: process.env.DOCKER_PORTS,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { createHeadersCallbackForSource } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource } from "@homarr/api/shared";
|
||||
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
|
||||
export const getCurrentColorSchemeAsync = cache(async () => {
|
||||
const cookieValue = cookies().get(colorSchemeCookieKey)?.value;
|
||||
const cookieValue = (await cookies()).get(colorSchemeCookieKey)?.value;
|
||||
|
||||
if (cookieValue) {
|
||||
return cookieValue as ColorScheme;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fsPromises from "fs/promises";
|
||||
import { glob } from "glob";
|
||||
|
||||
import packageJson from "~/../package.json";
|
||||
import packageJson from "../../../../package.json";
|
||||
|
||||
const getPackageVersion = () => packageJson.version;
|
||||
const getDependenciesAsync = async (): Promise<PackageJsonDependencies> => {
|
||||
|
||||
@@ -38,17 +38,17 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"superjson": "2.2.2",
|
||||
"undici": "7.1.1"
|
||||
"undici": "7.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.17.0",
|
||||
"@types/node": "^22.10.6",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -10,8 +10,8 @@
|
||||
<a href="https://github.com/homarr-labs/homarr/actions/workflows/deployment-docker-image.yml">
|
||||
<img title="Docker CI Status" src="https://github.com/homarr-labs/homarr/actions/workflows/deployment-docker-image.yml/badge.svg" alt="CI Status">
|
||||
</a>
|
||||
<a href=https://translate.homarr.dev/>
|
||||
<img title="Translations" src="https://badges.crowdin.net/homarr/localized.svg" />
|
||||
<a href="https://crowdin.com/project/homarr_labs">
|
||||
<img title="Translations" src="https://badges.crowdin.net/homarr_labs/localized.svg" />
|
||||
</a>
|
||||
<a href="https://discord.gg/aCsmEV5RgA">
|
||||
<img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { chromium } from "playwright";
|
||||
import { GenericContainer } from "testcontainers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { createHomarrContainer, withLogs } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
const defaultCredentials = {
|
||||
username: "admin",
|
||||
password: "password",
|
||||
email: "admin@homarr.dev",
|
||||
group: "lldap_admin",
|
||||
};
|
||||
|
||||
const ldapBase = "dc=example,dc=com";
|
||||
|
||||
describe("LLDAP authorization", () => {
|
||||
test("Authorize with LLDAP successfully", async () => {
|
||||
// Arrange
|
||||
const lldapContainer = await createLldapContainer().start();
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "ldap",
|
||||
AUTH_LDAP_URI: `ldap://host.docker.internal:${lldapContainer.getMappedPort(3890)}`,
|
||||
AUTH_LDAP_BASE: ldapBase,
|
||||
AUTH_LDAP_BIND_DN: `uid=${defaultCredentials.username},ou=People,${ldapBase}`,
|
||||
AUTH_LDAP_BIND_PASSWORD: defaultCredentials.password,
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const onboardingActions = new OnboardingActions(page, db);
|
||||
await onboardingActions.skipOnboardingAsync({
|
||||
group: defaultCredentials.group,
|
||||
});
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
|
||||
await page.getByLabel("Username").fill(defaultCredentials.username);
|
||||
await page.getByLabel("Password").fill(defaultCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
|
||||
// Assert
|
||||
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
const users = await db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(users).toHaveLength(1);
|
||||
const user = users[0]!;
|
||||
expect(user).toEqual(
|
||||
expect.objectContaining({
|
||||
name: defaultCredentials.username,
|
||||
email: defaultCredentials.email,
|
||||
provider: "ldap",
|
||||
}),
|
||||
);
|
||||
|
||||
const groups = user.groups.map((g) => g.group.name);
|
||||
expect(groups).toContain(defaultCredentials.group);
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await lldapContainer.stop();
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
const createLldapContainer = () => {
|
||||
return withLogs(
|
||||
new GenericContainer("lldap/lldap:stable").withExposedPorts(3890).withEnvironment({
|
||||
LLDAP_JWT_SECRET: "REPLACE_WITH_RANDOM",
|
||||
LLDAP_KEY_SEED: "REPLACE_WITH_RANDOM",
|
||||
LLDAP_LDAP_BASE_DN: ldapBase,
|
||||
LLDAP_LDAP_USER_PASS: defaultCredentials.password,
|
||||
LLDAP_LDAP_USER_EMAIL: defaultCredentials.email,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { chromium } from "playwright";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "ldap",
|
||||
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
|
||||
AUTH_LDAP_BASE: "",
|
||||
AUTH_LDAP_BIND_DN: "",
|
||||
AUTH_LDAP_BIND_PASSWORD: "",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
const externalGroupName = "oidc-admins";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
export class OnboardingActions {
|
||||
private readonly page: Page;
|
||||
private readonly db: SqliteDatabase;
|
||||
|
||||
constructor(page: Page, db: SqliteDatabase) {
|
||||
this.page = page;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async skipOnboardingAsync(input?: { group?: string }) {
|
||||
await this.db.update(sqliteSchema.onboarding).set({
|
||||
step: "finish",
|
||||
});
|
||||
|
||||
if (input?.group) {
|
||||
await this.db.insert(sqliteSchema.groups).values({
|
||||
id: createId(),
|
||||
name: input.group,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
|
||||
await this.page.locator("button", { hasText: type }).click();
|
||||
}
|
||||
|
||||
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
|
||||
await this.page.waitForSelector("text=administrator user");
|
||||
|
||||
await this.page.getByLabel("Username").fill(input.username);
|
||||
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
|
||||
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
|
||||
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processExternalGroupStepAsync(input: { name: string }) {
|
||||
await this.page.waitForSelector("text=external provider");
|
||||
await this.page.locator("input").fill(input.name);
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processSettingsStepAsync() {
|
||||
await this.page.waitForSelector("text=Analytics");
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Page } from "playwright";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import { OnboardingStep } from "../../../packages/definitions/src";
|
||||
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
export class OnboardingAssertions {
|
||||
private readonly page: Page;
|
||||
private readonly db: SqliteDatabase;
|
||||
|
||||
constructor(page: Page, db: SqliteDatabase) {
|
||||
this.page = page;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async assertDbOnboardingStepAsync(expectedStep: OnboardingStep) {
|
||||
const onboarding = await this.db.query.onboarding.findFirst();
|
||||
expect(onboarding?.step).toEqual(expectedStep);
|
||||
}
|
||||
|
||||
public async assertUserAndAdminGroupInsertedAsync(expectedUsername: string) {
|
||||
const users = await this.db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = users.find((u) => u.name === expectedUsername);
|
||||
expect(user).toBeDefined();
|
||||
|
||||
const adminGroup = user!.groups.find((g) => g.group.name === credentialsAdminGroup);
|
||||
expect(adminGroup).toBeDefined();
|
||||
expect(adminGroup!.group.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||
}
|
||||
|
||||
public async assertExternalGroupInsertedAsync(expectedGroupName: string) {
|
||||
const group = await this.db.query.groups.findFirst({
|
||||
where: eq(sqliteSchema.groups.name, expectedGroupName),
|
||||
with: {
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group!.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,51 @@
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import { Environment } from "testcontainers/build/types";
|
||||
|
||||
export const createHomarrContainer = () => {
|
||||
export const createHomarrContainer = (
|
||||
options: {
|
||||
environment?: Environment;
|
||||
mounts?: {
|
||||
"/appdata"?: string;
|
||||
"/var/run/docker.sock"?: string;
|
||||
};
|
||||
} = {},
|
||||
) => {
|
||||
if (!process.env.CI) {
|
||||
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
|
||||
}
|
||||
|
||||
return new GenericContainer("homarr-e2e")
|
||||
const container = new GenericContainer("homarr-e2e")
|
||||
.withExposedPorts(7575)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575));
|
||||
.withEnvironment({
|
||||
...options.environment,
|
||||
SECRET_ENCRYPTION_KEY: "0".repeat(64),
|
||||
})
|
||||
.withBindMounts(
|
||||
Object.entries(options.mounts ?? {})
|
||||
.filter((item) => item?.[0] !== undefined)
|
||||
.map(([container, local]) => ({
|
||||
source: local,
|
||||
target: container,
|
||||
})),
|
||||
)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575))
|
||||
.withExtraHosts([
|
||||
{
|
||||
// This enabled the usage of host.docker.internal as hostname in the container
|
||||
host: "host.docker.internal",
|
||||
ipAddress: "host-gateway",
|
||||
},
|
||||
]);
|
||||
|
||||
return withLogs(container);
|
||||
};
|
||||
|
||||
export const withLogs = (container: GenericContainer) => {
|
||||
container.withLogConsumer((stream) =>
|
||||
stream
|
||||
.on("data", (line) => console.log(line))
|
||||
.on("err", (line) => console.error(line))
|
||||
.on("end", () => console.log("Stream closed")),
|
||||
);
|
||||
return container;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import Database from "better-sqlite3";
|
||||
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import * as sqliteSchema from "../../packages/db/schema/sqlite";
|
||||
|
||||
export const createSqliteDbFileAsync = async () => {
|
||||
const localMountPath = path.join(__dirname, "tmp", createId());
|
||||
await mkdir(path.join(localMountPath, "db"), { recursive: true });
|
||||
|
||||
const localDbUrl = path.join(localMountPath, "db", "db.sqlite");
|
||||
|
||||
const connection = new Database(localDbUrl);
|
||||
const db = drizzle(connection, {
|
||||
schema: sqliteSchema,
|
||||
casing: "snake_case",
|
||||
});
|
||||
|
||||
await migrate(db, {
|
||||
migrationsFolder: path.join(__dirname, "..", "..", "packages", "db", "migrations", "sqlite"),
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
localMountPath,
|
||||
};
|
||||
};
|
||||
|
||||
export type SqliteDatabase = BetterSQLite3Database<typeof sqliteSchema>;
|
||||
+1
-1
@@ -21,7 +21,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.0-beta.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -22,9 +22,10 @@
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
"package:new": "turbo gen init",
|
||||
"release": "semantic-release",
|
||||
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
|
||||
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
@@ -32,29 +33,29 @@
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.0",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^11.0.1",
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"@turbo/gen": "^2.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"@vitest/ui": "^2.1.8",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semantic-release": "^24.2.0",
|
||||
"semantic-release": "^24.2.1",
|
||||
"testcontainers": "^10.16.0",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"allowNonAppliedPatches": true,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.17.0",
|
||||
"typescript": "^5.7.2"
|
||||
"eslint": "^9.18.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Umami } from "@umami/node";
|
||||
|
||||
import { count, db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { integrations, items, users } from "@homarr/db/schema/sqlite";
|
||||
import { integrations, items, users } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./websocket": "./src/websocket.ts"
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
@@ -27,6 +28,7 @@
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
@@ -39,19 +41,21 @@
|
||||
"@trpc/client": "next",
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"dockerode": "^4.0.2",
|
||||
"next": "^14.2.20",
|
||||
"react": "^19.0.0",
|
||||
"dockerode": "^4.0.3",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "15.1.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^2.1.0"
|
||||
"trpc-to-openapi": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.32",
|
||||
"eslint": "^9.17.0",
|
||||
"@types/dockerode": "^3.3.34",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
|
||||
"use client";
|
||||
|
||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { AppRouter } from ".";
|
||||
import { createHeadersCallbackForSource } from "./shared";
|
||||
|
||||
export const clientApi = createTRPCReact<AppRouter>();
|
||||
export const fetchApi = createTRPCClient<AppRouter>({
|
||||
@@ -16,7 +20,6 @@ export const fetchApi = createTRPCClient<AppRouter>({
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
@@ -27,42 +30,3 @@ function getBaseUrl() {
|
||||
export function getTrpcUrl() {
|
||||
return `${getBaseUrl()}/api/trpc`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a headers callback for a given source
|
||||
* It will set the x-trpc-source header and cookies if needed
|
||||
* @param source trpc source request comes from
|
||||
* @returns headers callback
|
||||
*/
|
||||
export function createHeadersCallbackForSource(source: string) {
|
||||
return async () => {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", source);
|
||||
|
||||
const cookies = await importCookiesAsync();
|
||||
// We need to set cookie for ssr requests (for example with useSuspenseQuery or middleware)
|
||||
if (cookies) {
|
||||
headers.set("cookie", cookies);
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a workarround as cookies are not passed to the server
|
||||
* when using useSuspenseQuery or middleware
|
||||
* @returns cookie string on server or null on client
|
||||
*/
|
||||
async function importCookiesAsync() {
|
||||
if (typeof window === "undefined") {
|
||||
return await import("next/headers").then(({ cookies }) =>
|
||||
cookies()
|
||||
.getAll()
|
||||
.map(({ name, value }) => `${name}=${value}`)
|
||||
.join(";"),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { decryptSecret } from "@homarr/common/server";
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import { integrations } from "@homarr/db/schema/sqlite";
|
||||
import { integrations } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { items } from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { generateSecureRandomToken } from "@homarr/common/server";
|
||||
import { createId, db } from "@homarr/db";
|
||||
import { apiKeys } from "@homarr/db/schema/sqlite";
|
||||
import { apiKeys } from "@homarr/db/schema";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { asc, createId, eq, inArray, like } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema/sqlite";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
||||
import { getIconForName } from "@homarr/icons";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
.input(validation.common.paginated)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
|
||||
.query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
|
||||
const totalCount = await ctx.db.$count(apps, whereQuery);
|
||||
|
||||
const dbApps = await ctx.db.query.apps.findMany({
|
||||
limit: input.pageSize,
|
||||
offset: (input.page - 1) * input.pageSize,
|
||||
where: whereQuery,
|
||||
orderBy: asc(apps.name),
|
||||
});
|
||||
|
||||
return {
|
||||
items: dbApps,
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
all: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema))
|
||||
@@ -98,6 +121,21 @@ export const appRouter = createTRPCRouter({
|
||||
href: input.href,
|
||||
});
|
||||
}),
|
||||
createMany: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
.input(validation.app.createMany)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(apps).values(
|
||||
input.map((app) => ({
|
||||
id: createId(),
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
|
||||
href: app.href,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(convertIntersectionToZodObject(validation.app.edit))
|
||||
|
||||
@@ -2,7 +2,7 @@ import SuperJSON from "superjson";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { db, eq, or } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { items } from "@homarr/db/schema";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../../widgets/src";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
@@ -11,11 +12,13 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
items,
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
} from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
@@ -119,6 +122,7 @@ export const boardRouter = createTRPCRouter({
|
||||
return dbBoards.map((board) => ({
|
||||
...board,
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
search: publicProcedure
|
||||
@@ -192,6 +196,7 @@ export const boardRouter = createTRPCRouter({
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
permissions: constructBoardPermissions(board, ctx.session),
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
createBoard: permissionRequiredProcedure
|
||||
@@ -216,6 +221,111 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
});
|
||||
}),
|
||||
duplicateBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(validation.board.duplicate)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name);
|
||||
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: eq(boards.id, input.id),
|
||||
with: {
|
||||
sections: {
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
integrations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
|
||||
const { sections: boardSections, ...boardProps } = board;
|
||||
|
||||
const newBoardId = createId();
|
||||
const sectionMap = new Map<string, string>(boardSections.map((section) => [section.id, createId()]));
|
||||
const sectionsToInsert: InferInsertModel<typeof sections>[] = boardSections.map(({ items: _, ...section }) => ({
|
||||
...section,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: sectionMap.get(section.id)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
parentSectionId: section.parentSectionId ? sectionMap.get(section.parentSectionId)! : null,
|
||||
boardId: newBoardId,
|
||||
}));
|
||||
const flatItems = boardSections.flatMap((section) => section.items);
|
||||
const itemMap = new Map<string, string>(flatItems.map((item) => [item.id, createId()]));
|
||||
const itemsToInsert: InferInsertModel<typeof items>[] = flatItems.map(({ integrations: _, ...item }) => ({
|
||||
...item,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: itemMap.get(item.id)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(item.sectionId)!,
|
||||
}));
|
||||
|
||||
// Creates a list with all integration ids the user has access to
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const itemIntegrationsToInsert = flatItems.flatMap((item) =>
|
||||
item.integrations
|
||||
// Restrict integrations to only those the user has access to
|
||||
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
|
||||
.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
);
|
||||
|
||||
ctx.db.transaction((transaction) => {
|
||||
transaction
|
||||
.insert(boards)
|
||||
.values({
|
||||
...boardProps,
|
||||
id: newBoardId,
|
||||
name: input.name,
|
||||
creatorId: ctx.session.user.id,
|
||||
})
|
||||
.run();
|
||||
|
||||
if (sectionsToInsert.length > 0) {
|
||||
transaction.insert(sections).values(sectionsToInsert).run();
|
||||
}
|
||||
|
||||
if (itemsToInsert.length > 0) {
|
||||
transaction.insert(items).values(itemsToInsert).run();
|
||||
}
|
||||
|
||||
if (itemIntegrationsToInsert.length > 0) {
|
||||
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
|
||||
}
|
||||
});
|
||||
}),
|
||||
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
|
||||
@@ -229,7 +339,10 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
|
||||
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
|
||||
if (
|
||||
input.visibility !== "public" &&
|
||||
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot make home board private",
|
||||
@@ -251,30 +364,30 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
|
||||
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const user = userId
|
||||
? await ctx.db.query.users.findFirst({
|
||||
? ((await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
})
|
||||
})) ?? null)
|
||||
: null;
|
||||
|
||||
// 1. user home board, 2. home board, 3. not found
|
||||
let boardWhere: SQL<unknown> | null = null;
|
||||
if (user?.homeBoardId) {
|
||||
boardWhere = eq(boards.id, user.homeBoardId);
|
||||
} else {
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
|
||||
}
|
||||
const homeBoardId = await getHomeIdBoardAsync(ctx.db, user, ctx.deviceType);
|
||||
|
||||
if (!boardWhere) {
|
||||
if (!homeBoardId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No home board found",
|
||||
});
|
||||
}
|
||||
|
||||
const boardWhere = eq(boards.id, homeBoardId);
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
@@ -585,6 +698,29 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the home board id of the user with the given device type
|
||||
* For an example of a user with deviceType = 'mobile' it would go through the following order:
|
||||
* 1. user.mobileHomeBoardId
|
||||
* 2. user.homeBoardId
|
||||
* 3. serverSettings.mobileHomeBoardId
|
||||
* 4. serverSettings.homeBoardId
|
||||
* 5. show NOT_FOUND error
|
||||
*/
|
||||
const getHomeIdBoardAsync = async (
|
||||
db: Database,
|
||||
user: InferSelectModel<typeof users> | null,
|
||||
deviceType: DeviceType,
|
||||
) => {
|
||||
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||
if (user?.[settingKey] || user?.homeBoardId) {
|
||||
return user[settingKey] ?? user.homeBoardId;
|
||||
} else {
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||
}
|
||||
};
|
||||
|
||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
||||
const boards = await db.query.boards.findMany({
|
||||
columns: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Session } from "@homarr/auth";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { boardGroupPermissions, boardUserPermissions, groupMembers } from "@homarr/db/schema/sqlite";
|
||||
import { boardGroupPermissions, boardUserPermissions, groupMembers } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import type Docker from "dockerode";
|
||||
import type { Container } from "dockerode";
|
||||
|
||||
import { db, like, or } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema/sqlite";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import type { DockerContainerState } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
import { createCacheChannel } from "@homarr/redis";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
@@ -152,11 +152,17 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: createId(),
|
||||
id: groupId,
|
||||
name: input.name,
|
||||
});
|
||||
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
createGroup: permissionRequiredProcedure
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { count } from "@homarr/db";
|
||||
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema/sqlite";
|
||||
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
|
||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user