diff --git a/.github/renovate.json b/.github/renovate.json5
similarity index 75%
rename from .github/renovate.json
rename to .github/renovate.json5
index 69fb4cdad..8978459cf 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json5
@@ -6,7 +6,8 @@
"packageRules": [
{
"matchPackagePatterns": [
- "^@homarr/"
+ "^@homarr/",
+ "tsx" // Disabled for now as version 0.14.4 did not work with the current version of homarr. It resulted in a ERR_MODULE_NOT_FOUND error
],
"enabled": false
},
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
deleted file mode 100644
index b8fbfaa9d..000000000
--- a/.github/workflows/build.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: Build apps and migration script
-
-on:
- pull_request:
- branches: ["*"]
- push:
- branches: ["main"]
- merge_group:
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
-
-env:
- FORCE_COLOR: 3
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Setup
- uses: ./tooling/github/setup
-
- - name: Copy env
- shell: bash
- run: cp .env.example .env
-
- - name: Build
- run: pnpm build
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 228dd9989..a75c144b7 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -1,4 +1,4 @@
-name: Code quality analysis
+name: "[Quality] Code Analysis"
on:
pull_request:
@@ -8,7 +8,7 @@ on:
merge_group:
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
+ group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
@@ -72,3 +72,15 @@ jobs:
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
+
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup
+ uses: ./tooling/github/setup
+ - name: Copy env
+ shell: bash
+ run: cp .env.example .env
+ - name: Build
+ run: pnpm build
diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventions-semantic-commits.yml
similarity index 77%
rename from .github/workflows/conventional-commits.yml
rename to .github/workflows/conventions-semantic-commits.yml
index 8f2216bd7..d26307ba0 100644
--- a/.github/workflows/conventional-commits.yml
+++ b/.github/workflows/conventions-semantic-commits.yml
@@ -1,7 +1,5 @@
-
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
-
-name: Conventional Commits
+name: "[Conventions] Semantic Commits"
on:
pull_request:
@@ -12,5 +10,5 @@ jobs:
name: Conventional Commits
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
\ No newline at end of file
diff --git a/.github/workflows/pr-conventional-commits.yml b/.github/workflows/conventions-semantic-pull-requests.yml
similarity index 81%
rename from .github/workflows/pr-conventional-commits.yml
rename to .github/workflows/conventions-semantic-pull-requests.yml
index e3dfb587b..cec459930 100644
--- a/.github/workflows/pr-conventional-commits.yml
+++ b/.github/workflows/conventions-semantic-pull-requests.yml
@@ -1,4 +1,4 @@
-name: "Lint PR"
+name: "[Conventions] Semantic PRs"
on:
pull_request_target:
@@ -11,8 +11,7 @@ permissions:
pull-requests: read
jobs:
- main:
- name: Validate PR title
+ validate-pull-request-title:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/deployment-docker-image.yml
similarity index 63%
rename from .github/workflows/docker-image.yml
rename to .github/workflows/deployment-docker-image.yml
index 046523358..63592aff5 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/deployment-docker-image.yml
@@ -1,18 +1,25 @@
-name: Docker image
+name: "[Deployment] Release"
on:
- pull_request:
- types:
- - closed
+ push:
branches:
- main
- workflow_dispatch: {}
+ workflow_dispatch:
+ inputs:
+ send-notifications:
+ type: boolean
+ required: false
+ default: true
+ description: Send notifications
permissions:
contents: write
packages: write
env:
+ SKIP_ENV_VALIDATION: true
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
TURBO_TELEMETRY_DISABLED: 1
concurrency: production
@@ -26,6 +33,7 @@ jobs:
node-version: [20]
steps:
- name: Discord notification
+ if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
@@ -37,8 +45,9 @@ jobs:
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
- branch: master
+ branch: dev
- name: Discord notification
+ if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
@@ -52,16 +61,19 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- - name: Install dependencies
- run: pnpm install
- - name: Build artifacts
- run: pnpm build
- name: Discord notification
+ if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Built application artifacts. Building images..."
+ - name: Log in to the Container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
@@ -70,23 +82,27 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: |
- type=raw,value=latest
- type=raw,value=${{ steps.semver.outputs.next }}
+ 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
id: buildPushAction
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v6
with:
- platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
+ platforms: linux/amd64 # we currently do't build for linux/arm64 as it's really slow and we'll move to a self hosted runner for that or use the official github runner, once it's available
context: .
- push: false
+ push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
network: host
+ env:
+ SKIP_ENV_VALIDATION: true
- name: Discord notification
+ if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
diff --git a/.github/workflows/deployment-weekly-release.yml b/.github/workflows/deployment-weekly-release.yml
new file mode 100644
index 000000000..20b732a63
--- /dev/null
+++ b/.github/workflows/deployment-weekly-release.yml
@@ -0,0 +1,101 @@
+name: "[Deployment] Automatic Weekly Release"
+
+on:
+ schedule:
+ - cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
+ workflow_dispatch:
+ inputs:
+ send-notifications:
+ type: boolean
+ required: false
+ default: true
+ description: Send notifications
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ create-and-merge-pr:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Discord notification
+ if: ${{ github.events.inputs.send-notifications }}
+ env:
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ uses: Ilshidur/action-discord@master
+ with:
+ args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Get Next Version
+ id: semver
+ uses: ietf-tools/semver-action@v1
+ with:
+ 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**.
Manual action may be required for major bumps.
Detected change to be ``${{ steps.semver.outputs.bump }}``
Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``\" --base main --head dev --label automerge"
+ env:
+ GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+ - name: Discord notification
+ if: ${{ github.events.inputs.send-notifications }}
+ env:
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ uses: Ilshidur/action-discord@master
+ with:
+ args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
+ - name: Obtain token
+ id: obtainApprovalToken
+ uses: tibdex/github-app-token@v2
+ with:
+ private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
+ app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
+ - name: Approve PR
+ env:
+ GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
+ run: |
+ gh pr review --approve --body "Automatically approved by GitHub Action"
+ - name: Obtain token
+ id: obtainMergeToken
+ uses: tibdex/github-app-token@v2
+ with:
+ private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
+ app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
+ - id: automerge
+ if: ${{ steps.semver.outputs.bump != 'major' }}
+ name: automerge
+ uses: "pascalgn/automerge-action@v0.16.3"
+ env:
+ GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
+ MERGE_METHOD: merge # we prefer merge commits for merging to master
+ MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
+ MERGE_DELETE_BRANCH: false # never set to true!
+ PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
+ MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
+ MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
+ MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
+
+ - name: Merged Discord notification
+ if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
+ env:
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ uses: Ilshidur/action-discord@master
+ with:
+ args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
+ - name: Major Bump Discord notification
+ if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
+ env:
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ uses: Ilshidur/action-discord@master
+ with:
+ args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
+ - name: Discord Fail Notification
+ if: failure() && github.events.inputs.send-notifications
+ env:
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ uses: Ilshidur/action-discord@master
+ with:
+ args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
diff --git a/.github/workflows/renovate-automatic-approval b/.github/workflows/renovate-automatic-approval.yml
similarity index 56%
rename from .github/workflows/renovate-automatic-approval
rename to .github/workflows/renovate-automatic-approval.yml
index b4701ed96..1185fd800 100644
--- a/.github/workflows/renovate-automatic-approval
+++ b/.github/workflows/renovate-automatic-approval.yml
@@ -1,4 +1,4 @@
-name: Approve Renovate PRs
+name: "[Dependency Updates] Auto Approve"
on:
pull_request:
types: [opened, synchronize]
@@ -6,17 +6,20 @@ on:
jobs:
approve-renovate-prs:
runs-on: ubuntu-latest
-
steps:
- name: Checkout code
- uses: actions/checkout@v2
-
+ uses: actions/checkout@v4
+ - name: Obtain token
+ id: obtainToken
+ uses: tibdex/github-app-token@v2
+ with:
+ private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
+ app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Install GitHub CLI
run: sudo apt-get install -y gh
-
- name: Approve Renovate PRs
env:
- GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
diff --git a/.gitignore b/.gitignore
index 8327c8b84..f7d87aa53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,8 +14,8 @@ coverage
out/
next-env.d.ts
-# nest.js
-apps/nestjs/dist
+# artifacts
+packages/db/migrations/*/migrate.cjs
# nitro
.nitro/
diff --git a/.nvmrc b/.nvmrc
index 48b14e6b2..8ce703082 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20.14.0
+20.16.0
diff --git a/.run/db_migration_mysql_generate.run.xml b/.run/db_migration_mysql_generate.run.xml
new file mode 100644
index 000000000..1eaa49077
--- /dev/null
+++ b/.run/db_migration_mysql_generate.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/db_migration_sqlite_generate.run.xml b/.run/db_migration_sqlite_generate.run.xml
new file mode 100644
index 000000000..ea63d6b81
--- /dev/null
+++ b/.run/db_migration_sqlite_generate.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/db_push.run.xml b/.run/db_push.run.xml
new file mode 100644
index 000000000..e5d56c827
--- /dev/null
+++ b/.run/db_push.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/db_studio.run.xml b/.run/db_studio.run.xml
new file mode 100644
index 000000000..df61c66f7
--- /dev/null
+++ b/.run/db_studio.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/dev.run.xml b/.run/dev.run.xml
new file mode 100644
index 000000000..15ec68c7f
--- /dev/null
+++ b/.run/dev.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/docker_dev.run.xml b/.run/docker_dev.run.xml
new file mode 100644
index 000000000..0aa18c69a
--- /dev/null
+++ b/.run/docker_dev.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/format.run.xml b/.run/format.run.xml
new file mode 100644
index 000000000..801ecad89
--- /dev/null
+++ b/.run/format.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/format_fix.run.xml b/.run/format_fix.run.xml
new file mode 100644
index 000000000..391e76671
--- /dev/null
+++ b/.run/format_fix.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/test.run.xml b/.run/test.run.xml
new file mode 100644
index 000000000..d96c3ec1b
--- /dev/null
+++ b/.run/test.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/test_ui.run.xml b/.run/test_ui.run.xml
new file mode 100644
index 000000000..2f18e7aa9
--- /dev/null
+++ b/.run/test_ui.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index b9a963aad..90148c6f2 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,13 +4,23 @@
"mode": "auto"
}
],
+ "eslint.experimental.useFlatConfig": true,
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [
- "superjson",
+ "cqmin",
"homarr",
+ "jellyfin",
+ "superjson",
"trpc",
- "Umami"
- ]
-}
\ No newline at end of file
+ "Umami",
+ "Sonarr"
+ ],
+ "i18n-ally.dirStructure": "auto",
+ "i18n-ally.enabledFrameworks": ["next-international"],
+ "i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
+ "i18n-ally.enabledParsers": ["ts"],
+ "i18n-ally.extract.keyMaxLength": 0,
+ "i18n-ally.keystyle": "flat"
+}
diff --git a/Dockerfile b/Dockerfile
index b9471f77e..845d978c0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,9 @@
-FROM node:20.13.1-alpine AS base
+FROM node:20.16.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
+
# Set working directory
WORKDIR /app
COPY . .
@@ -35,6 +36,7 @@ RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
+RUN corepack enable pnpm && pnpm install
RUN corepack enable pnpm && pnpm install sharp -w
@@ -43,10 +45,12 @@ COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ .
+
# Copy static data as it is not part of the build
COPY static-data ./static-data
-ARG SKIP_ENV_VALIDATION=true
-RUN corepack enable pnpm && pnpm turbo run build
+ARG SKIP_ENV_VALIDATION='true'
+ARG DISABLE_REDIS_LOGS='true'
+RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
@@ -83,5 +87,6 @@ COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
+ENV AUTH_PROVIDERS='credentials'
CMD ["sh", "run.sh"]
diff --git a/apps/nextjs/eslint.config.js b/apps/nextjs/eslint.config.js
new file mode 100644
index 000000000..c131bab90
--- /dev/null
+++ b/apps/nextjs/eslint.config.js
@@ -0,0 +1,13 @@
+import baseConfig from "@homarr/eslint-config/base";
+import nextjsConfig from "@homarr/eslint-config/nextjs";
+import reactConfig from "@homarr/eslint-config/react";
+
+/** @type {import('typescript-eslint').Config} */
+export default [
+ {
+ ignores: [".next/**"],
+ },
+ ...baseConfig,
+ ...reactConfig,
+ ...nextjsConfig,
+];
diff --git a/apps/nextjs/next.config.mjs b/apps/nextjs/next.config.mjs
index fcf1321a4..6dcb85464 100644
--- a/apps/nextjs/next.config.mjs
+++ b/apps/nextjs/next.config.mjs
@@ -1,6 +1,6 @@
// Importing env files here to validate on build
-import "./src/env.mjs";
import "@homarr/auth/env.mjs";
+import "./src/env.mjs";
/** @type {import("next").NextConfig} */
const config = {
@@ -9,6 +9,16 @@ const config = {
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
+ webpack: (config, { isServer }) => {
+ if (isServer) {
+ config.module.rules.push({
+ test: /\.node$/,
+ loader: "node-loader",
+ });
+ }
+
+ return config;
+ },
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
},
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index d82c1decd..9fbf6f904 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -7,7 +7,7 @@
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
- "lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
+ "lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
@@ -18,6 +18,7 @@
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
+ "@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
@@ -31,34 +32,40 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
- "@mantine/colors-generator": "^7.10.0",
- "@mantine/hooks": "^7.10.0",
- "@mantine/modals": "^7.10.0",
- "@mantine/tiptap": "^7.10.0",
+ "@mantine/colors-generator": "^7.11.2",
+ "@mantine/core": "^7.11.2",
+ "@mantine/hooks": "^7.11.2",
+ "@mantine/modals": "^7.11.2",
+ "@mantine/tiptap": "^7.11.2",
"@homarr/server-settings": "workspace:^0.1.0",
- "@t3-oss/env-nextjs": "^0.10.1",
- "@tanstack/react-query": "^5.40.0",
- "@tanstack/react-query-devtools": "^5.40.0",
- "@tanstack/react-query-next-experimental": "5.40.0",
- "@trpc/client": "11.0.0-rc.377",
+ "@t3-oss/env-nextjs": "^0.11.0",
+ "@tanstack/react-query": "^5.51.21",
+ "@tanstack/react-query-devtools": "^5.51.21",
+ "@tanstack/react-query-next-experimental": "5.51.21",
+ "@tabler/icons-react": "^3.11.0",
+ "@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
- "chroma-js": "^2.4.2",
- "dayjs": "^1.11.11",
+ "chroma-js": "^2.6.0",
+ "clsx": "^2.1.1",
+ "dayjs": "^1.11.12",
"dotenv": "^16.4.5",
- "flag-icons": "^7.2.2",
- "glob": "^10.4.1",
- "jotai": "^2.8.2",
- "next": "^14.2.3",
- "postcss-preset-mantine": "^1.15.0",
- "react": "18.3.1",
- "react-dom": "18.3.1",
+ "flag-icons": "^7.2.3",
+ "glob": "^11.0.0",
+ "jotai": "^2.9.1",
+ "mantine-react-table": "2.0.0-beta.6",
+ "next": "^14.2.5",
+ "postcss-preset-mantine": "^1.17.0",
+ "prismjs": "^1.29.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
- "sass": "^1.77.2",
+ "react-simple-code-editor": "^0.14.1",
+ "sass": "^1.77.8",
"superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1"
},
@@ -67,22 +74,15 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
- "@types/node": "^20.12.12",
+ "@types/node": "^20.14.14",
+ "@types/prismjs": "^1.26.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2",
- "eslint": "^8.57.0",
- "prettier": "^3.2.5",
- "tsx": "4.11.0",
- "typescript": "^5.4.5"
- },
- "eslintConfig": {
- "root": true,
- "extends": [
- "@homarr/eslint-config/base",
- "@homarr/eslint-config/nextjs",
- "@homarr/eslint-config/react"
- ]
+ "eslint": "^9.8.0",
+ "node-loader": "^2.0.0",
+ "prettier": "^3.3.3",
+ "typescript": "^5.5.4"
},
"prettier": "@homarr/prettier-config"
}
diff --git a/apps/nextjs/public/images/apps/imdb.png b/apps/nextjs/public/images/apps/imdb.png
new file mode 100644
index 000000000..9565159a4
Binary files /dev/null and b/apps/nextjs/public/images/apps/imdb.png differ
diff --git a/apps/nextjs/public/images/apps/lidarr.svg b/apps/nextjs/public/images/apps/lidarr.svg
new file mode 100644
index 000000000..41c5fb58a
--- /dev/null
+++ b/apps/nextjs/public/images/apps/lidarr.svg
@@ -0,0 +1,25 @@
+
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/radarr.svg b/apps/nextjs/public/images/apps/radarr.svg
new file mode 100644
index 000000000..93a4c9232
--- /dev/null
+++ b/apps/nextjs/public/images/apps/radarr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/readarr.svg b/apps/nextjs/public/images/apps/readarr.svg
new file mode 100644
index 000000000..faae05f79
--- /dev/null
+++ b/apps/nextjs/public/images/apps/readarr.svg
@@ -0,0 +1 @@
+
diff --git a/apps/nextjs/public/images/apps/sonarr.svg b/apps/nextjs/public/images/apps/sonarr.svg
new file mode 100644
index 000000000..86c9243db
--- /dev/null
+++ b/apps/nextjs/public/images/apps/sonarr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/the-tvdb.svg b/apps/nextjs/public/images/apps/the-tvdb.svg
new file mode 100644
index 000000000..b23711d36
--- /dev/null
+++ b/apps/nextjs/public/images/apps/the-tvdb.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/tmdb.png b/apps/nextjs/public/images/apps/tmdb.png
new file mode 100644
index 000000000..9f983b883
Binary files /dev/null and b/apps/nextjs/public/images/apps/tmdb.png differ
diff --git a/apps/nextjs/public/images/apps/truenas.svg b/apps/nextjs/public/images/apps/truenas.svg
new file mode 100644
index 000000000..c3d96ff70
--- /dev/null
+++ b/apps/nextjs/public/images/apps/truenas.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/unraid-alt.svg b/apps/nextjs/public/images/apps/unraid-alt.svg
new file mode 100644
index 000000000..7d695dadc
--- /dev/null
+++ b/apps/nextjs/public/images/apps/unraid-alt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
index 989406228..1624b50b5 100644
--- a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
+import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server";
@@ -19,6 +20,8 @@ interface InviteUsagePageProps {
}
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
+ if (!isProviderEnabled("credentials")) notFound();
+
const session = await auth();
if (session) notFound();
diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx
index d0345ec72..927e0c6f2 100644
--- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx
+++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx
@@ -1,75 +1,153 @@
"use client";
-import { useState } from "react";
+import type { PropsWithChildren } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
-import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
-import { IconAlertTriangle } from "@tabler/icons-react";
+import { Button, Divider, PasswordInput, Stack, TextInput } from "@mantine/core";
import { signIn } from "@homarr/auth/client";
+import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
-export const LoginForm = () => {
+import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
+
+interface LoginFormProps {
+ providers: string[];
+ oidcClientName: string;
+ isOidcAutoLoginEnabled: boolean;
+ callbackUrl: string;
+}
+
+export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user");
const router = useRouter();
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState();
+ const [isPending, setIsPending] = useState(false);
const form = useZodForm(validation.user.signIn, {
initialValues: {
name: "",
password: "",
+ credentialType: "basic",
},
});
- const handleSubmitAsync = async (values: z.infer) => {
- setIsLoading(true);
- setError(undefined);
- await signIn("credentials", {
- ...values,
- redirect: false,
- callbackUrl: "/",
- })
- .then((response) => {
- if (!response?.ok || response.error) {
- throw response?.error;
- }
+ const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
- showSuccessNotification({
- title: t("action.login.notification.success.title"),
- message: t("action.login.notification.success.message"),
- });
- router.push("/");
- })
- .catch((error: Error | string) => {
- setIsLoading(false);
- setError(error.toString());
- showErrorNotification({
- title: t("action.login.notification.error.title"),
- message: t("action.login.notification.error.message"),
- });
+ const onSuccess = useCallback(
+ async (response: Awaited>) => {
+ if (response && (!response.ok || response.error)) {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ throw response.error;
+ }
+
+ showSuccessNotification({
+ title: t("action.login.notification.success.title"),
+ message: t("action.login.notification.success.message"),
});
- };
+
+ // Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
+ if (response) {
+ await revalidatePathActionAsync("/");
+ router.push(callbackUrl);
+ }
+ },
+ [t, router, callbackUrl],
+ );
+
+ const onError = useCallback(() => {
+ setIsPending(false);
+
+ showErrorNotification({
+ title: t("action.login.notification.error.title"),
+ message: t("action.login.notification.error.message"),
+ autoClose: 10000,
+ });
+ }, [t]);
+
+ const signInAsync = useCallback(
+ async (provider: string, options?: Parameters[1]) => {
+ setIsPending(true);
+ await signIn(provider, {
+ ...options,
+ redirect: false,
+ callbackUrl: new URL(callbackUrl, window.location.href).href,
+ })
+ .then(onSuccess)
+ .catch(onError);
+ },
+ [setIsPending, onSuccess, onError, callbackUrl],
+ );
+
+ const isLoginInProgress = useRef(false);
+
+ useEffect(() => {
+ if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
+ isLoginInProgress.current = true;
+ void signInAsync("oidc");
+ }
+ }, [signInAsync, isOidcAutoLoginEnabled, isPending]);
return (
-
+
+ {credentialInputsVisible && (
+ <>
+
+ {providers.includes("oidc") && }
+ >
+ )}
+
+ {providers.includes("oidc") && (
+
+ )}
+
);
};
+
+interface SubmitButtonProps {
+ isPending: boolean;
+ form: ReturnType FormType>>;
+ credentialType: "basic" | "ldap";
+}
+
+const SubmitButton = ({ isPending, form, credentialType, children }: PropsWithChildren) => {
+ const isCurrentProviderActive = form.getValues().credentialType === credentialType;
+
+ return (
+
+ );
+};
+
+type FormType = z.infer;
diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx
index 00de2f2f3..7823d050d 100644
--- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx
+++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx
@@ -1,11 +1,26 @@
+import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
+import { env } from "@homarr/auth/env.mjs";
+import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
-export default async function Login() {
+interface LoginProps {
+ searchParams: {
+ redirectAfterLogin?: string;
+ };
+}
+
+export default async function Login({ searchParams }: LoginProps) {
+ const session = await auth();
+
+ if (session) {
+ redirect(searchParams.redirectAfterLogin ?? "/");
+ }
+
const t = await getScopedI18n("user.page.login");
return (
@@ -21,7 +36,12 @@ export default async function Login() {
-
+
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx
index 808759af1..346baff75 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx
@@ -49,7 +49,6 @@ export const BoardProvider = ({
useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx
index bf68bb902..e3c87aa05 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx
@@ -33,6 +33,7 @@ export const generateColors = (hex: string) => {
return rgbaColors.map((color) => {
return (
"#" +
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color
.split("(")[1]!
.replaceAll(" ", "")
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx
deleted file mode 100644
index 5739e98b6..000000000
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Group, Stack, Tabs } from "@mantine/core";
-import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
-
-import type { RouterOutputs } from "@homarr/api";
-import { clientApi } from "@homarr/api/client";
-import { useScopedI18n } from "@homarr/translation/client";
-import type { TablerIcon } from "@homarr/ui";
-import { CountBadge } from "@homarr/ui";
-
-import type { Board } from "../../_types";
-import { GroupsForm } from "./_access/group-access";
-import { InheritTable } from "./_access/inherit-access";
-import { UsersForm } from "./_access/user-access";
-
-interface Props {
- board: Board;
- initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
-}
-
-export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
- const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
- {
- id: board.id,
- },
- {
- initialData: initialPermissions,
- refetchOnMount: false,
- refetchOnWindowFocus: false,
- refetchOnReconnect: false,
- },
- );
-
- const [counts, setCounts] = useState({
- user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
- group: initialPermissions.groupPermissions.length,
- });
-
- return (
-
-
-
-
-
-
-
-
-
-
- setCounts(({ user, ...others }) => ({
- user: callback(user),
- ...others,
- }))
- }
- />
-
-
-
-
- setCounts(({ group, ...others }) => ({
- group: callback(group),
- ...others,
- }))
- }
- />
-
-
-
-
-
-
-
- );
-};
-
-interface TabItemProps {
- value: "user" | "group" | "inherited";
- count: number;
- icon: TablerIcon;
-}
-
-const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
- const t = useScopedI18n("board.setting.section.access.permission");
-
- return (
- }>
-
- {t(`tab.${value}`)}
-
-
-
- );
-};
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts
deleted file mode 100644
index 08520b9d5..000000000
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { BoardPermission } from "@homarr/definitions";
-import { createFormContext } from "@homarr/form";
-
-export interface BoardAccessFormType {
- items: {
- itemId: string;
- permission: BoardPermission;
- }[];
-}
-
-export const [FormProvider, useFormContext, useForm] = createFormContext();
-
-export type OnCountChange = (callback: (prev: number) => number) => void;
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx
deleted file mode 100644
index d887ee696..000000000
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
-
-import type { RouterOutputs } from "@homarr/api";
-import { getPermissionsWithChildren } from "@homarr/definitions";
-import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
-import { useScopedI18n } from "@homarr/translation/client";
-
-import { BoardAccessDisplayRow } from "./board-access-table-rows";
-import { GroupItemContent } from "./group-access";
-
-export interface InheritTableProps {
- initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
-}
-
-const mapPermissions = {
- "board-full-access": "board-full",
- "board-modify-all": "board-change",
- "board-view-all": "board-view",
-} satisfies Partial>;
-
-export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
- const tPermissions = useScopedI18n("board.setting.section.access.permission");
- return (
-
-
-
-
- {tPermissions("field.user.label")}
- {tPermissions("field.permission.label")}
-
-
-
- {initialPermissions.inherited.map(({ group, permission }) => {
- const boardPermission =
- permission in mapPermissions
- ? mapPermissions[permission as keyof typeof mapPermissions]
- : getPermissionsWithChildren([permission]).includes("board-full-access")
- ? "board-full"
- : null;
-
- if (!boardPermission) {
- return null;
- }
-
- return (
- }
- permission={boardPermission}
- />
- );
- })}
-
-
-
- );
-};
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx
deleted file mode 100644
index 8ed7e98ee..000000000
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import { useCallback, useState } from "react";
-import Link from "next/link";
-import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
-import { IconPlus } from "@tabler/icons-react";
-
-import type { RouterOutputs } from "@homarr/api";
-import { clientApi } from "@homarr/api/client";
-import { useModalAction } from "@homarr/modals";
-import { useI18n, useScopedI18n } from "@homarr/translation/client";
-import { UserAvatar } from "@homarr/ui";
-
-import type { Board } from "../../../_types";
-import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
-import type { BoardAccessFormType, OnCountChange } from "./form";
-import { FormProvider, useForm } from "./form";
-import { UserSelectModal } from "./user-select-modal";
-
-export interface FormProps {
- board: Pick;
- initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
- onCountChange: OnCountChange;
-}
-
-export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
- const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation();
- const utils = clientApi.useUtils();
- const [users, setUsers] = useState