Compare commits

...
Author SHA1 Message Date
Releases Homarr 4543a5fd2c chore(release): 1.0.0-beta.1 [skip ci] 2024-12-17 19:13:35 +00:00
homarr-releases[bot] 52cd6fc913 chore(release): automatic release v0.1.0 2024-12-17 19:12:29 +00:00
Meier Lukas daf7ea56c5 fix: add persists credentials false to checkout of release pipeline (#1691) 2024-12-17 20:11:48 +01:00
homarr-releases[bot] b47cfb2892 chore(release): automatic release v0.1.0 2024-12-17 19:05:20 +00:00
Meier Lukas 0f029b384d fix: change permission for github action to contents write (#1689) 2024-12-17 20:04:20 +01:00
homarr-releases[bot] ada60a7004 chore(release): automatic release v0.1.0 2024-12-17 18:56:25 +00:00
Meier Lukas 93abe8f6a2 fix: change docker concurrency group to allow multiple deployments to different branches (#1687) 2024-12-17 19:55:01 +01:00
homarr-releases[bot] 8b0e6435d0 chore(release): automatic release v0.1.0 2024-12-17 18:45:41 +00:00
Meier Lukas 2b5ed83de3 ci: adjust release pipeline for semver releases (#1679) 2024-12-17 19:41:51 +01:00
Manuel 692ef7f6dc docs: add readme (#1683) 2024-12-17 19:31:11 +01:00
Meier Lukas 15d47d0585 ci: generate auth secret in production (#1681)
* ci: generate auth secret in production

* refactor: remove no longer needed auth-secret from e2e test

* fix: remove static auth secret
2024-12-17 19:10:19 +01:00
Meier Lukas c050ec4fe9 docs: update license to apache 2.0 (#1684)
* Update LICENSE

* docs: add ajnart as copyright holder
2024-12-17 19:10:06 +01:00
homarr-renovate[bot] 28667c5e1a fix(deps): update tanstack-query monorepo to ^5.62.8 (#1682)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-17 17:36:20 +00:00
homarr-renovate[bot] dfa2e83238 chore(deps): update dependency @types/chroma-js to v2.4.5 (#1680)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-17 14:53:52 +00:00
homarr-crowdin[bot] 21126b6adc chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-17 08:40:01 +00:00
homarr-renovate[bot] 02777d104c fix(deps): update dependency undici to v7.1.1 (#1674)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-16 22:33:55 +00:00
homarr-renovate[bot] 613d1f9662 fix(deps): update dependency typescript-eslint to ^8.18.1 (#1673)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-16 20:10:43 +00:00
Meier Lukas 8e4d67c3eb feat: add not found pages for boards (#1671) 2024-12-16 17:25:44 +01:00
homarr-renovate[bot] b7b5da654b fix(deps): update dependency jotai to ^2.10.4 (#1672)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-16 14:38:19 +00:00
Meier Lukas e3bf3f371c test: reduce flakiness of nzbget and sabnzbd (#1669) 2024-12-16 15:06:31 +01:00
homarr-crowdin[bot] 73f7df771b chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-16 00:50:03 +00:00
homarr-renovate[bot] 8c596bf5fa fix(deps): update dependency @tabler/icons-react to ^3.26.0 (#1667)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-15 20:37:54 +00:00
Meier Lukas 922101dcbd fix: issues found in security audit (#1668) 2024-12-15 21:16:42 +01:00
ManuelandMeier Lukas 032509e462 feat: add tdarr integration (#1657)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-15 15:53:02 +01:00
Meier Lukas 6de74d9525 feat: add onboarding with oldmarr import (#1606) 2024-12-15 15:40:26 +01:00
Meier Lukas 82ec77d2da fix: icon picker keyboard interactions (#1666) 2024-12-15 15:31:45 +01:00
Manuel 8ca00161fa docs: create SECURITY.md (#1662) 2024-12-15 15:16:31 +01:00
homarr-crowdin[bot] d5399d20e3 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-15 12:16:48 +00:00
Meier Lukas f330c7e4f1 fix: auto-merge crowdin pr not working (#1664) 2024-12-15 13:11:49 +01:00
Meier Lukas 5bdc669e07 fix: auto-merge for crowdin download pull request not working (#1661) 2024-12-15 13:02:44 +01:00
Meier Lukas f857105994 fix: trpc error at least one integration (#1660) 2024-12-15 13:00:53 +01:00
Meier Lukas 60311918ce feat: add error page for docker tools page (#1655) 2024-12-15 12:34:42 +01:00
homarr-crowdin[bot] 2ef36366ec chore(lang): updated translations from crowdin (#1658)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-15 12:13:21 +01:00
homarr-renovate[bot] 3347886f1e fix(deps): update dependency tldts to ^6.1.68 (#1656)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-14 18:19:18 +00:00
Manuel dd9d8b5261 feat: add update indicator (#1626) 2024-12-14 18:58:46 +01:00
Meier Lukas cf9656d91e fix: styles for light mode (#1645) 2024-12-14 15:19:18 +01:00
homarr-renovate[bot] da63d8ebf4 fix(deps): update dependency drizzle-orm to ^0.38.2 (#1652)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-13 21:54:17 +00:00
homarr-renovate[bot] e0152f4ac7 chore(deps): update dependency eslint to ^9.17.0 (#1654)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-13 21:42:35 +00:00
homarr-renovate[bot] 732f4d45bd fix(deps): update mantine monorepo to ^7.15.1 (#1649)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-13 16:28:29 +00:00
homarr-renovate[bot] 6418dfed38 fix(deps): update dependency drizzle-kit to ^0.30.1 (#1651)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-13 16:19:16 +00:00
homarr-renovate[bot] 0bd8e2c085 fix(deps): update dependency sass to ^1.83.0 (#1650)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-13 13:14:29 +00:00
homarr-renovate[bot] 7d0a224a3f fix(deps): update dependency drizzle-orm to ^0.38.1 (#1646)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-11 23:15:51 +00:00
homarr-renovate[bot] 75c0f70ef6 fix(deps): update dependency tldts to ^6.1.67 (#1648)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-11 22:54:37 +00:00
homarr-renovate[bot] 4c7058b159 fix(deps): update dependency @million/lint to v1.0.14 (#1647)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-11 22:38:38 +00:00
homarr-renovate[bot] 51948677fa fix(deps): update dependency next-intl to v3.26.1 (#1644)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-11 09:54:19 +00:00
homarr-renovate[bot] d40ec67aa9 chore(deps): update dependency @types/node to ^22.10.2 (#1643)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-11 09:53:46 +00:00
homarr-renovate[bot] 5e466e80c5 fix(deps): update dependency zod to ^3.24.1 (#1642)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-11 09:51:38 +00:00
Meier Lukas db7058fdd9 chore(docs): updated docs sitemap (#1639) 2024-12-10 20:45:43 +01:00
Meier Lukas b6733c678c ci: add auto-merge after crowdin download and automatic approval (#1632) 2024-12-10 20:36:00 +01:00
homarr-renovate[bot] e692cc8917 fix(deps): update tanstack-query monorepo to ^5.62.7 (#1638)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-10 19:24:36 +00:00
homarr-renovate[bot] 99bb68012f fix(deps): update mantine monorepo to ^7.15.0 (#1636)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-10 18:16:44 +00:00
homarr-renovate[bot]andMeier Lukas 6f874e87ab fix(deps): update react monorepo to v19 (major) (#1615)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-10 18:49:31 +01:00
homarr-renovate[bot] 8a2546704b fix(deps): update dependency zod to ^3.24.0 (#1635)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-10 07:30:23 +00:00
homarr-renovate[bot]andMeier Lukas 3bee3fe3f6 fix(deps): update dependency typescript-eslint to ^8.18.0 (#1634)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-09 20:39:09 +01:00
homarr-renovate[bot] 51c87c1a33 fix(deps): update dependency drizzle-zod to ^0.6.0 (#1629)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-09 17:50:03 +00:00
homarr-crowdin[bot] 72e81f05a6 chore(lang): updated translations from crowdin (#1633)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-09 18:29:06 +01:00
homarr-renovate[bot] 2825c9bf45 fix(deps): update dependency drizzle-orm to ^0.38.0 (#1631)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-09 16:41:22 +00:00
homarr-renovate[bot] 46ce06155b fix(deps): update dependency drizzle-kit to ^0.30.0 (#1630)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-09 16:15:13 +00:00
homarr-renovate[bot] 753e0ecad2 fix(deps): update dependency better-sqlite3 to ^11.7.0 (#1628)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-08 03:50:46 +00:00
276 changed files with 18372 additions and 1624 deletions

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"
+78 -45
View File
@@ -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
+51
View File
@@ -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': '' }) %>"
}
]
]
}
+1064
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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
+197 -17
View File
@@ -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.
-7
View File
@@ -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
+29
View File
@@ -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
+21 -19
View File
@@ -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>
+10
View File
@@ -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);
+1 -1
View File
@@ -13,5 +13,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": [".", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next"]
}
+3 -3
View File
@@ -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"
+1 -1
View File
@@ -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"
}
-7
View File
@@ -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
View File
@@ -1 +1 @@
docker run -p 7575:7575 -e AUTH_SECRET='secrets' homarr:latest
docker run -p 7575:7575 homarr:latest
+130
View File
@@ -0,0 +1,130 @@
<!-- Project Title -->
![Banner](./banner.png)
<!-- 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/>
![Features Section](./section-features.png)
- 🖌️ 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/>
![Widgets & Integrations Section](./section-widgets-and-integrations.png)
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/>
![Installation Section](./section-installation.png)
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/>
![Contribute Section](./section-contribute.png)
<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.
[![Covered by Argos Visual Testing](https://argos-ci.com/badge-large.svg)](https://argos-ci.com?utm_source=%5Bhomarr%5D&utm_campaign=oss)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

-3
View File
@@ -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));
};
+12 -2
View File
@@ -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
}
}
}
+1 -1
View File
@@ -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"
}
}
+2 -2
View File
@@ -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
View File
@@ -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
+7 -6
View File
@@ -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);
}),
});
+52 -33
View File
@@ -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),
+15 -1
View File
@@ -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);
}),
});
+7 -4
View File
@@ -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",
});
}),
});
+14 -4
View File
@@ -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);
}),
});
+1 -1
View File
@@ -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(),
+13 -25
View File
@@ -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,
});
};
+11
View File
@@ -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;
}),
});
+30 -31
View File
@@ -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())
+3 -3
View File
@@ -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 }) => {
+2
View File
@@ -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(
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more