Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4543a5fd2c | ||
|
|
52cd6fc913 | ||
|
|
daf7ea56c5 | ||
|
|
b47cfb2892 | ||
|
|
0f029b384d | ||
|
|
ada60a7004 | ||
|
|
93abe8f6a2 | ||
|
|
8b0e6435d0 | ||
|
|
2b5ed83de3 | ||
|
|
692ef7f6dc | ||
|
|
15d47d0585 | ||
|
|
c050ec4fe9 | ||
|
|
28667c5e1a | ||
|
|
dfa2e83238 | ||
|
|
21126b6adc | ||
|
|
02777d104c | ||
|
|
613d1f9662 | ||
|
|
8e4d67c3eb | ||
|
|
b7b5da654b | ||
|
|
e3bf3f371c | ||
|
|
73f7df771b | ||
|
|
8c596bf5fa | ||
|
|
922101dcbd | ||
|
|
032509e462 | ||
|
|
6de74d9525 | ||
|
|
82ec77d2da | ||
|
|
8ca00161fa | ||
|
|
d5399d20e3 | ||
|
|
f330c7e4f1 | ||
|
|
5bdc669e07 | ||
|
|
f857105994 | ||
|
|
60311918ce | ||
|
|
2ef36366ec | ||
|
|
3347886f1e | ||
|
|
dd9d8b5261 | ||
|
|
cf9656d91e | ||
|
|
da63d8ebf4 | ||
|
|
e0152f4ac7 | ||
|
|
732f4d45bd | ||
|
|
6418dfed38 | ||
|
|
0bd8e2c085 | ||
|
|
7d0a224a3f | ||
|
|
75c0f70ef6 | ||
|
|
4c7058b159 | ||
|
|
51948677fa | ||
|
|
d40ec67aa9 | ||
|
|
5e466e80c5 | ||
|
|
db7058fdd9 | ||
|
|
b6733c678c | ||
|
|
e692cc8917 | ||
|
|
99bb68012f | ||
|
|
6f874e87ab | ||
|
|
8a2546704b | ||
|
|
3bee3fe3f6 | ||
|
|
51c87c1a33 | ||
|
|
72e81f05a6 | ||
|
|
2825c9bf45 | ||
|
|
46ce06155b | ||
|
|
753e0ecad2 |
No files matched your search
@@ -4,9 +4,9 @@ on:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
approve-renovate-prs:
|
||||
approve-automatic-prs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
|
||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
- name: Approve Renovate PRs
|
||||
- name: Approve automatic PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
app_id: ${{ secrets.CROWDIN_APP_ID }}
|
||||
|
||||
- name: Download Crowdin translations
|
||||
id: crowdin-download
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
@@ -30,6 +31,7 @@ jobs:
|
||||
create_pull_request: true
|
||||
pull_request_title: "chore(lang): updated translations from crowdin"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
commit_message: "chore(lang): update translations from crowdin"
|
||||
pull_request_base_branch_name: "dev"
|
||||
github_user_name: "Crowdin Homarr"
|
||||
github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com"
|
||||
@@ -38,3 +40,10 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.crowdin-download.outputs.pull_request_number != '' && steps.crowdin-download.outputs.pull_request_number != null
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
gh pr merge ${{steps.crowdin-download.outputs.pull_request_number}} --auto --squash --delete-branch --subject "chore(lang): updated translations from crowdin"
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
send-notifications:
|
||||
@@ -27,34 +29,90 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
TURBO_TELEMETRY_DISABLED: 1
|
||||
|
||||
concurrency: production
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create tag and release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
|
||||
outputs:
|
||||
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
|
||||
|
||||
steps:
|
||||
- run: echo "Skipping release for workflow_dispatch event"
|
||||
if: env.SKIP_RELEASE == 'true'
|
||||
# The below generated version fallback represents a normalized branch name, for example "feature/branch-name" -> "feature-branch-name"
|
||||
- run: echo "version="$(echo ${{github.ref_name}} | sed 's/[^a-zA-Z0-9\-]/-/g') >> "$GITHUB_OUTPUT"
|
||||
id: version-fallback
|
||||
if: env.SKIP_RELEASE == 'true' && github.ref_name != 'main' && github.ref_name != 'beta'
|
||||
|
||||
- name: Obtain token
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
id: obtainToken
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
- uses: actions/checkout@v4
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
node-version: 22
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
- name: Install dependencies
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Run Semantic Release
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
GIT_AUTHOR_NAME: "Releases Homarr"
|
||||
GIT_AUTHOR_EMAIL: "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||
GIT_COMMITTER_NAME: "Releases Homarr"
|
||||
GIT_COMMITTER_EMAIL: "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||
run: |
|
||||
pnpm release
|
||||
- name: Read semver output
|
||||
# We read the last tag either from the created release or from the current branch, this is to rerun the deployment job for the currently released version when it failed
|
||||
if: env.SKIP_RELEASE == 'false' || github.ref_name == 'main' || github.ref_name == 'beta'
|
||||
id: read-semver
|
||||
run: |
|
||||
git fetch --tags
|
||||
echo "version=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
|
||||
- name: Update dev branch
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
continue-on-error: true # Prevent pipeline from failing when merge fails
|
||||
run: |
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git merge ${{ github.ref_name }}
|
||||
git push origin dev
|
||||
deploy:
|
||||
name: Deploy docker image
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
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' }}
|
||||
steps:
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications != false }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
||||
- uses: actions/checkout@v4
|
||||
- name: Get Next Version
|
||||
id: semver
|
||||
uses: ietf-tools/semver-action@v1
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
branch: dev
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications != false }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}. Building images..."
|
||||
args: "Deployment of an image for version '${{env.NEXT_VERSION}}' has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -71,48 +129,23 @@ jobs:
|
||||
with:
|
||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
tags: |
|
||||
type=raw,value=alpha
|
||||
type=raw,value=early-adopters
|
||||
# tags: |
|
||||
# type=raw,value=latest
|
||||
# type=raw,value=${{ steps.semver.outputs.next }}
|
||||
- name: Build and push
|
||||
${{ env.DEPLOY_LATEST == true && 'type=raw,value=latest' || null }}
|
||||
type=raw,value=${{ env.NEXT_VERSION }}
|
||||
- name: Build and maybe push
|
||||
id: buildPushAction
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
network: host
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Build
|
||||
id: buildPushDryAction
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ github.events.inputs.push-image == 'false' }}
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: false
|
||||
push: ${{ env.PUSH_IMAGE}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
network: host
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications != false && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications != false && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."
|
||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. ${{ env.PUSH_IMAGE == true && '' || 'This was a dry run' }}"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
branch: dev
|
||||
- name: Create pull request
|
||||
run: "gh pr create --title \"chore(release): automatic release ${{ steps.semver.outputs.next }}\" --body \"**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``\" --base main --head dev --label automerge"
|
||||
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base beta --head dev --label automerge'
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Discord notification
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"branches": [
|
||||
"main",
|
||||
{
|
||||
"name": "beta",
|
||||
"prerelease": true,
|
||||
"channel": "beta"
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"npmPublish": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": ["package.json", "CHANGELOG.md"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"successComment": false,
|
||||
"failComment": false,
|
||||
"releaseBodyTemplate": "<%= _.truncate(nextRelease.notes, { 'length': 124000, 'omission': '' }) %>"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -67,7 +67,7 @@ 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/generateEncryptionKey.js ./generateEncryptionKey.js
|
||||
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
|
||||
|
||||
|
||||
@@ -1,21 +1,201 @@
|
||||
MIT License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2023 Julius Marminge
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
1. Definitions.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) 2024 Meier Lukas, Thomas Camlong and Homarr Labs
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,7 +0,0 @@
|
||||
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
|
||||
|
||||
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
|
||||
|
||||
### EVERYTHING IS SUBJECT TO CHANGE
|
||||
|
||||
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
|
||||
@@ -0,0 +1,29 @@
|
||||
# Security Policy
|
||||
This policy is relevant if you found potential vulnerabilities in an audit.
|
||||
We consider something as a vulnerability if it...
|
||||
1. puts users or user data at risk
|
||||
2. enables third parties to gain control or access (e.g. [RATs](https://en.wikipedia.org/wiki/Remote_desktop_software#RAT), [privilege escalation](https://en.wikipedia.org/wiki/Privilege_escalation), ...)
|
||||
3. abuses the system in an unintended way (e.g. crypto mining, proxy, ...)
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| >1.0.0 | :white_check_mark: |
|
||||
| <1.0.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
We use [GitHub's system for reporting vulnerabilities](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-advisories/working-with-repository-security-advisories/creating-a-repository-security-advisory).
|
||||
Click [**here to report an advisory**](https://github.com/homarr-labs/homarr/security/advisories/new). Our team will get notified and will get back to you within 1-6 business days.
|
||||
|
||||
As a general guideline; please provide as much detail as possible and provide reproduction steps / documentation regarding the re-creation.
|
||||
You may also provide a fork with a fix for the vulnerability.
|
||||
See https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html for guidelines regarding disclosure.
|
||||
|
||||
If you're unable / unwilling (or it's not safe) to disclose vulnerabilites via GitHub, please report them with the subject "Security advisory - CVEXXX" to our email homarr-labs@proton.me.
|
||||
Please never disclose security vulnerabilits on your own publicly - we'd like to search for a dimplomatic solution that is also safe for our users.
|
||||
|
||||
In your initial contact with us, please provide details according to the [OWASP guidelines for initial reports](https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html#initial-report).
|
||||
|
||||
Thank you!
|
||||
We're looking forward to your report
|
||||
@@ -29,6 +29,7 @@
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
@@ -37,17 +38,18 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.14.3",
|
||||
"@mantine/core": "^7.14.3",
|
||||
"@mantine/hooks": "^7.14.3",
|
||||
"@mantine/modals": "^7.14.3",
|
||||
"@mantine/tiptap": "^7.14.3",
|
||||
"@million/lint": "1.0.13",
|
||||
"@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",
|
||||
"@million/lint": "1.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.24.0",
|
||||
"@tanstack/react-query": "^5.62.3",
|
||||
"@tanstack/react-query-devtools": "^5.62.3",
|
||||
"@tanstack/react-query-next-experimental": "5.62.3",
|
||||
"@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",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -61,16 +63,16 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"flag-icons": "^7.2.3",
|
||||
"glob": "^11.0.0",
|
||||
"jotai": "^2.10.3",
|
||||
"jotai": "^2.10.4",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.20",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.82.0",
|
||||
"sass": "^1.83.0",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.18.2",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
@@ -79,14 +81,14 @@
|
||||
"@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.4",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.3.13",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint": "^9.17.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import definition from "../../boards/(content)/(home)/_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
@@ -1,4 +1,4 @@
|
||||
import definition from "../boards/(content)/(home)/_definition";
|
||||
import definition from "../../boards/(content)/(home)/_definition";
|
||||
|
||||
const { generateMetadataAsync: generateMetadata, page } = definition;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import HomeBoardNotFoundPage from "../boards/(content)/not-found";
|
||||
|
||||
export default HomeBoardNotFoundPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import definition from "../boards/(content)/(home)/_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
wsLink,
|
||||
} from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
import type { SuperJSONResult } from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
||||
@@ -82,8 +83,8 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
serialize(object: unknown) {
|
||||
return object;
|
||||
},
|
||||
deserialize(data: unknown) {
|
||||
return data;
|
||||
deserialize(data: SuperJSONResult) {
|
||||
return superjson.deserialize<unknown>(data);
|
||||
},
|
||||
},
|
||||
url: getTrpcUrl(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IconLayoutOff } from "@tabler/icons-react";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
|
||||
export default async function BoardNotFoundPage() {
|
||||
const tNotFound = await getScopedI18n("board.error.notFound");
|
||||
return (
|
||||
<BoardNotFound
|
||||
icon={IconLayoutOff}
|
||||
title={tNotFound("title")}
|
||||
description={tNotFound("description")}
|
||||
link={{ label: tNotFound("link"), href: "/manage/boards" }}
|
||||
notice={tNotFound("notice")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import type { BoardNotFoundProps } from "~/components/board/not-found";
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
|
||||
export default async function NotFoundBoardHomePage() {
|
||||
const boardNotFoundProps = await getPropsAsync();
|
||||
|
||||
return <BoardNotFound {...boardNotFoundProps} />;
|
||||
}
|
||||
|
||||
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
const boardCount = await db.$count(boards);
|
||||
const t = await getI18n();
|
||||
|
||||
if (boardCount === 0) {
|
||||
return {
|
||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
||||
title: t("board.error.noBoard.title"),
|
||||
description: t("board.error.noBoard.description"),
|
||||
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
|
||||
notice: t("board.error.noBoard.notice"),
|
||||
};
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const type = isAdmin ? "admin" : session !== null ? "user" : "anonymous";
|
||||
const href = {
|
||||
admin: "/manage/settings",
|
||||
user: `/manage/users/${session?.user.id}/general`,
|
||||
anonymous: "/manage/boards",
|
||||
}[type];
|
||||
|
||||
return {
|
||||
icon: IconHomeOff,
|
||||
title: t(`board.error.homeBoard.title`),
|
||||
description: t(`board.error.homeBoard.${type}.description`),
|
||||
link: { label: t(`board.error.homeBoard.${type}.link`), href },
|
||||
notice: t(`board.error.homeBoard.${type}.notice`),
|
||||
};
|
||||
};
|
||||
@@ -66,9 +66,13 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
||||
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
||||
const t = await getScopedI18n("board.setting");
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export const BackToStart = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
|
||||
|
||||
const handleBackToStartAsync = async () => {
|
||||
await mutateAsync();
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
|
||||
{t("init.backToStart")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Button, Card, Stack, Text } from "@mantine/core";
|
||||
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export const InitFinish = async () => {
|
||||
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
|
||||
const tFinish = await getScopedI18n("init.step.finish");
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Stack>
|
||||
<Text>{tFinish("description")}</Text>
|
||||
|
||||
{firstBoard ? (
|
||||
<InternalLinkButton
|
||||
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
|
||||
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
|
||||
>
|
||||
{tFinish("action.goToBoard", { name: firstBoard.name })}
|
||||
</InternalLinkButton>
|
||||
) : (
|
||||
<InternalLinkButton
|
||||
href="/auth/login?callbackUrl=/manage/boards"
|
||||
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
|
||||
>
|
||||
{tFinish("action.createBoard")}
|
||||
</InternalLinkButton>
|
||||
)}
|
||||
|
||||
{isProviderEnabled("credentials") && (
|
||||
<InternalLinkButton
|
||||
href="/auth/login?callbackUrl=/manage/users/invites"
|
||||
iconProps={{ icon: IconMailForward, color: "pink" }}
|
||||
>
|
||||
{tFinish("action.inviteUser")}
|
||||
</InternalLinkButton>
|
||||
)}
|
||||
|
||||
<ExternalLinkButton
|
||||
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
|
||||
iconProps={{ icon: IconBook2, color: "yellow" }}
|
||||
>
|
||||
{tFinish("action.docs")}
|
||||
</ExternalLinkButton>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface LinkButtonProps {
|
||||
href: string;
|
||||
children: string;
|
||||
iconProps: IconProps;
|
||||
}
|
||||
|
||||
interface IconProps {
|
||||
icon: TablerIcon;
|
||||
color: MantineColor;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
|
||||
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
|
||||
};
|
||||
|
||||
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
|
||||
return (
|
||||
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
|
||||
return (
|
||||
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, Stack, TextInput } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
export const InitGroup = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation();
|
||||
const form = useZodForm(validation.group.create, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: z.infer<typeof validation.group.create>) => {
|
||||
await mutateAsync(values, {
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
onError(error) {
|
||||
if (error.data?.code === "CONFLICT") {
|
||||
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("init.step.group.form.name.label")}
|
||||
description={t("init.step.group.form.name.description")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FileInfoCardProps {
|
||||
file: FileWithPath;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group>
|
||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||
{humanFileSize(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
rightSection={<IconPencil size={16} stroke={1.5} />}
|
||||
onClick={onRemove}
|
||||
visibleFrom="md"
|
||||
>
|
||||
{tFileInfo("action.change")}
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Group, rem, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||
|
||||
import "@mantine/dropzone/styles.css";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface ImportDropZoneProps {
|
||||
loading: boolean;
|
||||
updateFile: (file: FileWithPath) => void;
|
||||
}
|
||||
|
||||
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
|
||||
const tDropzone = useScopedI18n("init.step.import.dropzone");
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const firstFile = files[0];
|
||||
if (!firstFile) return;
|
||||
|
||||
updateFile(firstFile);
|
||||
}}
|
||||
acceptColor="blue.6"
|
||||
rejectColor="red.6"
|
||||
accept={[MIME_TYPES.zip]}
|
||||
loading={loading}
|
||||
multiple={false}
|
||||
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
{tDropzone("title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
{tDropzone("description")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useState } from "react";
|
||||
import { Card, Stack } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
import { ImportDropZone } from "./import-dropzone";
|
||||
|
||||
export const InitImport = () => {
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
|
||||
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<ImportDropZone
|
||||
loading={isPending}
|
||||
updateFile={(file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
mutate(formData, {
|
||||
onSuccess: (result) => {
|
||||
startTransition(() => {
|
||||
setAnalyseResult(result);
|
||||
setFile(file);
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mb="sm">
|
||||
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
||||
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition } from "react";
|
||||
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { CheckboxProps } from "@homarr/form/types";
|
||||
import { defaultServerSettings } from "@homarr/server-settings";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
export const InitSettings = () => {
|
||||
const tSection = useScopedI18n("management.page.settings.section");
|
||||
const t = useI18n();
|
||||
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation();
|
||||
const form = useZodForm(validation.settings.init, { initialValues: defaultServerSettings });
|
||||
|
||||
form.watch("analytics.enableGeneral", ({ value }) => {
|
||||
if (!value) {
|
||||
startTransition(() => {
|
||||
form.setFieldValue("analytics.enableWidgetData", false);
|
||||
form.setFieldValue("analytics.enableIntegrationData", false);
|
||||
form.setFieldValue("analytics.enableUserData", false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: z.infer<typeof validation.settings.init>) => {
|
||||
await mutateAsync(values, {
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
||||
|
||||
<Stack gap="xs" ps="md" w="100%">
|
||||
<AnalyticsRow
|
||||
kind="integrationData"
|
||||
disabled={!form.values.analytics.enableGeneral}
|
||||
{...form.getInputProps("analytics.enableWidgetData", { type: "checkbox" })}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
kind="widgetData"
|
||||
disabled={!form.values.analytics.enableGeneral}
|
||||
{...form.getInputProps("analytics.enableIntegrationData", { type: "checkbox" })}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
kind="usersData"
|
||||
disabled={!form.values.analytics.enableGeneral}
|
||||
{...form.getInputProps("analytics.enableUserData", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<CrawlingRow
|
||||
kind="noIndex"
|
||||
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noFollow"
|
||||
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noTranslate"
|
||||
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noSiteLinksSearchBox"
|
||||
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface AnalyticsRowProps {
|
||||
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
|
||||
const tSection = useI18n("management.page.settings.section");
|
||||
|
||||
return (
|
||||
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
interface CrawlingRowProps {
|
||||
kind: Exclude<
|
||||
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
|
||||
"title" | "warning"
|
||||
>;
|
||||
}
|
||||
|
||||
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
|
||||
const tSection = useI18n("management.page.settings.section");
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
title={tSection(`crawlingAndIndexing.${kind}.title`)}
|
||||
text={tSection(`crawlingAndIndexing.${kind}.text`)}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingRow = ({
|
||||
title,
|
||||
text,
|
||||
disabled,
|
||||
...inputProps
|
||||
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
|
||||
return (
|
||||
<Group wrap="nowrap" align="center">
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="gray.5">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Switch disabled={disabled} {...inputProps} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Card, Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { InitStartButton } from "./next-button";
|
||||
|
||||
export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Stack>
|
||||
<Text>{tStart("description")}</Text>
|
||||
|
||||
<InitStartButton
|
||||
preferredStep={undefined}
|
||||
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.scratch")}
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importOldmarr")}
|
||||
</InitStartButton>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
interface InitStartButtonProps {
|
||||
icon: ReactNode;
|
||||
preferredStep: OnboardingStep | undefined;
|
||||
}
|
||||
|
||||
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
|
||||
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync({ preferredStep });
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -12,9 +12,9 @@ import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("user");
|
||||
const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
|
||||
const tUser = useScopedI18n("init.step.user");
|
||||
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
|
||||
const form = useZodForm(validation.user.init, {
|
||||
initialValues: {
|
||||
username: "",
|
||||
@@ -25,17 +25,17 @@ export const InitUserForm = () => {
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
await mutateAsync(values, {
|
||||
onSuccess: () => {
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: "User created",
|
||||
message: "You can now log in",
|
||||
title: tUser("notification.success.title"),
|
||||
message: tUser("notification.success.message"),
|
||||
});
|
||||
router.push("/auth/login");
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error) => {
|
||||
showErrorNotification({
|
||||
title: "User creation failed",
|
||||
message: error?.message ?? "Unknown error",
|
||||
title: tUser("notification.error.title"),
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Card } from "@mantine/core";
|
||||
|
||||
import { InitUserForm } from "./init-user-form";
|
||||
|
||||
export const InitUser = () => {
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<InitUserForm />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { BackToStart } from "./_steps/back";
|
||||
import { InitFinish } from "./_steps/finish/init-finish";
|
||||
import { InitGroup } from "./_steps/group/init-group";
|
||||
import { InitImport } from "./_steps/import/init-import";
|
||||
import { InitSettings } from "./_steps/settings/init-settings";
|
||||
import { InitStart } from "./_steps/start/init-start";
|
||||
import { InitUser } from "./_steps/user/init-user";
|
||||
|
||||
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
|
||||
start: InitStart,
|
||||
import: InitImport,
|
||||
user: InitUser,
|
||||
group: InitGroup,
|
||||
settings: InitSettings,
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
export default async function InitPage() {
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await api.onboard.currentStep();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
|
||||
return (
|
||||
<Box mih="100dvh">
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t(`${currentStep.current}.title`)}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t(`${currentStep.current}.subtitle`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<CurrentLanguageCombobox width="100%" />
|
||||
<CurrentColorSchemeCombobox w="100%" />
|
||||
{CurrentComponent && <CurrentComponent />}
|
||||
{currentStep.previous === "start" && <BackToStart />}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { InitUserForm } from "./_init-user-form";
|
||||
|
||||
export default async function InitUser() {
|
||||
const firstUser = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstUser) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("user.page.init");
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<InitUserForm />
|
||||
</Card>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
.bannerContainer {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
#fa52521f 0%,
|
||||
var(--mantine-color-dark-6) 35%,
|
||||
var(--mantine-color-dark-6) 100%
|
||||
) !important;
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
#fa52521f 0%,
|
||||
var(--mantine-color-dark-6) 35%,
|
||||
var(--mantine-color-dark-6) 100%
|
||||
) !important;
|
||||
}
|
||||
@mixin light {
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
#fa52521f 0%,
|
||||
var(--mantine-color-gray-3) 35%,
|
||||
var(--mantine-color-gray-3) 100%
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
|
||||
@@ -32,9 +32,9 @@ const icons = [
|
||||
|
||||
const countIconGroups = 3;
|
||||
const animationDurationInSeconds = 12;
|
||||
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
||||
|
||||
export const HeroBanner = () => {
|
||||
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
||||
const gridSpan = 12 / countIconGroups;
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,7 +59,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap">
|
||||
<Avatar
|
||||
|
||||
@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card withBorder>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Button, Card, Collapse, Group, Kbd, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -16,7 +17,9 @@ import { integrationSecretIcons } from "./integration-secret-icons";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface SecretCardProps {
|
||||
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
|
||||
secret:
|
||||
| RouterOutputs["integration"]["byId"]["secrets"][number]
|
||||
| { kind: IntegrationSecretKind; value: null; updatedAt: null };
|
||||
children: React.ReactNode;
|
||||
onCancel: () => Promise<boolean>;
|
||||
}
|
||||
@@ -30,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
const KindIcon = integrationSecretIcons[secret.kind];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
@@ -41,11 +44,19 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
||||
</Group>
|
||||
<Group>
|
||||
<Text c="gray.6" size="sm">
|
||||
{t("integration.secrets.lastUpdated", {
|
||||
date: dayjs().to(dayjs(secret.updatedAt)),
|
||||
})}
|
||||
</Text>
|
||||
{secret.updatedAt ? (
|
||||
<Text c="gray.6" size="sm">
|
||||
{t("integration.secrets.lastUpdated", {
|
||||
date: dayjs().to(dayjs(secret.updatedAt)),
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t("integration.secrets.notSet.label")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPublic ? (
|
||||
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
||||
<DisplayIcon size={16} stroke={1.5} />
|
||||
|
||||
@@ -98,8 +98,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
{secretsKinds.map((kind, index) => (
|
||||
<SecretCard
|
||||
key={kind}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secret={secretsMap.get(kind)!}
|
||||
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
|
||||
onCancel={() =>
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
|
||||
@@ -81,7 +81,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Avatar
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
@@ -20,6 +22,12 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Anchor, Center, Stack, Text } from "@mantine/core";
|
||||
import { IconShipOff } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export default function DockerErrorPage() {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center">
|
||||
<IconShipOff size={48} stroke={1.5} />
|
||||
<Stack align="center" gap="xs">
|
||||
<Text size="lg" fw={500}>
|
||||
{t("docker.error.internalServerError")}
|
||||
</Text>
|
||||
<Anchor size="sm" component={Link} href="/manage/tools/logs">
|
||||
{t("common.action.checkLogs")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import classes from "./terminal.module.css";
|
||||
export const TerminalComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const terminalRef = useRef<Terminal>();
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
clientApi.log.subscribe.useSubscription(undefined, {
|
||||
onData(data) {
|
||||
terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`);
|
||||
|
||||
@@ -54,7 +54,7 @@ export const JobsList = ({ initialJobs }: JobsListProps) => {
|
||||
return (
|
||||
<Stack>
|
||||
{jobs.map((job) => (
|
||||
<Card key={job.job.name}>
|
||||
<Card key={job.job.name} withBorder>
|
||||
<Group justify={"space-between"} gap={"md"}>
|
||||
<Stack gap={0}>
|
||||
<Group>
|
||||
|
||||
@@ -149,6 +149,9 @@ const fileToBase64Async = async (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// The functionality below works as expected and doesn't result in [object Object].
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
reader.onload = () => resolve(reader.result?.toString() ?? "");
|
||||
reader.onerror = reject;
|
||||
});
|
||||
@@ -149,7 +149,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
color={!generalForm.isValid() ? "red" : undefined}
|
||||
>
|
||||
<form>
|
||||
<Card p="xl">
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={tUserField("username.label")}
|
||||
@@ -165,7 +165,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<form>
|
||||
<Card p="xl">
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
@@ -185,7 +185,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</form>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl">
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<GroupsForm
|
||||
initialGroups={initialGroups}
|
||||
addGroup={(groupId) =>
|
||||
@@ -198,7 +198,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl">
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Stack maw={300} align="center" mx="auto">
|
||||
<UserAvatar size="xl" user={{ name: generalForm.values.username, image: null }} />
|
||||
<Text tt="uppercase" fw="bolder" size="xl">
|
||||
@@ -208,7 +208,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
<Stepper.Completed>
|
||||
<Card p="xl">
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Stack align="center" maw={300} mx="auto">
|
||||
<IconUserCheck size="3rem" />
|
||||
<Title order={2}>{t("step.completed.title")}</Title>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core";
|
||||
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 { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -24,6 +26,12 @@ interface GroupsDetailPageProps {
|
||||
}
|
||||
|
||||
export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const tMembers = await getScopedI18n("management.page.group.setting.members");
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
@@ -18,6 +20,12 @@ interface GroupsDetailPageProps {
|
||||
}
|
||||
|
||||
export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const tGeneral = await getScopedI18n("management.page.group.setting.general");
|
||||
const tGroupAction = await getScopedI18n("group.action");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissions } from "@homarr/definitions";
|
||||
@@ -16,6 +18,12 @@ interface GroupPermissionsPageProps {
|
||||
}
|
||||
|
||||
export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const tPermissions = await getScopedI18n("group.permission");
|
||||
const t = await getI18n();
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
@@ -54,11 +55,14 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
@@ -72,6 +76,10 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Throw
|
||||
error={new NoIntegrationSelectedError()}
|
||||
when={widgetSupportsIntegrations && item.integrationIds.length === 0}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
@@ -87,3 +95,8 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
||||
if (when) throw error;
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Anchor, AppShellMain, Center, Flex, Group, Image, Text, Title } from "@mantine/core";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { MainHeader } from "../layout/header";
|
||||
import { HomarrLogoWithTitle } from "../layout/logo/homarr-logo";
|
||||
import { ClientShell } from "../layout/shell";
|
||||
|
||||
export interface BoardNotFoundProps {
|
||||
icon: TablerIcon | { src: string; alt: string };
|
||||
title: string;
|
||||
description: string;
|
||||
link: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
notice: string;
|
||||
}
|
||||
|
||||
export const BoardNotFound = ({ icon: Icon, title, description, link, notice }: BoardNotFoundProps) => {
|
||||
return (
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader logo={<HomarrLogoWithTitle size="md" />} hasNavigation={false} />
|
||||
<AppShellMain>
|
||||
<Center h={fullHeightWithoutHeaderAndFooter}>
|
||||
<Flex direction="column" align="center" ta="center">
|
||||
<Group gap="sm" justify="center">
|
||||
{"src" in Icon ? <Image fit="fill" h={48} w={48} src={Icon.src} alt={Icon.alt} /> : <Icon size={32} />}
|
||||
|
||||
<Title>{title}</Title>
|
||||
</Group>
|
||||
<Text>{description}</Text>
|
||||
<Anchor href={link.href}>{link.label}</Anchor>
|
||||
<Text c="dimmed">{notice}</Text>
|
||||
</Flex>
|
||||
</Center>
|
||||
</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ interface Props extends BoxProps {
|
||||
height: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
innerRef: React.RefObject<GridItemHTMLElement> | undefined;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
}
|
||||
|
||||
export const GridStackItem = ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
import { GridStack } from "@homarr/gridstack";
|
||||
@@ -9,9 +9,9 @@ interface InitializeGridstackProps {
|
||||
section: Omit<Section, "items">;
|
||||
itemIds: string[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
|
||||
gridstack: MutableRefObject<GridStack | undefined>;
|
||||
wrapper: RefObject<HTMLDivElement | null>;
|
||||
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
||||
gridstack: RefObject<GridStack | null>;
|
||||
};
|
||||
sectionColumnCount: number;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
@@ -11,9 +11,9 @@ import { useSectionActions } from "../section-actions";
|
||||
import { initializeGridstack } from "./init-gridstack";
|
||||
|
||||
export interface UseGridstackRefs {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
|
||||
gridstack: MutableRefObject<GridStack | undefined>;
|
||||
wrapper: RefObject<HTMLDivElement | null>;
|
||||
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
||||
gridstack: RefObject<GridStack | null>;
|
||||
}
|
||||
|
||||
interface UseGristackReturnType {
|
||||
@@ -60,9 +60,9 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
// define reference for wrapper - is used to calculate the width of the wrapper
|
||||
const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>();
|
||||
// references to the diffrent items contained in the gridstack
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement>>>({});
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
||||
// reference of the gridstack object for modifications after initialization
|
||||
const gridRef = useRef<GridStack>();
|
||||
const gridRef = useRef<GridStack>(null);
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
interface CurrentColorSchemeComboboxProps {
|
||||
w?: string;
|
||||
}
|
||||
|
||||
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
|
||||
const tOptions = useScopedI18n("common.colorScheme.options");
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
return (
|
||||
<SelectWithCustomItems
|
||||
value={colorScheme}
|
||||
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tOptions(scheme),
|
||||
}))}
|
||||
SelectOption={ColorSchemeCustomOption}
|
||||
w={w}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const appearanceIcons = {
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const Icon = appearanceIcons[value];
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Icon size={16} stroke={1.5} />
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
[data-combobox-selected="true"] .iconCard {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./icon-picker.module.css";
|
||||
|
||||
interface IconPickerProps {
|
||||
initialValue?: string;
|
||||
onChange: (iconUrl: string) => void;
|
||||
@@ -49,34 +51,43 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
|
||||
const groups = data?.icons.map((group) => {
|
||||
const options = group.icons.map((item) => (
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
const value = item.url;
|
||||
startTransition(() => {
|
||||
setValue(value);
|
||||
setPreviewUrl(value);
|
||||
setSearch(value);
|
||||
onChange(value);
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
<Combobox.Option
|
||||
key={item.id}
|
||||
value={item.url}
|
||||
p={0}
|
||||
h="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
w="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card
|
||||
p="sm"
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} radius="md" />
|
||||
</Box>
|
||||
</Card>
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
const value = item.url;
|
||||
startTransition(() => {
|
||||
setValue(value);
|
||||
setPreviewUrl(value);
|
||||
setSearch(value);
|
||||
onChange(value);
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card
|
||||
p="sm"
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={classes.iconCard}
|
||||
withBorder
|
||||
>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} radius="md" />
|
||||
</Box>
|
||||
</Card>
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||
|
||||
import { LanguageCombobox } from "./language-combobox";
|
||||
|
||||
export const CurrentLanguageCombobox = () => {
|
||||
interface CurrentLanguageComboboxProps {
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { changeLocale, isPending } = useChangeLocale();
|
||||
|
||||
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} />;
|
||||
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
|
||||
};
|
||||
@@ -9,14 +9,17 @@ import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
|
||||
import classes from "./language-combobox.module.css";
|
||||
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
interface LanguageComboboxProps {
|
||||
label?: string;
|
||||
value: SupportedLanguage;
|
||||
onChange: (value: SupportedLanguage) => void;
|
||||
isPending?: boolean;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export const LanguageCombobox = ({ label, value, onChange, isPending }: LanguageComboboxProps) => {
|
||||
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
@@ -49,6 +52,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending }: Language
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
variant="filled"
|
||||
w={width}
|
||||
>
|
||||
<OptionItem currentLocale={value} localeKey={value} />
|
||||
</InputBase>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
import { Indicator, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
|
||||
export const UserButton = () => {
|
||||
export const UserButton = async () => {
|
||||
const data = await api.updateChecker.getAvailableUpdates();
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
return (
|
||||
<UserAvatarMenu>
|
||||
<UserAvatarMenu availableUpdates={isAdmin ? data : undefined}>
|
||||
<UnstyledButton>
|
||||
<CurrentUserAvatar size="md" />
|
||||
<Indicator disabled={data.length === 0 || !isAdmin} size={15} processing withBorder>
|
||||
<CurrentUserAvatar size="md" />
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from "react";
|
||||
import { AppShellNavbar, AppShellSection, ScrollArea } from "@mantine/core";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import {
|
||||
IconBellRinging,
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
@@ -17,20 +18,20 @@ import {
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([["mod+J", toggleColorScheme]]);
|
||||
@@ -64,6 +65,21 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withArrow withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
{availableUpdates && availableUpdates.length > 0 && availableUpdates[0] && (
|
||||
<>
|
||||
<Menu.Item
|
||||
component={"a"}
|
||||
href={availableUpdates[0].url}
|
||||
target="_blank"
|
||||
leftSection={<IconBellRinging size="1rem" />}
|
||||
>
|
||||
<Text fw="bold" size="sm">
|
||||
{t("updateAvailable", { countUpdates: availableUpdates.length, tag: availableUpdates[0].tagName })}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
|
||||
{colorSchemeText}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import SuperJSON from "superjson";
|
||||
@@ -11,6 +12,15 @@ export async function middleware(request: NextRequest) {
|
||||
// In next 15 we will be able to use node apis and such the db directly
|
||||
const culture = await serverFetchApi.serverSettings.getCulture.query();
|
||||
|
||||
// Redirect to onboarding if it's not finished yet
|
||||
const pathname = request.nextUrl.pathname;
|
||||
if (!pathname.endsWith("/init")) {
|
||||
const currentOnboardingStep = await serverFetchApi.onboard.currentStep.query();
|
||||
if (currentOnboardingStep.current !== "finish") {
|
||||
return NextResponse.redirect(new URL("/init", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to fallback to accept-language header so we clear it
|
||||
request.headers.set("accept-language", "");
|
||||
const next = createI18nMiddleware(culture.defaultLocale);
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": [".", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
@@ -38,15 +38,15 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"superjson": "2.2.2",
|
||||
"undici": "7.1.0"
|
||||
"undici": "7.1.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.1",
|
||||
"@types/node": "^22.10.2",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint": "^9.17.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint": "^9.17.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
files:
|
||||
- source: /packages/translation/src/lang/en.json
|
||||
translation: /packages/translation/src/lang/%two_letters_code%.json
|
||||
|
||||
# Title of pull request and so the commit that will be used for squashed merge commit
|
||||
pull_request_title: "chore(lang): updated translations from crowdin"
|
||||
|
||||
# Custom commit message that is not only appended
|
||||
commit_message: "chore(lang): update translations %original_file_name% from crowdin [skip ci]"
|
||||
append_commit_message: false
|
||||
@@ -1 +1 @@
|
||||
docker run -p 7575:7575 -e AUTH_SECRET='secrets' homarr:latest
|
||||
docker run -p 7575:7575 homarr:latest
|
||||
@@ -0,0 +1,130 @@
|
||||
<!-- Project Title -->
|
||||

|
||||
|
||||
<!-- Badges -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/homarr-labs/homarr?label=%E2%AD%90%20Stars&style=flat-square?branch=master&kill_cache=1%22">
|
||||
<a href="https://github.com/homarr-labs/homarr/releases/latest">
|
||||
<img alt="Latest Release (Semver)" src="https://img.shields.io/github/v/release/homarr-labs/homarr?label=%F0%9F%9A%80%20Release">
|
||||
</a>
|
||||
<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>
|
||||
<a href="https://discord.gg/aCsmEV5RgA">
|
||||
<img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Links -->
|
||||
<p align="center">
|
||||
<a href="https://demo.homarr.dev/">
|
||||
<strong>Demo ✨</strong>
|
||||
</a>
|
||||
•
|
||||
<a href="https://homarr.dev/docs/getting-started/installation/">
|
||||
<strong>Install 💻</strong>
|
||||
</a> •
|
||||
<a href="https://translate.homarr.dev/">
|
||||
<strong>Translations 🈺</strong>
|
||||
</a> •
|
||||
<a href="https://discord.com/invite/aCsmEV5RgA">
|
||||
<strong>Discord 👋</strong>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> This is a beta version of Homarr. You can download the old but stable version at https://github.com/ajnart/homarr/. Running this version is at your own risk.
|
||||
|
||||
Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
|
||||
- 🖌️ Highly customizable with an extensive drag and drop grid system
|
||||
- ✨ Integrates seamlessly with your favorite self-hosted applications
|
||||
- 📌 Easy and fast app management - no YAML involved
|
||||
- 👤 Detailed and easy to use user management with permissions and groups
|
||||
- 👥 Support for single sign on via OIDC / LDAP
|
||||
- 🙊 Safe encryption using BCrypt and AES-256-CBC for your valuable data
|
||||
- 🕔 Realtime widget updates using WebSockets, tRPC and Redis
|
||||
- 🔍 Search through thousands of data points in supported integrations or your data in Homarr using the fast built-in search
|
||||
- 🦞 Icon picker with over 11K icons
|
||||
- 🚀 Compatible with any major consumer hardware (x86, Raspberry Pi, old laptops, ...) and most OS (Windows, Linux, TrueNAS, Unraid)
|
||||
- 🖥️ Extensive Kubernetes support with Helm for efficient scaling & high reliability
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
|
||||
Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/category/integrations), that connect to your applications and enable you to control them directly from the dashboard.
|
||||
|
||||
- 📥 **Torrent clients**
|
||||
- [Deluge](https://homarr.dev/docs/integrations/torrent#deluge)
|
||||
- [Transmission](https://homarr.dev/docs/integrations/torrent#transmission)
|
||||
- [qBittorent](https://homarr.dev/docs/integrations/torrent#qbittorrent-integration)
|
||||
- 📥 **Usenet clients**
|
||||
- [SABnzbd](https://homarr.dev/docs/integrations/usenet#sabnzbd)
|
||||
- [NZBGet](https://homarr.dev/docs/integrations/usenet#nzbget)
|
||||
- 📺 **Media servers**
|
||||
- [Plex](https://homarr.dev/docs/integrations/media-server/#plex)
|
||||
- [Jellyfin](https://homarr.dev/docs/integrations/media-server#jellyfin-and-emby)
|
||||
- 📚 **Media collection managers**
|
||||
- [Sonarr](https://homarr.dev/docs/integrations/servarr#sonarr)
|
||||
- [Radarr](https://homarr.dev/docs/integrations/servarr#radarr)
|
||||
- [Lidarr](https://homarr.dev/docs/integrations/servarr#lidarr)
|
||||
- [Readarr](https://homarr.dev/docs/integrations/servarr#readarr)
|
||||
- 🎞️ **Media request managers**
|
||||
- [Overseerr](https://homarr.dev/docs/integrations/media-requester)
|
||||
- [Jellyseerr](https://homarr.dev/docs/integrations/media-requester)
|
||||
- 🚫 **DNS ad-blockers**
|
||||
- [Pihole](https://homarr.dev/docs/integrations/dns#pihole)
|
||||
- [AdGuard Home](https://homarr.dev/docs/integrations/dns#adguard-home)
|
||||
- 🖥️ **Monitoring**
|
||||
- [Dash.](https://homarr.dev/docs/integrations/hardware)
|
||||
- [OpenMediaVault.](https://homarr.dev/docs/integrations/hardware/#openmediavault)
|
||||
- [Proxmox.](https://homarr.dev/docs/integrations/hardware/#proxmox)
|
||||
- 🐳 **Container management**:
|
||||
- [Docker](https://homarr.dev/docs/integrations/containers)
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
|
||||
Since we are updating Homarr very frequently, we recommend reading our official installation guides:
|
||||
|
||||
<h2>
|
||||
<a href="https://homarr.dev/docs/getting-started/installation/">
|
||||
Please click here for official installation instructions
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
|
||||
<br/>
|
||||
|
||||
Homarr is a free to use open source project that is maintained by volunteers and developers from all over the world. We publish under the ``Apache License 2.0`` license which allows commercial usage. We invest multiple hours daily in to providing support, developing Homarr, integrating to third party software and more. We also pay for licensing and server hosting fees.
|
||||
Please consider to help us cover these costs to enable the future development of Homarr. Thank you!
|
||||
|
||||
<h2>
|
||||
<a href="https://opencollective.com/homarr">
|
||||
Please click here to sponsor us at OpenCollective
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
You can also support us by helping with [translating the entire project](https://homarr.dev/docs/community/translations) to as many languages as possible or contributing directly to the code or documentation. Please read our [Contribution Guidelines](/CONTRIBUTING.md). All contributions, regardless of their size or scope, are welcome and highly appreciated! Thank you ❤️
|
||||
|
||||
## Sponsors
|
||||
Thanks to your generous sponsors we can continue to build Homarr. Check them out for high quality and easy to use development tools.
|
||||
Feel free to contact us at homarr-labs@proton.me if you wish to become a sponsor.
|
||||
|
||||
[](https://argos-ci.com?utm_source=%5Bhomarr%5D&utm_campaign=oss)
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -6,9 +6,6 @@ export const createHomarrContainer = () => {
|
||||
}
|
||||
|
||||
return new GenericContainer("homarr-e2e")
|
||||
.withEnvironment({
|
||||
AUTH_SECRET: "secret",
|
||||
})
|
||||
.withExposedPorts(7575)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575));
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.0.0-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -20,6 +21,7 @@
|
||||
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
|
||||
"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",
|
||||
@@ -29,13 +31,21 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"prettier": "^3.4.2",
|
||||
"semantic-release": "^24.2.0",
|
||||
"testcontainers": "^10.16.0",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.2",
|
||||
@@ -47,9 +57,9 @@
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"allowNonAppliedPatches": true,
|
||||
"patchedDependencies": {
|
||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||
},
|
||||
"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.16.0",
|
||||
"eslint": "^9.17.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
"@trpc/server": "next",
|
||||
"dockerode": "^4.0.2",
|
||||
"next": "^14.2.20",
|
||||
"react": "^18.3.1",
|
||||
"react": "^19.0.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^2.1.0"
|
||||
},
|
||||
@@ -50,7 +50,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.32",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint": "^9.17.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import { dockerRouter } from "./router/docker/docker-router";
|
||||
import { groupRouter } from "./router/group";
|
||||
import { homeRouter } from "./router/home";
|
||||
import { iconsRouter } from "./router/icons";
|
||||
import { importRouter } from "./router/import/import-router";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { locationRouter } from "./router/location";
|
||||
import { logRouter } from "./router/log";
|
||||
import { mediaRouter } from "./router/medias/media-router";
|
||||
import { onboardRouter } from "./router/onboard/onboard-router";
|
||||
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { updateCheckerRouter } from "./router/update-checker";
|
||||
import { userRouter } from "./router/user";
|
||||
import { widgetRouter } from "./router/widgets";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
@@ -29,12 +32,15 @@ export const appRouter = createTRPCRouter({
|
||||
location: locationRouter,
|
||||
log: logRouter,
|
||||
icon: iconsRouter,
|
||||
import: importRouter,
|
||||
onboard: onboardRouter,
|
||||
home: homeRouter,
|
||||
docker: dockerRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
cronJobs: cronJobsRouter,
|
||||
apiKeys: apiKeysRouter,
|
||||
media: mediaRouter,
|
||||
updateChecker: updateCheckerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import { importAsync } from "@homarr/old-import";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
import { createSectionSchema, sharedItemSchema, validation, z } from "@homarr/validation";
|
||||
import { createSectionSchema, sharedItemSchema, validation, z, zodUnionFromArray } from "@homarr/validation";
|
||||
|
||||
import { zodUnionFromArray } from "../../../validation/src/enums";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
@@ -575,12 +575,13 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
});
|
||||
}),
|
||||
importOldmarrConfig: protectedProcedure
|
||||
.input(validation.board.importOldmarrConfig)
|
||||
importOldmarrConfig: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(importJsonFileSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const content = await input.file.text();
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importAsync(ctx.db, oldmarr, input.configuration);
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Container } from "dockerode";
|
||||
import { db, like, or } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema/sqlite";
|
||||
import type { DockerContainerState } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
import { createCacheChannel } from "@homarr/redis";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
@@ -17,42 +18,60 @@ const dockerCache = createCacheChannel<{
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||
const { timestamp, data } = await dockerCache.consumeAsync(async () => {
|
||||
const dockerInstances = DockerSingleton.getInstance();
|
||||
const containers = await Promise.all(
|
||||
// Return all the containers of all the instances into only one item
|
||||
dockerInstances.map(({ instance, host: key }) =>
|
||||
instance.listContainers({ all: true }).then((containers) =>
|
||||
containers.map((container) => ({
|
||||
...container,
|
||||
instance: key,
|
||||
})),
|
||||
const result = await dockerCache
|
||||
.consumeAsync(async () => {
|
||||
const dockerInstances = DockerSingleton.getInstance();
|
||||
const containers = await Promise.all(
|
||||
// Return all the containers of all the instances into only one item
|
||||
dockerInstances.map(({ instance, host: key }) =>
|
||||
instance.listContainers({ all: true }).then((containers) =>
|
||||
containers.map((container) => ({
|
||||
...container,
|
||||
instance: key,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
).then((containers) => containers.flat());
|
||||
).then((containers) => containers.flat());
|
||||
|
||||
const extractImage = (container: Docker.ContainerInfo) =>
|
||||
container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
|
||||
const dbIcons =
|
||||
likeQueries.length >= 1
|
||||
? await db.query.icons.findMany({
|
||||
where: or(...likeQueries),
|
||||
})
|
||||
: [];
|
||||
const extractImage = (container: Docker.ContainerInfo) =>
|
||||
container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
|
||||
const dbIcons =
|
||||
likeQueries.length >= 1
|
||||
? await db.query.icons.findMany({
|
||||
where: or(...likeQueries),
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
containers: containers.map((container) => ({
|
||||
...container,
|
||||
iconUrl:
|
||||
dbIcons.find((icon) => {
|
||||
const extractedImage = extractImage(container);
|
||||
if (!extractedImage) return false;
|
||||
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
|
||||
})?.url ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return {
|
||||
containers: containers.map((container) => ({
|
||||
...container,
|
||||
iconUrl:
|
||||
dbIcons.find((icon) => {
|
||||
const extractedImage = extractImage(container);
|
||||
if (!extractedImage) return false;
|
||||
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
|
||||
})?.url ?? null,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
return {
|
||||
isError: true,
|
||||
error: error as unknown,
|
||||
};
|
||||
});
|
||||
|
||||
if ("isError" in result) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching the containers",
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, timestamp } = result;
|
||||
|
||||
return {
|
||||
containers: sanitizeContainers(data.containers),
|
||||
|
||||
@@ -6,8 +6,9 @@ import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getPaginated: permissionRequiredProcedure
|
||||
@@ -145,6 +146,19 @@ export const groupRouter = createTRPCRouter({
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
createInitialExternalGroup: onboardingProcedure
|
||||
.requiresStep("group")
|
||||
.input(validation.group.create)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
await ctx.db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: input.name,
|
||||
});
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
createGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.create)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
importInitialOldmarrAsync,
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
|
||||
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
|
||||
|
||||
export const importRouter = createTRPCRouter({
|
||||
analyseInitialOldmarrImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(analyseOldmarrImportInputSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await analyseOldmarrImportForRouterAsync(input);
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
importInitialOldmarrImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(importInitialOldmarrInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await importInitialOldmarrAsync(ctx.db, input);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -6,11 +6,12 @@ import { invites } from "@homarr/db/schema/sqlite";
|
||||
import { selectInviteSchema } from "@homarr/db/validationSchemas";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
|
||||
export const inviteRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure
|
||||
getAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.output(
|
||||
z.array(
|
||||
selectInviteSchema
|
||||
@@ -40,7 +41,8 @@ export const inviteRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
createInvite: protectedProcedure
|
||||
createInvite: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
expirationDate: z.date(),
|
||||
@@ -65,7 +67,8 @@ export const inviteRouter = createTRPCRouter({
|
||||
token,
|
||||
};
|
||||
}),
|
||||
deleteInvite: protectedProcedure
|
||||
deleteInvite: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { groups, onboarding } from "@homarr/db/schema/sqlite";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
|
||||
export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => {
|
||||
const { current } = await getOnboardingOrFallbackAsync(db);
|
||||
const nextStepConfiguration = nextSteps[current];
|
||||
if (!nextStepConfiguration) return;
|
||||
|
||||
for (const conditionalStep of objectEntries(nextStepConfiguration)) {
|
||||
if (!conditionalStep) continue;
|
||||
const [nextStep, condition] = conditionalStep;
|
||||
if (condition === "preferred" && nextStep !== preferredStep) continue;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (typeof condition === "boolean" && !condition) continue;
|
||||
if (typeof condition === "function" && !(await condition(db))) continue;
|
||||
|
||||
await db.update(onboarding).set({
|
||||
previousStep: current,
|
||||
step: nextStep,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const getOnboardingOrFallbackAsync = async (db: Database) => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
};
|
||||
|
||||
type NextStepCondition = true | "preferred" | ((db: Database) => MaybePromise<boolean>);
|
||||
|
||||
/**
|
||||
* The below object is a definition of which can be the next step of the current one.
|
||||
* If the value is `true`, it means the step can always be the next one.
|
||||
* If the value is `preferred`, it means that the step can only be reached if the input `preferredStep` is set to the step.
|
||||
* If the value is a function, it will be called with the database instance and should return a boolean.
|
||||
* If the value or result is `false`, the step has to be skipped and the next value or callback should be checked.
|
||||
*/
|
||||
const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, NextStepCondition>>>> = {
|
||||
start: {
|
||||
import: "preferred" as const,
|
||||
user: () => isProviderEnabled("credentials"),
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
import: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
user: async (db: Database) => {
|
||||
if (!isProviderEnabled("credentials")) return false;
|
||||
|
||||
const adminGroup = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !adminGroup || adminGroup.members.length === 0;
|
||||
},
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
user: {
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
group: {
|
||||
settings: true,
|
||||
},
|
||||
settings: {
|
||||
finish: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { onboarding } from "@homarr/db/schema/sqlite";
|
||||
import { onboardingSteps } from "@homarr/definitions";
|
||||
import { z, zodEnumFromArray } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
|
||||
|
||||
export const onboardRouter = createTRPCRouter({
|
||||
currentStep: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getOnboardingOrFallbackAsync(ctx.db);
|
||||
}),
|
||||
nextStep: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
// Preferred step is only needed for 'preferred' conditions
|
||||
preferredStep: zodEnumFromArray(onboardingSteps).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await nextOnboardingStepAsync(ctx.db, input.preferredStep);
|
||||
}),
|
||||
previousStep: publicProcedure.mutation(async ({ ctx }) => {
|
||||
const { previous } = await getOnboardingOrFallbackAsync(ctx.db);
|
||||
|
||||
if (previous !== "start") {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.db.update(onboarding).set({
|
||||
previousStep: null,
|
||||
step: "start",
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,18 +1,20 @@
|
||||
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { z } from "@homarr/validation";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||
}),
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
return await getServerSettingsAsync(ctx.db);
|
||||
}),
|
||||
saveSettings: protectedProcedure
|
||||
saveSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
settingsKey: z.enum(defaultServerSettingsKeys),
|
||||
@@ -26,4 +28,12 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
input.value as ServerSettings[keyof ServerSettings],
|
||||
);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
.input(validation.settings.init)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
|
||||
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { inviteRouter } from "../invite";
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: createId(),
|
||||
permissions: [],
|
||||
permissions: ["admin"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: createId(),
|
||||
permissions: [],
|
||||
permissions: ["admin"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { createId, eq, schema } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { onboarding, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { userRouter } from "../user";
|
||||
|
||||
@@ -36,31 +37,9 @@ vi.mock("@homarr/auth/env.mjs", () => {
|
||||
});
|
||||
|
||||
describe("initUser should initialize the first user", () => {
|
||||
it("should throw an error if a user already exists", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await db.insert(schema.users).values({
|
||||
id: "test",
|
||||
name: "test",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
await caller.initUser({
|
||||
username: "test",
|
||||
password: "123ABCdef+/-",
|
||||
confirmPassword: "123ABCdef+/-",
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrow("User already exists");
|
||||
});
|
||||
|
||||
it("should create a user if none exists", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
@@ -83,6 +62,7 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should not create a user if the password and confirmPassword do not match", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
@@ -106,6 +86,7 @@ describe("initUser should initialize the first user", () => {
|
||||
["abc123+/-"], // does not contain uppercase
|
||||
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
@@ -324,3 +305,10 @@ describe("delete should delete user", () => {
|
||||
expect(usersInDb[1]).containSubset(initialUsers[2]);
|
||||
});
|
||||
});
|
||||
|
||||
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
|
||||
await db.insert(onboarding).values({
|
||||
id: createId(),
|
||||
step,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const updateCheckerRouter = createTRPCRouter({
|
||||
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
const data = await handler.getCachedOrUpdatedDataAsync({});
|
||||
return data.data.availableUpdates;
|
||||
}),
|
||||
});
|
||||
@@ -5,47 +5,46 @@ import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like, schema } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
onboardingProcedure,
|
||||
permissionRequiredProcedure,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
initUser: publicProcedure.input(validation.user.init).mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
initUser: onboardingProcedure
|
||||
.requiresStep("user")
|
||||
.input(validation.user.init)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const firstUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstUser) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User already exists",
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "admin",
|
||||
ownerId: userId,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
}),
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
register: publicProcedure
|
||||
.input(validation.user.registrationApi)
|
||||
.output(z.void())
|
||||
|
||||
@@ -10,7 +10,7 @@ import { controlsInputSchema } from "@homarr/integrations/types";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
export const dnsHoleRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
@@ -62,7 +62,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
enable: publicProcedure
|
||||
enable: protectedProcedure
|
||||
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.mutation(async ({ ctx: { integration } }) => {
|
||||
const client = integrationCreator(integration);
|
||||
@@ -75,7 +75,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
disable: publicProcedure
|
||||
disable: protectedProcedure
|
||||
.input(controlsInputSchema)
|
||||
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { healthMonitoringRouter } from "./health-monitoring";
|
||||
import { indexerManagerRouter } from "./indexer-manager";
|
||||
import { mediaRequestsRouter } from "./media-requests";
|
||||
import { mediaServerRouter } from "./media-server";
|
||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
@@ -25,4 +26,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
rssFeed: rssFeedRouter,
|
||||
indexerManager: indexerManagerRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
|
||||
@@ -54,7 +54,7 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
}),
|
||||
testAllIndexers: publicProcedure
|
||||
testAllIndexers: protectedProcedure
|
||||
.unstable_concat(createIndexerManagerIntegrationMiddleware("interact"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
|
||||