Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b04e620e |
No files matched your search
+1
-2
@@ -5,5 +5,4 @@ npm-debug.log
|
|||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
dev
|
dev
|
||||||
.build
|
|
||||||
@@ -20,6 +20,8 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
# DB_PASSWORD='password'
|
# DB_PASSWORD='password'
|
||||||
# DB_NAME='name-of-database'
|
# DB_NAME='name-of-database'
|
||||||
|
|
||||||
|
# @see https://next-auth.js.org/configuration/options#nextauth_url
|
||||||
|
AUTH_URL='http://localhost:3000'
|
||||||
|
|
||||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||||
# @see https://next-auth.js.org/configuration/options#secret
|
# @see https://next-auth.js.org/configuration/options#secret
|
||||||
|
|||||||
+29
-17
@@ -1,19 +1,31 @@
|
|||||||
{
|
{
|
||||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
extends: ["config:recommended"],
|
"extends": [
|
||||||
packageRules: [
|
"config:recommended"
|
||||||
{
|
|
||||||
matchPackagePatterns: ["^@homarr/"],
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
|
|
||||||
automerge: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
updateInternalDeps: true,
|
"packageRules": [
|
||||||
rangeStrategy: "bump",
|
{
|
||||||
automerge: false,
|
"matchPackagePatterns": [
|
||||||
baseBranches: ["dev"],
|
"^@homarr/",
|
||||||
dependencyDashboard: false,
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch",
|
||||||
|
"pin",
|
||||||
|
"digest"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updateInternalDeps": true,
|
||||||
|
"rangeStrategy": "bump",
|
||||||
|
"automerge": false,
|
||||||
|
"baseBranches": [
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
|
"dependencyDashboard": false
|
||||||
|
}
|
||||||
@@ -1,30 +1,18 @@
|
|||||||
name: "[Deployment] Release"
|
name: "[Deployment] Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch: {}
|
||||||
inputs:
|
|
||||||
send-notifications:
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
description: Send notifications
|
|
||||||
push-image:
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
description: Push Docker Image
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
TURBO_TELEMETRY_DISABLED: 1
|
||||||
|
|
||||||
concurrency: production
|
concurrency: production
|
||||||
@@ -38,21 +26,19 @@ jobs:
|
|||||||
node-version: [20]
|
node-version: [20]
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications != false }}
|
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
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
|
- uses: actions/checkout@v4
|
||||||
- name: Get Next Version
|
- name: Get Next Version
|
||||||
id: semver
|
id: semver
|
||||||
uses: ietf-tools/semver-action@v1
|
uses: ietf-tools/semver-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: dev
|
branch: master
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications != false }}
|
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
@@ -65,19 +51,17 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: "pnpm"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
- name: Build artifacts
|
||||||
|
run: pnpm build
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications != false }}
|
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Built application artifacts. Building images..."
|
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
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -88,48 +72,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=alpha
|
type=raw,value=latest
|
||||||
type=raw,value=early-adopters
|
type=raw,value=${{ steps.semver.outputs.next }}
|
||||||
# tags: |
|
|
||||||
# type=raw,value=latest
|
|
||||||
# type=raw,value=${{ steps.semver.outputs.next }}
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: buildPushAction
|
id: buildPushAction
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
|
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
|
||||||
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: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
network: host
|
network: host
|
||||||
env:
|
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications != false && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
|
||||||
- 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."
|
|
||||||
@@ -4,12 +4,6 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
send-notifications:
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
description: Send notifications
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -20,7 +14,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications }}
|
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
@@ -37,39 +30,27 @@ jobs:
|
|||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: dev
|
branch: dev
|
||||||
- name: Create pull request
|
- 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"
|
uses: devops-infra/action-pull-request@v0.5.5
|
||||||
env:
|
id: create-pull-request
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
title: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||||
|
new_string: "**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 }}``"
|
||||||
|
source_branch: dev
|
||||||
|
target_branch: main
|
||||||
|
label: automerge
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications }}
|
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
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
|
- id: automerge
|
||||||
if: ${{ steps.semver.outputs.bump != 'major' }}
|
if: ${{ steps.semver.outputs.bump != 'major' }}
|
||||||
name: automerge
|
name: automerge
|
||||||
uses: "pascalgn/automerge-action@v0.16.4"
|
uses: "pascalgn/automerge-action@v0.16.3"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
||||||
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||||
MERGE_DELETE_BRANCH: false # never set to true!
|
MERGE_DELETE_BRANCH: false # never set to true!
|
||||||
@@ -79,21 +60,21 @@ jobs:
|
|||||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||||
|
|
||||||
- name: Merged Discord notification
|
- name: Merged Discord notification
|
||||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
|
if: ${{ steps.automerge.outputs.mergeResult == 'merged' }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
- name: Major Bump Discord notification
|
- name: Major Bump Discord notification
|
||||||
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
|
if: ${{ steps.semver.outputs.bump == 'major' }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
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 }}"
|
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
|
- name: Discord Fail Notification
|
||||||
if: failure() && github.events.inputs.send-notifications
|
if: failure()
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
approve-renovate-prs:
|
approve-renovate-prs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -22,4 +21,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
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"
|
||||||
|
done
|
||||||
+2
-8
@@ -48,16 +48,10 @@ yarn-error.log*
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
# database
|
# database
|
||||||
*.sqlite
|
db.sqlite
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
apps/tasks/tasks.cjs
|
apps/tasks/tasks.cjs
|
||||||
apps/websocket/wssServer.cjs
|
apps/websocket/wssServer.cjs
|
||||||
apps/nextjs/.million/
|
|
||||||
packages/cli/cli.cjs
|
|
||||||
|
|
||||||
|
|
||||||
#personal backgrounds
|
|
||||||
apps/nextjs/public/images/background.png
|
|
||||||
Vendored
+1
-2
@@ -2,7 +2,6 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"yoavbls.pretty-ts-errors",
|
"yoavbls.pretty-ts-errors"
|
||||||
"million.million-lint"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Vendored
+2
-11
@@ -9,22 +9,13 @@
|
|||||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||||
"prettier.configPath": "./tooling/prettier/index.mjs",
|
"prettier.configPath": "./tooling/prettier/index.mjs",
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"ajnart",
|
|
||||||
"cqmin",
|
"cqmin",
|
||||||
"gridstack",
|
|
||||||
"homarr",
|
"homarr",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
"mantine",
|
|
||||||
"manuel-rw",
|
|
||||||
"Meierschlumpf",
|
|
||||||
"overseerr",
|
|
||||||
"Sabnzbd",
|
|
||||||
"SeDemal",
|
|
||||||
"Sonarr",
|
|
||||||
"superjson",
|
"superjson",
|
||||||
"tabler",
|
|
||||||
"trpc",
|
"trpc",
|
||||||
"Umami"
|
"Umami",
|
||||||
|
"Sonarr"
|
||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||||
|
|||||||
+16
-42
@@ -1,9 +1,8 @@
|
|||||||
FROM node:20.18.0-alpine AS base
|
FROM node:20.15.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -12,7 +11,6 @@ RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
|
|||||||
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
|
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
|
||||||
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
|
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
|
||||||
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
|
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
|
||||||
RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
|
|
||||||
|
|
||||||
# Add lockfile and package.json's of isolated subworkspace
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
@@ -24,19 +22,20 @@ WORKDIR /app
|
|||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
|
|
||||||
COPY --from=builder /app/tasks-out/json/ .
|
COPY --from=builder /app/tasks-out/json/ .
|
||||||
|
COPY --from=builder /app/tasks-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
RUN corepack enable pnpm && pnpm install
|
||||||
|
|
||||||
COPY --from=builder /app/websocket-out/json/ .
|
COPY --from=builder /app/websocket-out/json/ .
|
||||||
|
COPY --from=builder /app/websocket-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
RUN corepack enable pnpm && pnpm install
|
||||||
|
|
||||||
COPY --from=builder /app/migration-out/json/ .
|
COPY --from=builder /app/migration-out/json/ .
|
||||||
COPY --from=builder /app/cli-out/json/ .
|
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
RUN corepack enable pnpm && pnpm install
|
||||||
|
|
||||||
COPY --from=builder /app/next-out/json/ .
|
COPY --from=builder /app/next-out/json/ .
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
|
||||||
# Is used for postinstall of docs definitions
|
|
||||||
COPY --from=builder /app/packages/definitions/src/docs ./packages/definitions/src/docs
|
|
||||||
|
|
||||||
# Uses the lockfile to install the dependencies
|
|
||||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
|
||||||
|
|
||||||
# Install sharp for image optimization
|
|
||||||
RUN corepack enable pnpm && pnpm install sharp -w
|
RUN corepack enable pnpm && pnpm install sharp -w
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
@@ -44,45 +43,24 @@ COPY --from=builder /app/tasks-out/full/ .
|
|||||||
COPY --from=builder /app/websocket-out/full/ .
|
COPY --from=builder /app/websocket-out/full/ .
|
||||||
COPY --from=builder /app/next-out/full/ .
|
COPY --from=builder /app/next-out/full/ .
|
||||||
COPY --from=builder /app/migration-out/full/ .
|
COPY --from=builder /app/migration-out/full/ .
|
||||||
COPY --from=builder /app/cli-out/full/ .
|
|
||||||
|
|
||||||
# Copy static data as it is not part of the build
|
# Copy static data as it is not part of the build
|
||||||
COPY static-data ./static-data
|
COPY static-data ./static-data
|
||||||
ARG SKIP_ENV_VALIDATION='true'
|
ARG SKIP_ENV_VALIDATION=true
|
||||||
ARG CI='true'
|
RUN corepack enable pnpm && pnpm turbo run build
|
||||||
ARG DISABLE_REDIS_LOGS='true'
|
|
||||||
RUN corepack enable pnpm && pnpm build
|
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# gettext is required for envsubst
|
RUN apk add --no-cache redis
|
||||||
RUN apk add --no-cache redis nginx bash gettext
|
|
||||||
RUN mkdir /appdata
|
RUN mkdir /appdata
|
||||||
RUN mkdir /appdata/db
|
RUN mkdir /appdata/db
|
||||||
RUN mkdir /appdata/redis
|
RUN mkdir /appdata/redis
|
||||||
VOLUME /appdata
|
VOLUME /appdata
|
||||||
RUN mkdir /secrets
|
|
||||||
VOLUME /secrets
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
# Enable homarr cli
|
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
|
|
||||||
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
|
|
||||||
RUN chmod +x /usr/bin/homarr
|
|
||||||
|
|
||||||
# Don't run production as root
|
# Don't run production as root
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
RUN chown -R nextjs:nodejs /appdata
|
RUN chown -R nextjs:nodejs /appdata
|
||||||
RUN chown -R nextjs:nodejs /secrets
|
|
||||||
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
|
|
||||||
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
|
|
||||||
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
|
|
||||||
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
|
|
||||||
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
||||||
@@ -100,14 +78,10 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
|
|||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
||||||
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||||
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
|
|
||||||
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
||||||
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
|
|
||||||
|
|
||||||
|
|
||||||
ENV DB_URL='/appdata/db/db.sqlite'
|
ENV DB_URL='/appdata/db/db.sqlite'
|
||||||
ENV DB_DIALECT='sqlite'
|
ENV DB_DIALECT='sqlite'
|
||||||
ENV DB_DRIVER='better-sqlite3'
|
ENV DB_DRIVER='better-sqlite3'
|
||||||
ENV AUTH_PROVIDERS='credentials'
|
|
||||||
|
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
import "@homarr/auth/env.mjs";
|
import "@homarr/auth/env.mjs";
|
||||||
|
|
||||||
import MillionLint from "@million/lint";
|
|
||||||
|
|
||||||
import "./src/env.mjs";
|
import "./src/env.mjs";
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
@@ -12,14 +9,11 @@ const config = {
|
|||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
webpack: (config, { isServer }) => {
|
webpack: (config) => {
|
||||||
if (isServer) {
|
config.module.rules.push({
|
||||||
config.module.rules.push({
|
test: /\.node$/,
|
||||||
test: /\.node$/,
|
loader: "node-loader",
|
||||||
loader: "node-loader",
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -31,7 +25,4 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
|
||||||
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
+32
-38
@@ -7,13 +7,12 @@
|
|||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --"
|
"with-env": "dotenv -e ../../.env --"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
@@ -23,31 +22,27 @@
|
|||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/gridstack": "^1.0.3",
|
"@homarr/gridstack": "^1.0.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
|
||||||
"@homarr/notifications": "workspace:^0.1.0",
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
"@homarr/old-schema": "workspace:^0.1.0",
|
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
|
||||||
"@homarr/spotlight": "workspace:^0.1.0",
|
"@homarr/spotlight": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.13.4",
|
"@mantine/colors-generator": "^7.11.1",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.11.1",
|
||||||
"@mantine/hooks": "^7.13.4",
|
"@mantine/hooks": "^7.11.1",
|
||||||
"@mantine/modals": "^7.13.4",
|
"@mantine/modals": "^7.11.1",
|
||||||
"@mantine/tiptap": "^7.13.4",
|
"@mantine/tiptap": "^7.11.1",
|
||||||
"@million/lint": "1.0.11",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.10.1",
|
||||||
"@tabler/icons-react": "^3.20.0",
|
"@tanstack/react-query": "^5.50.1",
|
||||||
"@tanstack/react-query": "^5.59.16",
|
"@tanstack/react-query-devtools": "^5.50.1",
|
||||||
"@tanstack/react-query-devtools": "^5.59.16",
|
"@tanstack/react-query-next-experimental": "5.50.1",
|
||||||
"@tanstack/react-query-next-experimental": "5.59.16",
|
"@tabler/icons-react": "^3.9.0",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -55,24 +50,23 @@
|
|||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"chroma-js": "^3.1.2",
|
"chroma-js": "^2.4.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.11",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"glob": "^11.0.0",
|
"glob": "^10.4.3",
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.8.4",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"mantine-react-table": "2.0.0-beta.5",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.4",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.15.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.0.13",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.80.4",
|
"sass": "^1.77.6",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"swagger-ui-react": "^5.17.14",
|
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -80,15 +74,15 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.17.1",
|
"@types/node": "^20.14.10",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"concurrently": "^8.2.2",
|
||||||
"concurrently": "^9.0.1",
|
"eslint": "^9.6.0",
|
||||||
"eslint": "^9.13.0",
|
|
||||||
"node-loader": "^2.0.0",
|
"node-loader": "^2.0.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.5.3"
|
||||||
}
|
},
|
||||||
|
"prettier": "@homarr/prettier-config"
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,74 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
|
||||||
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
|
||||||
|
|
||||||
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
|
||||||
const manager = useColorSchemeManager();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DirectionProvider>
|
|
||||||
<MantineProvider
|
|
||||||
defaultColorScheme="dark"
|
|
||||||
colorSchemeManager={manager}
|
|
||||||
theme={createTheme({
|
|
||||||
primaryColor: "red",
|
|
||||||
autoContrast: true,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MantineProvider>
|
|
||||||
</DirectionProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function useColorSchemeManager(): MantineColorSchemeManager {
|
|
||||||
const key = "homarr-color-scheme";
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
|
||||||
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
updateCookieValue(variables.colorScheme);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
get: (defaultValue) => {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cookies = parseCookies(document.cookie);
|
|
||||||
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
|
|
||||||
} catch {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
set: (value) => {
|
|
||||||
if (value === "auto") return;
|
|
||||||
try {
|
|
||||||
if (session) {
|
|
||||||
mutateColorScheme({ colorScheme: value });
|
|
||||||
}
|
|
||||||
updateCookieValue(value);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[@mantine/core] Color scheme manager was unable to save color scheme.", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subscribe: () => undefined,
|
|
||||||
unsubscribe: () => undefined,
|
|
||||||
clear: () => undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useEffect } from "react";
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { SessionProvider, signIn } from "@homarr/auth/client";
|
import { SessionProvider } from "@homarr/auth/client";
|
||||||
|
|
||||||
interface AuthProviderProps extends AuthContextProps {
|
interface AuthProviderProps {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => {
|
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
|
||||||
useLoginRedirectOnSessionExpiry(session);
|
return <SessionProvider session={session}>{children}</SessionProvider>;
|
||||||
|
|
||||||
return (
|
|
||||||
<SessionProvider session={session}>
|
|
||||||
<AuthContext.Provider value={{ logoutUrl }}>{children}</AuthContext.Provider>
|
|
||||||
</SessionProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AuthContextProps {
|
|
||||||
logoutUrl: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextProps | null>(null);
|
|
||||||
|
|
||||||
export const useAuthContext = () => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
|
|
||||||
if (!context) throw new Error("useAuthContext must be used within an AuthProvider");
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useLoginRedirectOnSessionExpiry = (session: Session | null) => {
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
if (!session) return () => {};
|
|
||||||
//setTimeout doesn't allow for a number higher than 2147483647 (2³¹-1 , or roughly 24 days)
|
|
||||||
const timeout = setTimeout(() => void signIn(), Math.min(dayjs(session.expires).diff(), 2147483647));
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [session]);
|
|
||||||
};
|
};
|
||||||
@@ -5,37 +5,14 @@ import { useState } from "react";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
import {
|
import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
|
||||||
createWSClient,
|
|
||||||
httpLink,
|
|
||||||
isNonJsonSerializable,
|
|
||||||
loggerLink,
|
|
||||||
splitLink,
|
|
||||||
unstable_httpBatchStreamLink,
|
|
||||||
wsLink,
|
|
||||||
} from "@trpc/client";
|
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import { env } from "~/env.mjs";
|
|
||||||
|
|
||||||
const constructWebsocketUrl = () => {
|
|
||||||
const fallback = "ws://localhost:3001/websockets";
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.NODE_ENV === "development") {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `ws://${window.location.hostname}:${window.location.port}/websockets`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wsClient = createWSClient({
|
const wsClient = createWSClient({
|
||||||
url: constructWebsocketUrl(),
|
url: "ws://localhost:3001",
|
||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
@@ -57,29 +34,18 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
enabled: (opts) =>
|
enabled: (opts) =>
|
||||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
}),
|
}),
|
||||||
splitLink({
|
(args) => {
|
||||||
condition: ({ type }) => type === "subscription",
|
return ({ op, next }) => {
|
||||||
true: wsLink<AppRouter>({
|
console.log("op", op.type, op.input, op.path, op.id);
|
||||||
client: wsClient,
|
if (op.type === "subscription") {
|
||||||
transformer: superjson,
|
const link = wsLink<AppRouter>({
|
||||||
}),
|
client: wsClient,
|
||||||
false: splitLink({
|
transformer: superjson,
|
||||||
condition: ({ input }) => isNonJsonSerializable(input),
|
});
|
||||||
true: httpLink({
|
return link(args)({ op, next });
|
||||||
/**
|
}
|
||||||
* We don't want to transform the data here as we want to use form data
|
|
||||||
*/
|
return unstable_httpBatchStreamLink({
|
||||||
transformer: {
|
|
||||||
serialize(object: unknown) {
|
|
||||||
return object;
|
|
||||||
},
|
|
||||||
deserialize(data: unknown) {
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
url: `${getBaseUrl()}/api/trpc`,
|
|
||||||
}),
|
|
||||||
false: unstable_httpBatchStreamLink({
|
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: `${getBaseUrl()}/api/trpc`,
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
headers() {
|
headers() {
|
||||||
@@ -87,9 +53,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
headers.set("x-trpc-source", "nextjs-react");
|
headers.set("x-trpc-source", "nextjs-react");
|
||||||
return headers;
|
return headers;
|
||||||
},
|
},
|
||||||
}),
|
})(args)({ op, next });
|
||||||
}),
|
};
|
||||||
}),
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput } from "@homarr/ui";
|
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -65,8 +64,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
|
||||||
<CustomPasswordInput
|
<PasswordInput
|
||||||
withPasswordRequirements
|
|
||||||
label={t("field.password.label")}
|
label={t("field.password.label")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { notFound } from "next/navigation";
|
|||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
|
||||||
import { and, db, eq } from "@homarr/db";
|
import { and, db, eq } from "@homarr/db";
|
||||||
import { invites } from "@homarr/db/schema/sqlite";
|
import { invites } from "@homarr/db/schema/sqlite";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
@@ -20,8 +19,6 @@ interface InviteUsagePageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
||||||
if (!isProviderEnabled("credentials")) notFound();
|
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session) notFound();
|
if (session) notFound();
|
||||||
|
|
||||||
|
|||||||
@@ -1,185 +1,78 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import { useState } from "react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import type { useForm } from "@homarr/form";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { validation, z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
interface LoginFormProps {
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
providers: string[];
|
|
||||||
oidcClientName: string;
|
|
||||||
isOidcAutoLoginEnabled: boolean;
|
|
||||||
callbackUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extendedValidation = validation.user.signIn.extend({ provider: z.enum(["credentials", "ldap"]) });
|
export const LoginForm = () => {
|
||||||
|
|
||||||
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
|
|
||||||
const t = useScopedI18n("user");
|
const t = useScopedI18n("user");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const form = useZodForm(extendedValidation, {
|
const [error, setError] = useState<string>();
|
||||||
|
const form = useZodForm(validation.user.signIn, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
provider: "credentials",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
|
const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
await signIn("credentials", {
|
||||||
|
...values,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: "/",
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response?.ok || response.error) {
|
||||||
|
throw response?.error;
|
||||||
|
}
|
||||||
|
|
||||||
const onSuccess = useCallback(
|
showSuccessNotification({
|
||||||
async (response: Awaited<ReturnType<typeof signIn>>) => {
|
title: t("action.login.notification.success.title"),
|
||||||
if (response && (!response.ok || response.error)) {
|
message: t("action.login.notification.success.message"),
|
||||||
// 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("/");
|
await revalidatePathActionAsync("/");
|
||||||
router.push(callbackUrl);
|
router.push("/");
|
||||||
}
|
|
||||||
},
|
|
||||||
[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<typeof signIn>[1]) => {
|
|
||||||
setIsPending(true);
|
|
||||||
await signIn(provider, {
|
|
||||||
...options,
|
|
||||||
redirect: false,
|
|
||||||
callbackUrl: new URL(callbackUrl, window.location.href).href,
|
|
||||||
})
|
})
|
||||||
.then(onSuccess)
|
.catch((error: Error | string) => {
|
||||||
.catch(onError);
|
setIsLoading(false);
|
||||||
},
|
setError(error.toString());
|
||||||
[setIsPending, onSuccess, onError, callbackUrl],
|
showErrorNotification({
|
||||||
);
|
title: t("action.login.notification.error.title"),
|
||||||
|
message: t("action.login.notification.error.message"),
|
||||||
const isLoginInProgress = useRef(false);
|
});
|
||||||
|
});
|
||||||
useEffect(() => {
|
};
|
||||||
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
|
|
||||||
isLoginInProgress.current = true;
|
|
||||||
void signInAsync("oidc");
|
|
||||||
}
|
|
||||||
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<Stack gap="lg">
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||||
{credentialInputsVisible && (
|
<Stack gap="lg">
|
||||||
<>
|
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
|
||||||
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
|
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
||||||
<Stack gap="lg">
|
<Button type="submit" fullWidth loading={isLoading}>
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
|
{t("action.login.label")}
|
||||||
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
|
||||||
|
|
||||||
{providers.includes("credentials") && (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
|
||||||
{t("action.login.label")}
|
|
||||||
</SubmitButton>
|
|
||||||
<PasswordForgottenCollapse username={form.values.name} />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{providers.includes("ldap") && (
|
|
||||||
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
|
||||||
{t("action.login.labelWith", { provider: "LDAP" })}
|
|
||||||
</SubmitButton>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
{providers.includes("oidc") && <Divider label="OIDC" labelPosition="center" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{providers.includes("oidc") && (
|
|
||||||
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
|
|
||||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</Stack>
|
||||||
</Stack>
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SubmitButtonProps {
|
|
||||||
isPending: boolean;
|
|
||||||
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
|
|
||||||
provider: "credentials" | "ldap";
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
|
||||||
const isCurrentProviderActive = form.getValues().provider === provider;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
name={provider}
|
|
||||||
fullWidth
|
|
||||||
onClick={() => form.setFieldValue("provider", provider)}
|
|
||||||
loading={isPending && isCurrentProviderActive}
|
|
||||||
disabled={isPending && !isCurrentProviderActive}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PasswordForgottenCollapseProps {
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
|
|
||||||
const [visible, { toggle }] = useDisclosure(false);
|
|
||||||
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
|
|
||||||
|
|
||||||
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Anchor type="button" component="button" onClick={toggle}>
|
|
||||||
{tForgotPassword("label")}
|
|
||||||
</Anchor>
|
|
||||||
|
|
||||||
<Collapse in={visible}>
|
|
||||||
<Card>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm">{tForgotPassword("description")}</Text>
|
|
||||||
|
|
||||||
<Code>homarr reset-password -u {commandUsername}</Code>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Collapse>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof extendedValidation>;
|
|
||||||
@@ -1,26 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
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 { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
interface LoginProps {
|
export default async function Login() {
|
||||||
searchParams: {
|
|
||||||
callbackUrl?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Login({ searchParams }: LoginProps) {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
redirect(searchParams.callbackUrl ?? "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("user.page.login");
|
const t = await getScopedI18n("user.page.login");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,12 +21,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||||
<LoginForm
|
<LoginForm />
|
||||||
providers={env.AUTH_PROVIDERS}
|
|
||||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
|
||||||
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
|
|
||||||
callbackUrl={searchParams.callbackUrl ?? "/"}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { DDGridStack } from "@homarr/gridstack";
|
||||||
|
|
||||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
@@ -40,15 +41,47 @@ export const useUpdateBoard = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dd = new DDGridStack();
|
||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const isReady = useIsBoardReady();
|
const isReady = useIsBoardReady();
|
||||||
|
const { updateBoard } = useUpdateBoard();
|
||||||
|
|
||||||
const fullWidthSortedSections = board.sections
|
const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
|
||||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!boxRef.current) return;
|
||||||
|
dd.droppable(boxRef.current, {
|
||||||
|
accept: () => true,
|
||||||
|
})
|
||||||
|
.on(boxRef.current, "dropover", (event, el, helper) => {
|
||||||
|
console.log("dropover", event, el, helper);
|
||||||
|
})
|
||||||
|
.on(boxRef.current, "dropout", (event, el, helper) => {
|
||||||
|
console.log("dropout", event, el, helper);
|
||||||
|
})
|
||||||
|
.on(boxRef.current, "drop", (event, el, helper) => {
|
||||||
|
console.log("drop", event, el, helper);
|
||||||
|
const itemId = el.getAttribute("data-id");
|
||||||
|
if (!itemId) return;
|
||||||
|
|
||||||
|
// Same as removeItem
|
||||||
|
updateBoard((previous) => {
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
// Filter removed item out of items array
|
||||||
|
sections: previous.sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter((item) => item.id !== itemId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
@@ -60,11 +93,12 @@ export const ClientBoard = () => {
|
|||||||
h={fullHeightWithoutHeaderAndFooter}
|
h={fullHeightWithoutHeaderAndFooter}
|
||||||
/>
|
/>
|
||||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||||
{fullWidthSortedSections.map((section) =>
|
<Box ref={boxRef} w="100%" h={80} bg="red"></Box>
|
||||||
|
{sortedSections.map((section) =>
|
||||||
section.kind === "empty" ? (
|
section.kind === "empty" ? (
|
||||||
<BoardEmptySection key={section.id} section={section} />
|
<BoardEmptySection key={section.id} section={section} mainRef={ref} />
|
||||||
) : (
|
) : (
|
||||||
<BoardCategorySection key={section.id} section={section} />
|
<BoardCategorySection key={section.id} section={section} mainRef={ref} />
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const BoardProvider = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [data.sections.length, setReadySections]);
|
}, [data.sections.length, setReadySections]);
|
||||||
|
|
||||||
const markAsReady = useCallback((id: string) => {
|
const markAsReady = useCallback((id: string) => {
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import { TRPCError } from "@trpc/server";
|
|||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
@@ -28,17 +25,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
|
isBoardContentPage: true,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
page: () => {
|
||||||
page: async () => {
|
return <ClientBoard />;
|
||||||
const session = await auth();
|
|
||||||
const integrations = await getIntegrationsWithPermissionsAsync(session);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IntegrationProvider integrations={integrations}>
|
|
||||||
<ClientBoard />
|
|
||||||
</IntegrationProvider>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
@@ -49,10 +39,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||||
icons: {
|
icons: {
|
||||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
|
||||||
},
|
|
||||||
appleWebApp: {
|
|
||||||
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,29 +2,27 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Group, Menu } from "@mantine/core";
|
import { Group, Menu } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
IconBoxAlignTop,
|
IconBoxAlignTop,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
|
IconPackageImport,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconResize,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { useEditMode, useRequiredBoard } from "./_context";
|
import { useEditMode, useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
@@ -54,7 +52,6 @@ const AddMenu = () => {
|
|||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
const { addDynamicSection } = useDynamicSectionActions();
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const handleAddCategory = useCallback(
|
const handleAddCategory = useCallback(
|
||||||
@@ -95,16 +92,13 @@ const AddMenu = () => {
|
|||||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
||||||
{t("item.action.create")}
|
{t("item.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
{t("section.category.action.create")}
|
{t("section.category.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
|
||||||
{t("section.dynamic.action.create")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
@@ -138,8 +132,6 @@ const EditModeMenu = () => {
|
|||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||||
|
|
||||||
useHotkeys([["mod+e", toggle]]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export default createBoardLayout<{ locale: string; name: string }>({
|
|||||||
async getInitialBoardAsync({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
|
isBoardContentPage: false,
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"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 (
|
||||||
|
<Stack>
|
||||||
|
<Tabs color="red" defaultValue="user">
|
||||||
|
<Tabs.List grow>
|
||||||
|
<TabItem value="user" count={counts.user} icon={IconUser} />
|
||||||
|
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
|
||||||
|
<TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} />
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="user">
|
||||||
|
<UsersForm
|
||||||
|
board={board}
|
||||||
|
initialPermissions={permissions}
|
||||||
|
onCountChange={(callback) =>
|
||||||
|
setCounts(({ user, ...others }) => ({
|
||||||
|
user: callback(user),
|
||||||
|
...others,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="group">
|
||||||
|
<GroupsForm
|
||||||
|
board={board}
|
||||||
|
initialPermissions={permissions}
|
||||||
|
onCountChange={(callback) =>
|
||||||
|
setCounts(({ group, ...others }) => ({
|
||||||
|
group: callback(group),
|
||||||
|
...others,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="inherited">
|
||||||
|
<InheritTable initialPermissions={permissions} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
|
||||||
|
<Group gap="sm">
|
||||||
|
{t(`tab.${value}`)}
|
||||||
|
<CountBadge count={count} />
|
||||||
|
</Group>
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
};
|
||||||
+50
-42
@@ -1,36 +1,43 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import type { SelectProps } from "@mantine/core";
|
import type { SelectProps } from "@mantine/core";
|
||||||
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
|
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
|
||||||
import { Icon123, IconCheck } from "@tabler/icons-react";
|
import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import type { BoardPermission } from "@homarr/definitions";
|
||||||
|
import { boardPermissions } from "@homarr/definitions";
|
||||||
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { useAccessContext } from "./context";
|
import type { OnCountChange } from "./form";
|
||||||
import type { HandleCountChange } from "./form";
|
|
||||||
import { useFormContext } from "./form";
|
import { useFormContext } from "./form";
|
||||||
|
|
||||||
interface AccessSelectRowProps {
|
const icons = {
|
||||||
|
"board-change": IconPencil,
|
||||||
|
"board-view": IconEye,
|
||||||
|
"board-full": IconSettings,
|
||||||
|
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
|
||||||
|
|
||||||
|
interface BoardAccessSelectRowProps {
|
||||||
itemContent: ReactNode;
|
itemContent: ReactNode;
|
||||||
permission: string;
|
permission: BoardPermission;
|
||||||
index: number;
|
index: number;
|
||||||
handleCountChange: HandleCountChange;
|
onCountChange: OnCountChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AccessSelectRow = ({ itemContent, permission, index, handleCountChange }: AccessSelectRowProps) => {
|
export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => {
|
||||||
const tRoot = useI18n();
|
const tRoot = useI18n();
|
||||||
const { icons, getSelectData } = useAccessContext();
|
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
|
const Icon = icons[permission];
|
||||||
|
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
form.setFieldValue(
|
form.setFieldValue(
|
||||||
"items",
|
"items",
|
||||||
form.values.items.filter((_, i) => i !== index),
|
form.values.items.filter((_, i) => i !== index),
|
||||||
);
|
);
|
||||||
handleCountChange((prev) => prev - 1);
|
onCountChange((prev) => prev - 1);
|
||||||
}, [form, index, handleCountChange]);
|
}, [form, index, onCountChange]);
|
||||||
|
|
||||||
const Icon = icons[permission] ?? Icon123;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
@@ -43,7 +50,10 @@ export const AccessSelectRow = ({ itemContent, permission, index, handleCountCha
|
|||||||
leftSection={<Icon size="1rem" />}
|
leftSection={<Icon size="1rem" />}
|
||||||
renderOption={RenderOption}
|
renderOption={RenderOption}
|
||||||
variant="unstyled"
|
variant="unstyled"
|
||||||
data={getSelectData()}
|
data={boardPermissions.map((permission) => ({
|
||||||
|
value: permission,
|
||||||
|
label: tPermissions(`item.${permission}.label`),
|
||||||
|
}))}
|
||||||
{...form.getInputProps(`items.${index}.permission`)}
|
{...form.getInputProps(`items.${index}.permission`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -56,6 +66,30 @@ export const AccessSelectRow = ({ itemContent, permission, index, handleCountCha
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface BoardAccessDisplayRowProps {
|
||||||
|
itemContent: ReactNode;
|
||||||
|
permission: BoardPermission | "board-full";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDisplayRowProps) => {
|
||||||
|
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||||
|
const Icon = icons[permission];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Group gap={0}>
|
||||||
|
<Flex w={34} h={34} align="center" justify="center">
|
||||||
|
<Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
|
||||||
|
</Flex>
|
||||||
|
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
stroke: 1.5,
|
stroke: 1.5,
|
||||||
color: "currentColor",
|
color: "currentColor",
|
||||||
@@ -64,9 +98,7 @@ const iconProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||||
const { icons } = useAccessContext();
|
const Icon = icons[option.value as BoardPermission];
|
||||||
|
|
||||||
const Icon = icons[option.value] ?? Icon123;
|
|
||||||
return (
|
return (
|
||||||
<Group flex="1" gap="xs" wrap="nowrap">
|
<Group flex="1" gap="xs" wrap="nowrap">
|
||||||
<Icon {...iconProps} />
|
<Icon {...iconProps} />
|
||||||
@@ -75,27 +107,3 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AccessDisplayRowProps {
|
|
||||||
itemContent: ReactNode;
|
|
||||||
permission: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AccessDisplayRow = ({ itemContent, permission }: AccessDisplayRowProps) => {
|
|
||||||
const { icons, translate } = useAccessContext();
|
|
||||||
const Icon = icons[permission] ?? Icon123;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableTr>
|
|
||||||
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
|
||||||
<TableTd>
|
|
||||||
<Group gap={0}>
|
|
||||||
<Flex w={34} h={34} align="center" justify="center">
|
|
||||||
<Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
|
|
||||||
</Flex>
|
|
||||||
<Text size="sm">{translate(permission)}</Text>
|
|
||||||
</Group>
|
|
||||||
</TableTd>
|
|
||||||
</TableTr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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<BoardAccessFormType>();
|
||||||
|
|
||||||
|
export type OnCountChange = (callback: (prev: number) => number) => void;
|
||||||
+46
-31
@@ -1,60 +1,73 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { AccessQueryData } from "./access-settings";
|
import { BoardAccessSelectRow } from "./board-access-table-rows";
|
||||||
import { AccessSelectRow } from "./access-table-rows";
|
import type { BoardAccessFormType } from "./form";
|
||||||
import { useAccessContext } from "./context";
|
|
||||||
import type { AccessFormType } from "./form";
|
|
||||||
import { FormProvider, useForm } from "./form";
|
import { FormProvider, useForm } from "./form";
|
||||||
import { GroupSelectModal } from "./group-select-modal";
|
import { GroupSelectModal } from "./group-select-modal";
|
||||||
import type { FormProps } from "./user-access-form";
|
import type { FormProps } from "./user-access";
|
||||||
|
|
||||||
export const GroupAccessForm = <TPermission extends string>({
|
export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
|
||||||
accessQueryData,
|
const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||||
handleCountChange,
|
const utils = clientApi.useUtils();
|
||||||
handleSubmit,
|
const [groups, setGroups] = useState<Map<string, Group>>(
|
||||||
isPending,
|
new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])),
|
||||||
}: Omit<FormProps<TPermission>, "entity">) => {
|
|
||||||
const { defaultPermission } = useAccessContext();
|
|
||||||
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
|
|
||||||
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
|
|
||||||
);
|
);
|
||||||
const { openModal } = useModalAction(GroupSelectModal);
|
const { openModal } = useModalAction(GroupSelectModal);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tPermissions = useScopedI18n("permission");
|
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
items: accessQueryData.groups.map(({ group, permission }) => ({
|
items: initialPermissions.groupPermissions.map(({ group, permission }) => ({
|
||||||
principalId: group.id,
|
itemId: group.id,
|
||||||
permission,
|
permission,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAddUser = () => {
|
const handleSubmit = useCallback(
|
||||||
|
(values: BoardAccessFormType) => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
id: board.id,
|
||||||
|
permissions: values.items,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.board.getBoardPermissions.invalidate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[board.id, mutate, utils.board.getBoardPermissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddUser = useCallback(() => {
|
||||||
openModal({
|
openModal({
|
||||||
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
|
presentGroupIds: form.values.items.map(({ itemId: id }) => id),
|
||||||
onSelect: (group) => {
|
onSelect: (group) => {
|
||||||
setGroups((prev) => new Map(prev).set(group.id, group));
|
setGroups((prev) => new Map(prev).set(group.id, group));
|
||||||
form.setFieldValue("items", [
|
form.setFieldValue("items", [
|
||||||
{
|
{
|
||||||
principalId: group.id,
|
itemId: group.id,
|
||||||
permission: defaultPermission,
|
permission: "board-view",
|
||||||
},
|
},
|
||||||
...form.values.items,
|
...form.values.items,
|
||||||
]);
|
]);
|
||||||
handleCountChange((prev) => prev + 1);
|
onCountChange((prev) => prev + 1);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
}, [form, openModal, onCountChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<FormProvider form={form}>
|
<FormProvider form={form}>
|
||||||
<Stack pt="sm">
|
<Stack pt="sm">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -66,13 +79,13 @@ export const GroupAccessForm = <TPermission extends string>({
|
|||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{form.values.items.map((row, index) => (
|
{form.values.items.map((row, index) => (
|
||||||
<AccessSelectRow
|
<BoardAccessSelectRow
|
||||||
key={row.principalId}
|
key={row.itemId}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
itemContent={<GroupItemContent group={groups.get(row.principalId)!} />}
|
itemContent={<GroupItemContent group={groups.get(row.itemId)!} />}
|
||||||
permission={row.permission}
|
permission={row.permission}
|
||||||
index={index}
|
index={index}
|
||||||
handleCountChange={handleCountChange}
|
onCountChange={onCountChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableTbody>
|
</TableTbody>
|
||||||
@@ -83,7 +96,7 @@ export const GroupAccessForm = <TPermission extends string>({
|
|||||||
{t("common.action.add")}
|
{t("common.action.add")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={isPending} color="teal">
|
<Button type="submit" loading={isPending} color="teal">
|
||||||
{t("permission.action.saveGroup")}
|
{t("common.action.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -92,10 +105,12 @@ export const GroupAccessForm = <TPermission extends string>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GroupItemContent = ({ group }: { group: AccessQueryData<string>["groups"][number]["group"] }) => {
|
export const GroupItemContent = ({ group }: { group: Group }) => {
|
||||||
return (
|
return (
|
||||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
|
||||||
+1
-1
@@ -63,5 +63,5 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("permission.groupSelect.title"),
|
defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"),
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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<Record<GroupPermissionKey, BoardPermission | "board-full">>;
|
||||||
|
|
||||||
|
export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
|
||||||
|
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||||
|
return (
|
||||||
|
<Stack pt="sm">
|
||||||
|
<Table>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||||
|
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{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 (
|
||||||
|
<BoardAccessDisplayRow
|
||||||
|
key={group.id}
|
||||||
|
itemContent={<GroupItemContent group={group} />}
|
||||||
|
permission={boardPermission}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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<Board, "id" | "creatorId" | "creator">;
|
||||||
|
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<Map<string, User>>(
|
||||||
|
new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])),
|
||||||
|
);
|
||||||
|
const { openModal } = useModalAction(UserSelectModal);
|
||||||
|
const t = useI18n();
|
||||||
|
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
items: initialPermissions.userPermissions.map(({ user, permission }) => ({
|
||||||
|
itemId: user.id,
|
||||||
|
permission,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: BoardAccessFormType) => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
id: board.id,
|
||||||
|
permissions: values.items,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.board.getBoardPermissions.invalidate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[board.id, mutate, utils.board.getBoardPermissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddUser = useCallback(() => {
|
||||||
|
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds,
|
||||||
|
onSelect: (user) => {
|
||||||
|
setUsers((prev) => new Map(prev).set(user.id, user));
|
||||||
|
form.setFieldValue("items", [
|
||||||
|
{
|
||||||
|
itemId: user.id,
|
||||||
|
permission: "board-view",
|
||||||
|
},
|
||||||
|
...form.values.items,
|
||||||
|
]);
|
||||||
|
onCountChange((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [form, openModal, board.creatorId, onCountChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<FormProvider form={form}>
|
||||||
|
<Stack pt="sm">
|
||||||
|
<Table>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||||
|
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{board.creator && (
|
||||||
|
<BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" />
|
||||||
|
)}
|
||||||
|
{form.values.items.map((row, index) => (
|
||||||
|
<BoardAccessSelectRow
|
||||||
|
key={row.itemId}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
itemContent={<UserItemContent user={users.get(row.itemId)!} />}
|
||||||
|
permission={row.permission}
|
||||||
|
index={index}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
||||||
|
{t("common.action.add")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending} color="teal">
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</FormProvider>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserItemContent = ({ user }: { user: User }) => {
|
||||||
|
return (
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<Box visibleFrom="xs">
|
||||||
|
<UserAvatar user={user} size="sm" />
|
||||||
|
</Box>
|
||||||
|
<Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{user.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
}
|
||||||
+1
-1
@@ -72,7 +72,7 @@ export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps })
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("permission.userSelect.title"),
|
defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { boardPermissions, boardPermissionsMap } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { AccessSettings } from "~/components/access/access-settings";
|
|
||||||
import type { Board } from "../../_types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
board: Board;
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
|
||||||
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
|
|
||||||
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
|
||||||
{
|
|
||||||
id: board.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initialData: initialPermissions,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccessSettings
|
|
||||||
entity={{
|
|
||||||
id: board.id,
|
|
||||||
ownerId: board.creatorId,
|
|
||||||
owner: board.creator,
|
|
||||||
}}
|
|
||||||
query={{
|
|
||||||
invalidate: () => utils.board.getBoardPermissions.invalidate(),
|
|
||||||
data: permissions,
|
|
||||||
}}
|
|
||||||
groupsMutation={{
|
|
||||||
mutate: groupMutation.mutate,
|
|
||||||
isPending: groupMutation.isPending,
|
|
||||||
}}
|
|
||||||
usersMutation={{
|
|
||||||
mutate: userMutation.mutate,
|
|
||||||
isPending: userMutation.isPending,
|
|
||||||
}}
|
|
||||||
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
|
|
||||||
permission={{
|
|
||||||
items: boardPermissions,
|
|
||||||
default: "view",
|
|
||||||
fullAccessGroupPermission: "board-full-all",
|
|
||||||
groupPermissionMapping: boardPermissionsMap,
|
|
||||||
icons: {
|
|
||||||
modify: IconPencil,
|
|
||||||
view: IconEye,
|
|
||||||
full: IconSettings,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -20,8 +20,8 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||||
|
import { AccessSettingsContent } from "./_access";
|
||||||
import { BackgroundSettingsContent } from "./_background";
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
import { BoardAccessSettings } from "./_board-access";
|
|
||||||
import { ColorSettingsContent } from "./_colors";
|
import { ColorSettingsContent } from "./_colors";
|
||||||
import { CustomCssSettingsContent } from "./_customCss";
|
import { CustomCssSettingsContent } from "./_customCss";
|
||||||
import { DangerZoneSettingsContent } from "./_danger";
|
import { DangerZoneSettingsContent } from "./_danger";
|
||||||
@@ -44,8 +44,8 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
|||||||
const permissions = hasFullAccess
|
const permissions = hasFullAccess
|
||||||
? await api.board.getBoardPermissions({ id: board.id })
|
? await api.board.getBoardPermissions({ id: board.id })
|
||||||
: {
|
: {
|
||||||
users: [],
|
userPermissions: [],
|
||||||
groups: [],
|
groupPermissions: [],
|
||||||
inherited: [],
|
inherited: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
|||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<AccordionItemFor value="access" icon={IconUser}>
|
<AccordionItemFor value="access" icon={IconUser}>
|
||||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
<AccessSettingsContent board={board} initialPermissions={permissions} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
<DangerZoneSettingsContent />
|
<DangerZoneSettingsContent />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AppShellMain } from "@mantine/core";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
import { GlobalItemServerDataRunner } from "@homarr/widgets";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
@@ -17,11 +18,13 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
|
isBoardContentPage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
|
isBoardContentPage,
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -39,19 +42,21 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
||||||
<BoardMantineProvider>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
<CustomCss />
|
<BoardMantineProvider>
|
||||||
<ClientShell hasNavigation={false}>
|
<CustomCss />
|
||||||
<MainHeader
|
<ClientShell hasNavigation={false}>
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
<MainHeader
|
||||||
actions={headerActions}
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
hasNavigation={false}
|
actions={headerActions}
|
||||||
/>
|
hasNavigation={false}
|
||||||
<AppShellMain>{children}</AppShellMain>
|
/>
|
||||||
</ClientShell>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</BoardMantineProvider>
|
</ClientShell>
|
||||||
</BoardProvider>
|
</BoardMantineProvider>
|
||||||
|
</BoardProvider>
|
||||||
|
</GlobalItemServerDataRunner>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ export type Item = Section["items"][number];
|
|||||||
|
|
||||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||||
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
|
||||||
|
|
||||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||||
@@ -7,7 +7,6 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput } from "@homarr/ui";
|
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -51,11 +50,7 @@ export const InitUserForm = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||||
<CustomPasswordInput
|
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
||||||
withPasswordRequirements
|
|
||||||
label={t("field.password.label")}
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||||
<Button type="submit" fullWidth loading={isPending}>
|
<Button type="submit" fullWidth loading={isPending}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
import "@homarr/ui/styles.css";
|
||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
import "@homarr/ui/styles.css";
|
|
||||||
import "~/styles/scroll-area.scss";
|
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env.mjs";
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
|
||||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||||
import { AuthProvider } from "./_client-providers/session";
|
import { AuthProvider } from "./_client-providers/session";
|
||||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||||
@@ -28,7 +23,8 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateMetadata = (): Metadata => ({
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL("http://localhost:3000"),
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
@@ -39,17 +35,12 @@ export const generateMetadata = (): Metadata => ({
|
|||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
siteName: "Homarr Documentation",
|
siteName: "Homarr Documentation",
|
||||||
},
|
},
|
||||||
icons: {
|
twitter: {
|
||||||
icon: "/logo/logo.png",
|
card: "summary_large_image",
|
||||||
apple: "/logo/logo.png",
|
site: "@jullerino",
|
||||||
|
creator: "@jullerino",
|
||||||
},
|
},
|
||||||
appleWebApp: {
|
};
|
||||||
title: "Homarr",
|
|
||||||
capable: true,
|
|
||||||
startupImage: { url: "/logo/logo.png" },
|
|
||||||
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
@@ -58,37 +49,35 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||||
const session = await auth();
|
const colorScheme = "dark";
|
||||||
const colorScheme = getColorScheme();
|
|
||||||
const tCommon = await getScopedI18n("common");
|
|
||||||
const direction = tCommon("direction");
|
|
||||||
|
|
||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
(innerProps) => {
|
async (innerProps) => {
|
||||||
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
const session = await auth();
|
||||||
|
return <AuthProvider session={session} {...innerProps} />;
|
||||||
},
|
},
|
||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
||||||
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
(innerProps) => (
|
||||||
|
<MantineProvider
|
||||||
|
{...innerProps}
|
||||||
|
defaultColorScheme="dark"
|
||||||
|
theme={createTheme({
|
||||||
|
primaryColor: "red",
|
||||||
|
autoContrast: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
),
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
<html lang="en" suppressHydrationWarning>
|
||||||
<html
|
|
||||||
lang="en"
|
|
||||||
dir={direction}
|
|
||||||
data-mantine-color-scheme={colorScheme}
|
|
||||||
style={{
|
|
||||||
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
|
|
||||||
}}
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
<head>
|
<head>
|
||||||
|
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SearchEngineOptimization />
|
|
||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
@@ -99,7 +88,3 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getColorScheme = () => {
|
|
||||||
return cookies().get("homarr-color-scheme")?.value ?? "dark";
|
|
||||||
};
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(-50.8%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -18,15 +17,16 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
||||||
|
import { setStaticParamsLocale } from "next-international/server";
|
||||||
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||||
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
import contributorsData from "../../../../../../../static-data/contributors.json";
|
||||||
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
import translatorsData from "../../../../../../../static-data/translators.json";
|
||||||
import classes from "./about.module.css";
|
import classes from "./about.module.css";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -37,26 +37,16 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHost = () => {
|
interface PageProps {
|
||||||
if (process.env.HOSTNAME) {
|
params: {
|
||||||
return `${process.env.HOSTNAME}:3000`;
|
locale: string;
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return headers().get("host");
|
export default async function AboutPage({ params: { locale } }: PageProps) {
|
||||||
};
|
setStaticParamsLocale(locale);
|
||||||
|
|
||||||
export default async function AboutPage() {
|
|
||||||
const baseServerUrl = `http://${getHost()}`;
|
|
||||||
const t = await getScopedI18n("management.page.about");
|
const t = await getScopedI18n("management.page.about");
|
||||||
const attributes = await getPackageAttributesAsync();
|
const attributes = await getPackageAttributesAsync();
|
||||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
|
||||||
res.json(),
|
|
||||||
)) as typeof githubContributorsJson;
|
|
||||||
|
|
||||||
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
|
|
||||||
res.json(),
|
|
||||||
)) as typeof crowdinContributorsJson;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
@@ -80,14 +70,14 @@ export default async function AboutPage() {
|
|||||||
<Text>{t("accordion.contributors.title")}</Text>
|
<Text>{t("accordion.contributors.title")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("accordion.contributors.subtitle", {
|
{t("accordion.contributors.subtitle", {
|
||||||
count: githubContributors.length,
|
count: contributorsData.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<Flex wrap="wrap" gap="xs">
|
<Flex wrap="wrap" gap="xs">
|
||||||
{githubContributors.map((contributor) => (
|
{contributorsData.map((contributor) => (
|
||||||
<GenericContributorLinkCard
|
<GenericContributorLinkCard
|
||||||
key={contributor.login}
|
key={contributor.login}
|
||||||
link={`https://github.com/${contributor.login}`}
|
link={`https://github.com/${contributor.login}`}
|
||||||
@@ -104,14 +94,14 @@ export default async function AboutPage() {
|
|||||||
<Text>{t("accordion.translators.title")}</Text>
|
<Text>{t("accordion.translators.title")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("accordion.translators.subtitle", {
|
{t("accordion.translators.subtitle", {
|
||||||
count: crowdinContributors.length,
|
count: translatorsData.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<Flex wrap="wrap" gap="xs">
|
<Flex wrap="wrap" gap="xs">
|
||||||
{crowdinContributors.map((translator) => (
|
{translatorsData.map((translator) => (
|
||||||
<GenericContributorLinkCard
|
<GenericContributorLinkCard
|
||||||
key={translator.username}
|
key={translator.username}
|
||||||
link={`https://crowdin.com/profile/${translator.username}`}
|
link={`https://crowdin.com/profile/${translator.username}`}
|
||||||
@@ -174,3 +164,9 @@ const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLin
|
|||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return getStaticParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = "force-static";
|
||||||
@@ -6,11 +6,12 @@ import { IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
||||||
|
|
||||||
interface AppDeleteButtonProps {
|
interface AppDeleteButtonProps {
|
||||||
app: RouterOutputs["app"]["all"][number];
|
app: RouterOutputs["app"]["all"][number];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { AppForm } from "../../_form";
|
import { AppForm } from "../../_form";
|
||||||
|
|
||||||
interface AppEditFormProps {
|
interface AppEditFormProps {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useCallback } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { AppForm } from "../_form";
|
import { AppForm } from "../_form";
|
||||||
|
|
||||||
export const AppNewForm = () => {
|
export const AppNewForm = () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { IconApps, IconPencil } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
@@ -70,8 +69,8 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{app.href && (
|
{app.href && (
|
||||||
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
|
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
|
||||||
{parseAppHrefWithVariablesServer(app.href)}
|
{app.href}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -105,7 +104,7 @@ const AppNoResults = async () => {
|
|||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
{t("app.page.list.noResults.title")}
|
{t("app.page.list.noResults.title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
|
|||||||
@@ -1,47 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
import { useCallback } from "react";
|
||||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { BetaBadge } from "@homarr/ui";
|
|
||||||
|
|
||||||
export const CreateBoardButton = () => {
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
|
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
|
||||||
|
interface CreateBoardButtonProps {
|
||||||
|
boardNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
const { openModal } = useModalAction(AddBoardModal);
|
||||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
|
||||||
|
|
||||||
const buttonGroupContent = (
|
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
|
||||||
<>
|
onSettled: async () => {
|
||||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
await revalidatePathActionAsync("/manage/boards");
|
||||||
{t("management.page.board.action.new.label")}
|
},
|
||||||
</Button>
|
});
|
||||||
<Menu position="bottom-end">
|
|
||||||
<Menu.Target>
|
const onClick = useCallback(() => {
|
||||||
<Button px="xs" ms={1}>
|
openModal({
|
||||||
<IconChevronDown size="1rem" />
|
onSuccess: async (values) => {
|
||||||
</Button>
|
await mutateAsync({
|
||||||
</Menu.Target>
|
name: values.name,
|
||||||
<Menu.Dropdown>
|
});
|
||||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
},
|
||||||
<Group>
|
boardNames,
|
||||||
{t("board.action.oldImport.label")}
|
});
|
||||||
<BetaBadge size="xs" />
|
}, [mutateAsync, boardNames, openModal]);
|
||||||
</Group>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
||||||
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
{t("management.page.board.action.new.label")}
|
||||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
</MobileAffixButton>
|
||||||
<Button.Group>{buttonGroupContent}</Button.Group>
|
|
||||||
</Affix>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -19,7 +19,6 @@ import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/i
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -31,9 +30,8 @@ import { CreateBoardButton } from "./_components/create-board-button";
|
|||||||
|
|
||||||
export default async function ManageBoardsPage() {
|
export default async function ManageBoardsPage() {
|
||||||
const t = await getScopedI18n("management.page.board");
|
const t = await getScopedI18n("management.page.board");
|
||||||
const session = await auth();
|
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getAllBoards();
|
||||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
@@ -41,7 +39,7 @@ export default async function ManageBoardsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title mb="md">{t("title")}</Title>
|
<Title mb="md">{t("title")}</Title>
|
||||||
{canCreateBoards && <CreateBoardButton />}
|
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Grid mb={{ base: "xl", md: 0 }}>
|
<Grid mb={{ base: "xl", md: 0 }}>
|
||||||
|
|||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconPlayerPlay, IconSelector, IconSettings } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { integrationPermissions, integrationPermissionsMap } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { AccessSettings } from "~/components/access/access-settings";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
integration: RouterOutputs["integration"]["byId"];
|
|
||||||
initialPermissions: RouterOutputs["integration"]["getIntegrationPermissions"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
|
|
||||||
{
|
|
||||||
id: integration.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
initialData: initialPermissions,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const usersMutation = clientApi.integration.saveUserIntegrationPermissions.useMutation();
|
|
||||||
const groupsMutation = clientApi.integration.saveGroupIntegrationPermissions.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccessSettings
|
|
||||||
entity={{
|
|
||||||
id: integration.id,
|
|
||||||
ownerId: null,
|
|
||||||
owner: null,
|
|
||||||
}}
|
|
||||||
permission={{
|
|
||||||
items: integrationPermissions,
|
|
||||||
default: "use",
|
|
||||||
fullAccessGroupPermission: "integration-full-all",
|
|
||||||
icons: {
|
|
||||||
use: IconSelector,
|
|
||||||
interact: IconPlayerPlay,
|
|
||||||
full: IconSettings,
|
|
||||||
},
|
|
||||||
groupPermissionMapping: integrationPermissionsMap,
|
|
||||||
}}
|
|
||||||
translate={(key) => t(`integration.permission.${key}`)}
|
|
||||||
query={{
|
|
||||||
data,
|
|
||||||
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
|
|
||||||
}}
|
|
||||||
groupsMutation={groupsMutation}
|
|
||||||
usersMutation={usersMutation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import type { MantineSize } from "@mantine/core";
|
|
||||||
import { Avatar } from "@mantine/core";
|
import { Avatar } from "@mantine/core";
|
||||||
|
import type { MantineSize } from "@mantine/core";
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
import { getIconUrl } from "@homarr/definitions";
|
import { getIconUrl } from "@homarr/definitions";
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
interface IntegrationAvatarProps {
|
interface IntegrationAvatarProps {
|
||||||
size: MantineSize;
|
size: MantineSize;
|
||||||
@@ -5,11 +5,12 @@ import { ActionIcon } from "@mantine/core";
|
|||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
||||||
|
|
||||||
interface DeleteIntegrationActionButtonProps {
|
interface DeleteIntegrationActionButtonProps {
|
||||||
count: number;
|
count: number;
|
||||||
integration: { id: string; name: string };
|
integration: { id: string; name: string };
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,6 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -16,6 +15,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||||
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
import { Container, Group, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { IntegrationAvatar } from "../../_integration-avatar";
|
||||||
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
interface EditIntegrationPageProps {
|
interface EditIntegrationPageProps {
|
||||||
@@ -15,10 +13,8 @@ interface EditIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
||||||
const editT = await getScopedI18n("integration.page.edit");
|
const t = await getScopedI18n("integration.page.edit");
|
||||||
const t = await getI18n();
|
const integration = await api.integration.byId({ id: params.id });
|
||||||
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
|
||||||
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -27,14 +23,9 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||||
<Title>{editT("title", { name: getIntegrationName(integration.kind) })}</Title>
|
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<EditIntegrationForm integration={integration} />
|
<EditIntegrationForm integration={integration} />
|
||||||
|
|
||||||
<Title order={2}>{t("permission.title")}</Title>
|
|
||||||
<Fieldset>
|
|
||||||
<IntegrationAccessSettings integration={integration} initialPermissions={integrationPermissions} />
|
|
||||||
</Fieldset>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { IconSearch } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
|
import { IntegrationAvatar } from "../_integration-avatar";
|
||||||
|
|
||||||
export const IntegrationCreateDropdownContent = () => {
|
export const IntegrationCreateDropdownContent = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
|
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -19,6 +17,7 @@ import type { z } from "@homarr/validation";
|
|||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||||
|
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||||
@@ -32,7 +31,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
name: searchParams.name ?? "",
|
||||||
url: searchParams.url ?? "",
|
url: searchParams.url ?? "",
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
@@ -81,7 +80,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
|
|
||||||
@@ -96,11 +95,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
{...form.getInputProps(`secrets.${index}.value`)}
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{form.values.secrets.length === 0 && (
|
|
||||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
|
||||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
@@ -126,20 +120,12 @@ const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmented
|
|||||||
const t = useScopedI18n("integration.secrets");
|
const t = useScopedI18n("integration.secrets");
|
||||||
|
|
||||||
const secretKindGroups = secretKinds.map((kinds) => ({
|
const secretKindGroups = secretKinds.map((kinds) => ({
|
||||||
label:
|
label: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
|
||||||
kinds.length === 0
|
value: kinds.join("-"),
|
||||||
? t("noSecretsRequired.segmentTitle")
|
|
||||||
: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
|
|
||||||
value: kinds.length === 0 ? "empty" : kinds.join("-"),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
if (value === "empty") {
|
|
||||||
form.setFieldValue("secrets", []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const kinds = value.split("-") as IntegrationSecretKind[];
|
const kinds = value.split("-") as IntegrationSecretKind[];
|
||||||
const secrets = kinds.map((kind) => ({
|
const secrets = kinds.map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
import { Container, Group, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
import type { validation } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { IntegrationAvatar } from "../_integration-avatar";
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
@@ -19,17 +18,13 @@ interface NewIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||||
const session = await auth();
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
if (!session?.user.permissions.includes("integration-create")) {
|
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tCreate = await getScopedI18n("integration.page.create");
|
const t = await getScopedI18n("integration.page.create");
|
||||||
|
|
||||||
const currentKind = result.data;
|
const currentKind = result.data;
|
||||||
|
|
||||||
@@ -40,7 +35,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<NewIntegrationForm searchParams={searchParams} />
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -30,16 +30,16 @@ import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react"
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
|
import { CountBadge } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
|
import { IntegrationAvatar } from "./_integration-avatar";
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
|
|
||||||
@@ -51,11 +51,8 @@ interface IntegrationsPageProps {
|
|||||||
|
|
||||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||||
const integrations = await api.integration.all();
|
const integrations = await api.integration.all();
|
||||||
const session = await auth();
|
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
@@ -63,27 +60,23 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
|
|||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title>{t("page.list.title")}</Title>
|
<Title>{t("page.list.title")}</Title>
|
||||||
|
|
||||||
{canCreateIntegrations && (
|
<Box>
|
||||||
<>
|
<IntegrationSelectMenu>
|
||||||
<Box>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
<IntegrationSelectMenu>
|
<MenuTarget>
|
||||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
<MenuTarget>
|
</MenuTarget>
|
||||||
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
</Affix>
|
||||||
</MenuTarget>
|
</IntegrationSelectMenu>
|
||||||
</Affix>
|
</Box>
|
||||||
</IntegrationSelectMenu>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box visibleFrom="md">
|
<Box visibleFrom="md">
|
||||||
<IntegrationSelectMenu>
|
<IntegrationSelectMenu>
|
||||||
<MenuTarget>
|
<MenuTarget>
|
||||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
</MenuTarget>
|
</MenuTarget>
|
||||||
</IntegrationSelectMenu>
|
</IntegrationSelectMenu>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
@@ -110,8 +103,6 @@ interface IntegrationListProps {
|
|||||||
|
|
||||||
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
const session = await auth();
|
|
||||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
|
||||||
|
|
||||||
if (integrations.length === 0) {
|
if (integrations.length === 0) {
|
||||||
return <div>{t("page.list.empty")}</div>;
|
return <div>{t("page.list.empty")}</div>;
|
||||||
@@ -161,20 +152,18 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
<ActionIconGroup>
|
||||||
<ActionIconGroup>
|
<ActionIcon
|
||||||
<ActionIcon
|
component={Link}
|
||||||
component={Link}
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
variant="subtle"
|
||||||
variant="subtle"
|
color="gray"
|
||||||
color="gray"
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
>
|
||||||
>
|
<IconPencil size={16} stroke={1.5} />
|
||||||
<IconPencil size={16} stroke={1.5} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
</ActionIconGroup>
|
||||||
</ActionIconGroup>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
@@ -189,21 +178,18 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Text>{integration.name}</Text>
|
<Text>{integration.name}</Text>
|
||||||
{hasFullAccess ||
|
<ActionIconGroup>
|
||||||
(integration.permissions.hasFullAccess && (
|
<ActionIcon
|
||||||
<ActionIconGroup>
|
component={Link}
|
||||||
<ActionIcon
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
component={Link}
|
variant="subtle"
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
color="gray"
|
||||||
variant="subtle"
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
color="gray"
|
>
|
||||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
<IconPencil size={16} stroke={1.5} />
|
||||||
>
|
</ActionIcon>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
</ActionIcon>
|
</ActionIconGroup>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
|
||||||
</ActionIconGroup>
|
|
||||||
))}
|
|
||||||
</Group>
|
</Group>
|
||||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||||
{integration.url}
|
{integration.url}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
IconPlug,
|
IconPlug,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconReport,
|
IconReport,
|
||||||
IconSearch,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -23,9 +22,6 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
@@ -35,7 +31,6 @@ import { ClientShell } from "~/components/layout/shell";
|
|||||||
|
|
||||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const t = await getScopedI18n("management.navbar");
|
||||||
const session = await auth();
|
|
||||||
const navigationLinks: NavigationLink[] = [
|
const navigationLinks: NavigationLink[] = [
|
||||||
{
|
{
|
||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
@@ -57,15 +52,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: IconSearch,
|
|
||||||
href: "/manage/search-engines",
|
|
||||||
label: t("items.searchEngies"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: IconUser,
|
icon: IconUser,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.users.items.manage"),
|
label: t("items.users.items.manage"),
|
||||||
@@ -76,7 +65,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.users.items.invites"),
|
label: t("items.users.items.invites"),
|
||||||
icon: IconMailForward,
|
icon: IconMailForward,
|
||||||
href: "/manage/users/invites",
|
href: "/manage/users/invites",
|
||||||
hidden: !isProviderEnabled("credentials"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.users.items.groups"),
|
label: t("items.users.items.groups"),
|
||||||
@@ -88,18 +76,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.tools.label"),
|
label: t("items.tools.label"),
|
||||||
icon: IconTool,
|
icon: IconTool,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.docker"),
|
label: t("items.tools.items.docker"),
|
||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
href: "/manage/tools/docker",
|
href: "/manage/tools/docker",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("items.tools.items.api"),
|
|
||||||
icon: IconPlug,
|
|
||||||
href: "/manage/tools/api",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
@@ -116,7 +98,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.settings"),
|
label: t("items.settings"),
|
||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
label: t("items.help.label"),
|
||||||
@@ -125,7 +106,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.help.items.documentation"),
|
label: t("items.help.items.documentation"),
|
||||||
icon: IconBook2,
|
icon: IconBook2,
|
||||||
href: createDocumentationLink("/docs/getting-started"),
|
href: "https://homarr.dev/docs/getting-started/prerequisites",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -135,7 +116,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.items.discord"),
|
label: t("items.tools.items.docker"),
|
||||||
icon: IconBrandDiscord,
|
icon: IconBrandDiscord,
|
||||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||||
external: true,
|
external: true,
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
|||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
@@ -16,7 +14,6 @@ interface LinkProps {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
count: number;
|
count: number;
|
||||||
href: string;
|
href: string;
|
||||||
hidden?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -29,7 +26,6 @@ export async function generateMetadata() {
|
|||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const statistics = await api.home.getStats();
|
const statistics = await api.home.getStats();
|
||||||
const session = await auth();
|
|
||||||
const t = await getScopedI18n("management.page.home");
|
const t = await getScopedI18n("management.page.home");
|
||||||
|
|
||||||
const links: LinkProps[] = [
|
const links: LinkProps[] = [
|
||||||
@@ -37,40 +33,37 @@ export default async function ManagementPage() {
|
|||||||
count: statistics.countBoards,
|
count: statistics.countBoards,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
subtitle: t("statisticLabel.boards"),
|
subtitle: t("statisticLabel.boards"),
|
||||||
title: t("statistic.board"),
|
title: t("statistic.countBoards"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countUsers,
|
count: statistics.countUsers,
|
||||||
href: "/manage/users",
|
href: "/manage/boards",
|
||||||
subtitle: t("statisticLabel.authentication"),
|
subtitle: t("statisticLabel.authentication"),
|
||||||
title: t("statistic.user"),
|
title: t("statistic.createUser"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countInvites,
|
count: statistics.countInvites,
|
||||||
href: "/manage/users/invites",
|
href: "/manage/boards",
|
||||||
subtitle: t("statisticLabel.authentication"),
|
subtitle: t("statisticLabel.authentication"),
|
||||||
title: t("statistic.invite"),
|
title: t("statistic.createInvite"),
|
||||||
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countIntegrations,
|
count: statistics.countIntegrations,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
subtitle: t("statisticLabel.resources"),
|
subtitle: t("statisticLabel.resources"),
|
||||||
title: t("statistic.integration"),
|
title: t("statistic.addIntegration"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countApps,
|
count: statistics.countApps,
|
||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
subtitle: t("statisticLabel.resources"),
|
subtitle: t("statisticLabel.resources"),
|
||||||
title: t("statistic.app"),
|
title: t("statistic.addApp"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countGroups,
|
count: statistics.countGroups,
|
||||||
href: "/manage/users/groups",
|
href: "/manage/users/groups",
|
||||||
subtitle: t("statisticLabel.authorization"),
|
subtitle: t("statisticLabel.authorization"),
|
||||||
title: t("statistic.group"),
|
title: t("statistic.manageRoles"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
@@ -79,27 +72,24 @@ export default async function ManagementPage() {
|
|||||||
<HeroBanner />
|
<HeroBanner />
|
||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{links.map(
|
{links.map((link, index) => (
|
||||||
(link) =>
|
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
|
||||||
!link.hidden && (
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Card component={Link} href={link.href} key={link.href} withBorder>
|
<Group wrap="nowrap">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Text size="2.4rem" fw="bolder">
|
||||||
<Group wrap="nowrap">
|
{link.count}
|
||||||
<Text size="2.4rem" fw="bolder">
|
</Text>
|
||||||
{link.count}
|
<Stack gap={0}>
|
||||||
</Text>
|
<Text c="red" size="xs">
|
||||||
<Stack gap={0}>
|
{link.subtitle}
|
||||||
<Text c="red" size="xs">
|
</Text>
|
||||||
{link.subtitle}
|
<Text fw="bold">{link.title}</Text>
|
||||||
</Text>
|
</Stack>
|
||||||
<Text fw="bold">{link.title}</Text>
|
</Group>
|
||||||
</Stack>
|
<IconArrowRight />
|
||||||
</Group>
|
</Group>
|
||||||
<IconArrowRight />
|
</Card>
|
||||||
</Group>
|
))}
|
||||||
</Card>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
|
||||||
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
|
||||||
|
|
||||||
interface SearchEngineFormProps {
|
|
||||||
submitButtonTranslation: (t: TranslationFunction) => string;
|
|
||||||
initialValues?: FormType;
|
|
||||||
handleSubmit: (values: FormType) => void;
|
|
||||||
isPending: boolean;
|
|
||||||
disableShort?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
|
||||||
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
const form = useZodForm(validation.searchEngine.manage, {
|
|
||||||
initialValues: initialValues ?? {
|
|
||||||
name: "",
|
|
||||||
short: "",
|
|
||||||
iconUrl: "",
|
|
||||||
urlTemplate: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack>
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
|
|
||||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
|
|
||||||
<TextInput
|
|
||||||
{...form.getInputProps("short")}
|
|
||||||
disabled={disableShort}
|
|
||||||
withAsterisk
|
|
||||||
label={t("search.engine.field.short.label")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
|
||||||
<TextInput
|
|
||||||
{...form.getInputProps("urlTemplate")}
|
|
||||||
withAsterisk
|
|
||||||
label={t("search.engine.field.urlTemplate.label")}
|
|
||||||
/>
|
|
||||||
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button variant="default" component={Link} href="/manage/search-engines">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{submitButtonTranslation(t)}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { ActionIcon } from "@mantine/core";
|
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
interface SearchEngineDeleteButtonProps {
|
|
||||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
|
||||||
const t = useScopedI18n("search.engine.page.delete");
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
|
||||||
openConfirmModal({
|
|
||||||
title: t("title"),
|
|
||||||
children: t("message", searchEngine),
|
|
||||||
onConfirm: () => {
|
|
||||||
mutate(
|
|
||||||
{ id: searchEngine.id },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("notification.success.title"),
|
|
||||||
message: t("notification.success.message"),
|
|
||||||
});
|
|
||||||
void revalidatePathActionAsync("/manage/search-engines");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("notification.error.title"),
|
|
||||||
message: t("notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [searchEngine, mutate, t, openConfirmModal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import type { validation, z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { SearchEngineForm } from "../../_form";
|
|
||||||
|
|
||||||
interface SearchEngineEditFormProps {
|
|
||||||
searchEngine: RouterOutputs["searchEngine"]["byId"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
|
|
||||||
const t = useScopedI18n("search.engine.page.edit.notification");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("success.title"),
|
|
||||||
message: t("success.message"),
|
|
||||||
});
|
|
||||||
void revalidatePathActionAsync("/manage/search-engines").then(() => {
|
|
||||||
router.push("/manage/search-engines");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("error.title"),
|
|
||||||
message: t("error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(values: z.infer<typeof validation.searchEngine.manage>) => {
|
|
||||||
mutate({
|
|
||||||
id: searchEngine.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[mutate, searchEngine.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SearchEngineForm
|
|
||||||
submitButtonTranslation={submitButtonTranslation}
|
|
||||||
initialValues={searchEngine}
|
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
isPending={isPending}
|
|
||||||
disableShort
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|
||||||
import { SearchEngineEditForm } from "./_search-engine-edit-form";
|
|
||||||
|
|
||||||
interface SearchEngineEditPageProps {
|
|
||||||
params: { id: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
|
||||||
const searchEngine = await api.searchEngine.byId({ id: params.id });
|
|
||||||
const t = await getI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ManageContainer>
|
|
||||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("search.engine.page.edit.title")}</Title>
|
|
||||||
<SearchEngineEditForm searchEngine={searchEngine} />
|
|
||||||
</Stack>
|
|
||||||
</ManageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import type { validation, z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { SearchEngineForm } from "../_form";
|
|
||||||
|
|
||||||
export const SearchEngineNewForm = () => {
|
|
||||||
const t = useScopedI18n("search.engine.page.create.notification");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("success.title"),
|
|
||||||
message: t("success.message"),
|
|
||||||
});
|
|
||||||
await revalidatePathActionAsync("/manage/search-engines");
|
|
||||||
router.push("/manage/search-engines");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("error.title"),
|
|
||||||
message: t("error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(values: z.infer<typeof validation.searchEngine.manage>) => {
|
|
||||||
mutate(values);
|
|
||||||
},
|
|
||||||
[mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SearchEngineForm
|
|
||||||
submitButtonTranslation={submitButtonTranslation}
|
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
isPending={isPending}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|
||||||
import { SearchEngineNewForm } from "./_search-engine-new-form";
|
|
||||||
|
|
||||||
export default async function SearchEngineNewPage() {
|
|
||||||
const t = await getI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ManageContainer>
|
|
||||||
<DynamicBreadcrumb />
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("search.engine.page.create.title")}</Title>
|
|
||||||
<SearchEngineNewForm />
|
|
||||||
</Stack>
|
|
||||||
</ManageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
|
||||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
|
||||||
import { z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|
||||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
|
||||||
|
|
||||||
const searchParamsSchema = z.object({
|
|
||||||
search: z.string().optional(),
|
|
||||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
|
||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
|
||||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
interface SearchEnginesPageProps {
|
|
||||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|
||||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
|
||||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
|
||||||
|
|
||||||
const tEngine = await getScopedI18n("search.engine");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ManageContainer>
|
|
||||||
<DynamicBreadcrumb />
|
|
||||||
<Stack>
|
|
||||||
<Title>{tEngine("page.list.title")}</Title>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
|
|
||||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
|
||||||
{tEngine("page.create.title")}
|
|
||||||
</MobileAffixButton>
|
|
||||||
</Group>
|
|
||||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
|
||||||
{searchEngines.length > 0 && (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{searchEngines.map((searchEngine) => (
|
|
||||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</ManageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchEngineCardProps {
|
|
||||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
|
||||||
const t = await getScopedI18n("search.engine");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
|
||||||
<Avatar
|
|
||||||
size="sm"
|
|
||||||
src={searchEngine.iconUrl}
|
|
||||||
radius={0}
|
|
||||||
styles={{
|
|
||||||
image: {
|
|
||||||
objectFit: "contain",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw={500} lineClamp={1}>
|
|
||||||
{searchEngine.name}
|
|
||||||
</Text>
|
|
||||||
{searchEngine.description && (
|
|
||||||
<Text size="sm" c="gray.6" lineClamp={4}>
|
|
||||||
{searchEngine.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
|
||||||
{searchEngine.urlTemplate}
|
|
||||||
</Anchor>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<Group>
|
|
||||||
<ActionIconGroup>
|
|
||||||
<ActionIcon
|
|
||||||
component={Link}
|
|
||||||
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
aria-label={t("page.edit.title")}
|
|
||||||
>
|
|
||||||
<IconPencil size={16} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
|
||||||
</ActionIconGroup>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SearchEngineNoResults = async () => {
|
|
||||||
const t = await getI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder bg="transparent">
|
|
||||||
<Stack align="center" gap="sm">
|
|
||||||
<IconSearch size="2rem" />
|
|
||||||
<Text fw={500} size="lg">
|
|
||||||
{t("search.engine.page.list.noResults.title")}
|
|
||||||
</Text>
|
|
||||||
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
import type { MantineSpacing } from "@mantine/core";
|
||||||
|
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useForm } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
|
|
||||||
interface AnalyticsSettingsProps {
|
interface AnalyticsSettingsProps {
|
||||||
initialData: typeof defaultServerSettings.analytics;
|
initialData: typeof defaultServerSettings.analytics;
|
||||||
@@ -60,7 +62,6 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("integrationData.title")}
|
title={t("integrationData.title")}
|
||||||
text={t("integrationData.text")}
|
text={t("integrationData.text")}
|
||||||
disabled={!form.values.enableGeneral}
|
|
||||||
/>
|
/>
|
||||||
<SwitchSetting
|
<SwitchSetting
|
||||||
form={form}
|
form={form}
|
||||||
@@ -68,7 +69,6 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("widgetData.title")}
|
title={t("widgetData.title")}
|
||||||
text={t("widgetData.text")}
|
text={t("widgetData.text")}
|
||||||
disabled={!form.values.enableGeneral}
|
|
||||||
/>
|
/>
|
||||||
<SwitchSetting
|
<SwitchSetting
|
||||||
form={form}
|
form={form}
|
||||||
@@ -76,10 +76,45 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("usersData.title")}
|
title={t("usersData.title")}
|
||||||
text={t("usersData.text")}
|
text={t("usersData.text")}
|
||||||
disabled={!form.values.enableGeneral}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SwitchSetting = ({
|
||||||
|
form,
|
||||||
|
ms,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
formKey,
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
|
||||||
|
formKey: keyof typeof defaultServerSettings.analytics;
|
||||||
|
ms?: MantineSpacing;
|
||||||
|
title: string;
|
||||||
|
text: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setFieldValue(formKey, !form.values[formKey]);
|
||||||
|
}, [form, formKey, disabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
||||||
|
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw="bold">{title}</Text>
|
||||||
|
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</UnstyledButton>
|
||||||
|
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useForm } from "@homarr/form";
|
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
|
||||||
|
|
||||||
interface CrawlingAndIndexingSettingsProps {
|
|
||||||
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
|
|
||||||
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: initialData,
|
|
||||||
onValuesChange: (updatedValues, _) => {
|
|
||||||
if (!form.isValid()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void mutateAsync({
|
|
||||||
settingsKey: "crawlingAndIndexing",
|
|
||||||
value: updatedValues,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
|
||||||
onSettled: async () => {
|
|
||||||
await revalidatePathActionAsync("/manage/settings");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title order={2}>{t("title")}</Title>
|
|
||||||
|
|
||||||
<Card pos="relative" withBorder>
|
|
||||||
<Text c={"dimmed"} mb={"lg"}>
|
|
||||||
{t("warning")}
|
|
||||||
</Text>
|
|
||||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
|
||||||
<Stack>
|
|
||||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
|
||||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
|
||||||
<SwitchSetting
|
|
||||||
form={form}
|
|
||||||
formKey="noTranslate"
|
|
||||||
title={t("noTranslate.title")}
|
|
||||||
text={t("noTranslate.text")}
|
|
||||||
/>
|
|
||||||
<SwitchSetting
|
|
||||||
form={form}
|
|
||||||
formKey="noSiteLinksSearchBox"
|
|
||||||
title={t("noSiteLinksSearchBox.title")}
|
|
||||||
text={t("noSiteLinksSearchBox.text")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import React from "react";
|
|
||||||
import type { MantineSpacing } from "@mantine/core";
|
|
||||||
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { Modify } from "@homarr/common/types";
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
|
||||||
|
|
||||||
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
|
||||||
form,
|
|
||||||
ms,
|
|
||||||
title,
|
|
||||||
text,
|
|
||||||
formKey,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
form: Modify<
|
|
||||||
UseFormReturnType<TFormValue, () => TFormValue>,
|
|
||||||
{
|
|
||||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
formKey: keyof TFormValue;
|
|
||||||
ms?: MantineSpacing;
|
|
||||||
title: string;
|
|
||||||
text: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
const handleClick = React.useCallback(() => {
|
|
||||||
if (disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.setFieldValue(formKey, (previous) => !previous);
|
|
||||||
}, [form, formKey, disabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
|
||||||
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw="bold">{title}</Text>
|
|
||||||
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</UnstyledButton>
|
|
||||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,6 @@ import { Stack, Title } from "@mantine/core";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ export default async function SettingsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("title")}</Title>
|
<Title order={1}>{t("title")}</Title>
|
||||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||||
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
|
||||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { UserAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
|
|
||||||
|
|
||||||
interface ApiKeysManagementProps {
|
|
||||||
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|
||||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
|
||||||
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
|
|
||||||
async onSuccess(data) {
|
|
||||||
openModal({
|
|
||||||
apiKey: data.randomToken,
|
|
||||||
});
|
|
||||||
await revalidatePathActionAsync("/manage/tools/api");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
|
|
||||||
|
|
||||||
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: "id",
|
|
||||||
header: t("table.header.id"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "user",
|
|
||||||
header: t("table.header.createdBy"),
|
|
||||||
Cell: ({ row }) => (
|
|
||||||
<Group gap={"xs"}>
|
|
||||||
<UserAvatar user={row.original.user} size={"sm"} />
|
|
||||||
<Text>{row.original.user.name}</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useMantineReactTable({
|
|
||||||
columns,
|
|
||||||
data: apiKeys,
|
|
||||||
renderTopToolbarCustomActions: () => (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
mutate();
|
|
||||||
}}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
{t("button.createApiToken")}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
enableDensityToggle: false,
|
|
||||||
state: {
|
|
||||||
density: "xs",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("title")}</Title>
|
|
||||||
<MantineReactTable table={table} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.api.modal.createApiToken");
|
|
||||||
const [visible, { toggle }] = useDisclosure(false);
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Text>{t("description")}</Text>
|
|
||||||
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
|
|
||||||
<CopyButton value={innerProps.apiKey}>
|
|
||||||
{({ copy }) => (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
copy();
|
|
||||||
actions.closeModal();
|
|
||||||
}}
|
|
||||||
variant="default"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t("button")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle(t) {
|
|
||||||
return t("management.page.tool.api.modal.createApiToken.title");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { OpenAPIV3 } from "openapi-types";
|
|
||||||
import SwaggerUI from "swagger-ui-react";
|
|
||||||
|
|
||||||
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
|
||||||
import "../swagger-ui-dark.css";
|
|
||||||
import "../swagger-ui-overrides.css";
|
|
||||||
import "../swagger-ui.css";
|
|
||||||
|
|
||||||
interface SwaggerUIClientProps {
|
|
||||||
document: OpenAPIV3.Document;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const requestInterceptor = (req: Record<string, any>) => {
|
|
||||||
req.credentials = "omit";
|
|
||||||
return req;
|
|
||||||
};
|
|
||||||
|
|
||||||
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
|
|
||||||
};
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
|
||||||
|
|
||||||
import { openApiDocument } from "@homarr/api";
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { ApiKeysManagement } from "./components/api-keys";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("management");
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: createMetaTitle(t("metaTitle")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ApiPage() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
|
|
||||||
const apiKeys = await api.apiKeys.getAll();
|
|
||||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Tabs defaultValue={"documentation"}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
|
|
||||||
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
|
|
||||||
</TabsList>
|
|
||||||
<TabsPanel value={"authentication"}>
|
|
||||||
<ApiKeysManagement apiKeys={apiKeys} />
|
|
||||||
</TabsPanel>
|
|
||||||
<TabsPanel value={"documentation"}>
|
|
||||||
<SwaggerUIClient document={document} />
|
|
||||||
</TabsPanel>
|
|
||||||
</Tabs>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
|||||||
.swagger-ui .info {
|
|
||||||
margin: 0 !important;
|
|
||||||
margin-bottom: 20px !important;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||||
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -15,7 +15,6 @@ import type { TranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { OverflowBadge } from "@homarr/ui";
|
import { OverflowBadge } from "@homarr/ui";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|
||||||
|
|
||||||
const createColumns = (
|
const createColumns = (
|
||||||
t: TranslationFunction,
|
t: TranslationFunction,
|
||||||
@@ -46,12 +45,10 @@ const createColumns = (
|
|||||||
accessorKey: "image",
|
accessorKey: "image",
|
||||||
header: t("docker.field.containerImage.label"),
|
header: t("docker.field.containerImage.label"),
|
||||||
maxSize: 200,
|
maxSize: 200,
|
||||||
Cell({ renderedCellValue, cell }) {
|
Cell({ renderedCellValue }) {
|
||||||
return (
|
return (
|
||||||
<Box maw={200}>
|
<Box maw={200}>
|
||||||
<Text truncate="end" title={cell.row.original.image}>
|
<Text truncate="end">{renderedCellValue}</Text>
|
||||||
{renderedCellValue}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -77,7 +74,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data.timestamp);
|
const relativeTime = useTimeAgo(data.timestamp);
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useMantineReactTable({
|
||||||
data: data.containers,
|
data: data.containers,
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
@@ -95,35 +92,6 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
},
|
},
|
||||||
|
|
||||||
initialState: { density: "xs", showGlobalFilter: true },
|
initialState: { density: "xs", showGlobalFilter: true },
|
||||||
renderTopToolbarCustomActions: () => {
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
|
||||||
async onSuccess() {
|
|
||||||
await utils.docker.getContainers.invalidate();
|
|
||||||
showSuccessNotification({
|
|
||||||
title: tDocker("action.refresh.notification.success.title"),
|
|
||||||
message: tDocker("action.refresh.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: tDocker("action.refresh.notification.error.title"),
|
|
||||||
message: tDocker("action.refresh.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
rightSection={<IconRefresh size="1rem" />}
|
|
||||||
onClick={() => mutate()}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
{tDocker("action.refresh.label")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||||
return (
|
return (
|
||||||
<Group gap={"sm"}>
|
<Group gap={"sm"}>
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { DockerTable } from "./docker-table";
|
import { DockerTable } from "./docker-table";
|
||||||
|
|
||||||
export default async function DockerPage() {
|
export default async function DockerPage() {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { containers, timestamp } = await api.docker.getContainers();
|
const { containers, timestamp } = await api.docker.getContainers();
|
||||||
const tDocker = await getScopedI18n("docker");
|
const tDocker = await getScopedI18n("docker");
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
export const ClientSideTerminalComponent = dynamic(
|
|
||||||
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -4,20 +4,13 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { notFound } from "next/navigation";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { ClientSideTerminalComponent } from "./client";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -25,12 +18,11 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function LogsManagementPage() {
|
const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
|
||||||
const session = await auth();
|
ssr: false,
|
||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
});
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export default function LogsManagementPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
|
|
||||||
import classes from "./terminal.module.css";
|
import classes from "./terminal.module.css";
|
||||||
|
|
||||||
export const TerminalComponent = () => {
|
export default function TerminalComponent() {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const terminalRef = useRef<Terminal>();
|
const terminalRef = useRef<Terminal>();
|
||||||
@@ -54,4 +54,4 @@ export const TerminalComponent = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||||
};
|
}
|
||||||
@@ -58,7 +58,7 @@ export const JobsList = ({ initialJobs }: JobsListProps) => {
|
|||||||
<Group justify={"space-between"} gap={"md"}>
|
<Group justify={"space-between"} gap={"md"}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group>
|
<Group>
|
||||||
<Text>{t(`job.${job.job.name}.label` as TranslationKeys)}</Text>
|
<Text>{t(`${job.job.name}.label` as TranslationKeys)}</Text>
|
||||||
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
|
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
|
||||||
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
|
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
|
||||||
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
|
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Box, Title } from "@mantine/core";
|
import { Box, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { JobsList } from "./_components/jobs-list";
|
import { JobsList } from "./_components/jobs-list";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -21,11 +15,6 @@ export async function generateMetadata() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function TasksPage() {
|
export default async function TasksPage() {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobs = await api.cronJobs.getJobs();
|
const jobs = await api.cronJobs.getJobs();
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
|
|
||||||
interface ChangeHomeBoardFormProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
boardsData: { value: string; label: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
form.setInitialValues({
|
|
||||||
homeBoardId: variables.homeBoardId,
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("user.action.changeHomeBoard.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("user.action.changeHomeBoard.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(validation.user.changeHomeBoard, {
|
|
||||||
initialValues: {
|
|
||||||
homeBoardId: user.homeBoardId ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate({
|
|
||||||
userId: user.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
|
|
||||||
+2
-1
@@ -6,10 +6,11 @@ import { Button } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
|
|
||||||
interface DeleteUserButtonProps {
|
interface DeleteUserButtonProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
|
|||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Radio, Stack } from "@mantine/core";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import localeData from "dayjs/plugin/localeData";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
|
|
||||||
dayjs.extend(localeData);
|
|
||||||
|
|
||||||
interface FirstDayOfWeekProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekDays = dayjs.weekdays(false);
|
|
||||||
|
|
||||||
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
form.setInitialValues({
|
|
||||||
firstDayOfWeek: variables.firstDayOfWeek,
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(validation.user.firstDayOfWeek, {
|
|
||||||
initialValues: {
|
|
||||||
firstDayOfWeek: user.firstDayOfWeek,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate({
|
|
||||||
id: user.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputProps = form.getInputProps("firstDayOfWeek");
|
|
||||||
const onChange = inputProps.onChange as (value: number) => void;
|
|
||||||
const value = (inputProps.value as number).toString();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
|
|
||||||
<Group mt="xs">
|
|
||||||
<Radio value="1" label={weekDays[1]} />
|
|
||||||
<Radio value="6" label={weekDays[6]} />
|
|
||||||
<Radio value="0" label={weekDays[0]} />
|
|
||||||
</Group>
|
|
||||||
</Radio.Group>
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.user.firstDayOfWeek>;
|
|
||||||
-66
@@ -1,66 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
|
|
||||||
interface PingIconsEnabledProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
form.setInitialValues({
|
|
||||||
pingIconsEnabled: variables.pingIconsEnabled,
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("user.action.changePingIconsEnabled.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("user.action.changePingIconsEnabled.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(validation.user.pingIconsEnabled, {
|
|
||||||
initialValues: {
|
|
||||||
pingIconsEnabled: user.pingIconsEnabled,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate({
|
|
||||||
id: user.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.user.pingIconsEnabled>;
|
|
||||||
+16
-29
@@ -7,12 +7,13 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
|
|
||||||
interface UserProfileAvatarForm {
|
interface UserProfileAvatarForm {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
@@ -92,38 +93,24 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
});
|
});
|
||||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||||
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative">
|
<Box pos="relative">
|
||||||
<Menu
|
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
|
||||||
opened={opened}
|
|
||||||
keepMounted
|
|
||||||
onChange={isCredentialsUser ? toggle : undefined}
|
|
||||||
position="bottom-start"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton
|
<UnstyledButton onClick={toggle}>
|
||||||
component={isCredentialsUser ? undefined : "div"}
|
|
||||||
style={{ cursor: !isCredentialsUser ? "default" : undefined }}
|
|
||||||
onClick={isCredentialsUser ? toggle : undefined}
|
|
||||||
>
|
|
||||||
<UserAvatar user={user} size={200} />
|
<UserAvatar user={user} size={200} />
|
||||||
{isCredentialsUser && (
|
<Button
|
||||||
<Button
|
component="div"
|
||||||
component="div"
|
pos="absolute"
|
||||||
pos="absolute"
|
bottom={0}
|
||||||
bottom={0}
|
left={0}
|
||||||
left={0}
|
size="compact-md"
|
||||||
size="compact-md"
|
fw="normal"
|
||||||
fw="normal"
|
variant="default"
|
||||||
variant="default"
|
leftSection={<IconPencil size={18} stroke={1.5} />}
|
||||||
leftSection={<IconPencil size={18} stroke={1.5} />}
|
>
|
||||||
>
|
{t("common.action.edit")}
|
||||||
{t("common.action.edit")}
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
|||||||
+10
-24
@@ -5,12 +5,13 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
|
|
||||||
interface UserProfileFormProps {
|
interface UserProfileFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
@@ -50,42 +51,27 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only credentials users can edit their profile
|
|
||||||
const isProviderCredentials = user.provider === "credentials";
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(values: FormType) => {
|
(values: FormType) => {
|
||||||
if (!isProviderCredentials) return;
|
|
||||||
mutate({
|
mutate({
|
||||||
...values,
|
...values,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isProviderCredentials, mutate, user.id],
|
[user.id, mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} />
|
||||||
disabled={!isProviderCredentials}
|
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
|
||||||
label={t("user.field.username.label")}
|
|
||||||
withAsterisk
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
disabled={!isProviderCredentials}
|
|
||||||
label={t("user.field.email.label")}
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isProviderCredentials && (
|
<Group justify="end">
|
||||||
<Group justify="end">
|
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
|
||||||
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
|
{t("common.action.saveChanges")}
|
||||||
{t("common.action.saveChanges")}
|
</Button>
|
||||||
</Button>
|
</Group>
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||||
|
|
||||||
|
export const ProfileLanguageChange = () => {
|
||||||
|
return (
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>Language & Region</Title>
|
||||||
|
<LanguageCombobox />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
|
import { Box, Group, Stack, Title } from "@mantine/core";
|
||||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { LanguageCombobox } from "~/components/language/language-combobox";
|
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
|
||||||
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { UserProfileForm } from "./_components/_profile-form";
|
import { UserProfileForm } from "./_components/_profile-form";
|
||||||
|
import { ProfileLanguageChange } from "./_components/_profile-language-change";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: {
|
||||||
@@ -57,18 +53,8 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const boards = await api.board.getAllBoards();
|
|
||||||
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{!isCredentialsUser && (
|
|
||||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
|
||||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title>{tGeneral("title")}</Title>
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Group gap="xl">
|
<Group gap="xl">
|
||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
@@ -79,41 +65,15 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack mb="lg">
|
<ProfileLanguageChange />
|
||||||
<Title order={2}>{tGeneral("item.language")}</Title>
|
|
||||||
<LanguageCombobox />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack mb="lg">
|
<DangerZoneRoot>
|
||||||
<Title order={2}>{tGeneral("item.board")}</Title>
|
<DangerZoneItem
|
||||||
<ChangeHomeBoardForm
|
label={t("user.action.delete.label")}
|
||||||
user={user}
|
description={t("user.action.delete.description")}
|
||||||
boardsData={boards.map((board) => ({
|
action={<DeleteUserButton user={user} />}
|
||||||
value: board.id,
|
|
||||||
label: board.name,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</DangerZoneRoot>
|
||||||
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
|
||||||
<FirstDayOfWeek user={user} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>{tGeneral("item.accessibility")}</Title>
|
|
||||||
<PingIconsEnabled user={user} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{isCredentialsUser && (
|
|
||||||
<DangerZoneRoot>
|
|
||||||
<DangerZoneItem
|
|
||||||
label={t("user.action.delete.label")}
|
|
||||||
description={t("user.action.delete.description")}
|
|
||||||
action={<DeleteUserButton user={user} />}
|
|
||||||
/>
|
|
||||||
</DangerZoneRoot>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,6 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer size="xl">
|
<ManageContainer size="xl">
|
||||||
<DynamicBreadcrumb
|
<DynamicBreadcrumb
|
||||||
@@ -43,7 +41,7 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
|||||||
/>
|
/>
|
||||||
<Grid>
|
<Grid>
|
||||||
<GridCol span={12}>
|
<GridCol span={12}>
|
||||||
<Group align="center">
|
<Group justify="space-between" align="center">
|
||||||
<UserAvatar user={user} size="lg" />
|
<UserAvatar user={user} size="lg" />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title order={3}>{user.name}</Title>
|
<Title order={3}>{user.name}</Title>
|
||||||
@@ -59,13 +57,11 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
|||||||
label={tUser("setting.general.title")}
|
label={tUser("setting.general.title")}
|
||||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
{isCredentialsUser && (
|
<NavigationLink
|
||||||
<NavigationLink
|
href={`/manage/users/${params.userId}/security`}
|
||||||
href={`/manage/users/${params.userId}/security`}
|
label={tUser("setting.security.title")}
|
||||||
label={tUser("setting.security.title")}
|
icon={<IconShieldLock size="1rem" stroke={1.5} />}
|
||||||
icon={<IconShieldLock size="1rem" stroke={1.5} />}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
+3
-8
@@ -5,13 +5,13 @@ import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
|
|||||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput } from "@homarr/ui";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
|
|
||||||
interface ChangePasswordFormProps {
|
interface ChangePasswordFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
@@ -71,12 +71,7 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CustomPasswordInput
|
<PasswordInput withAsterisk label={t("user.field.password.label")} {...form.getInputProps("password")} />
|
||||||
withPasswordRequirements
|
|
||||||
withAsterisk
|
|
||||||
label={t("user.field.password.label")}
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ export default async function UserSecurityPage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.provider !== "credentials") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{tSecurity("title")}</Title>
|
<Title>{tSecurity("title")}</Title>
|
||||||
|
|||||||
+19
-24
@@ -2,23 +2,20 @@
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
|
import { Avatar, Button, Group, Text, ThemeIcon, Title } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|
||||||
|
|
||||||
interface UserListComponentProps {
|
interface UserListComponentProps {
|
||||||
initialUserList: RouterOutputs["user"]["getAll"];
|
initialUserList: RouterOutputs["user"]["getAll"];
|
||||||
credentialsProviderEnabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => {
|
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
|
||||||
const tUserList = useScopedI18n("management.page.user.list");
|
const tUserList = useScopedI18n("management.page.user.list");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
||||||
@@ -30,27 +27,26 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: t("user.field.username.label"),
|
header: t("user.field.username.label"),
|
||||||
grow: 1,
|
grow: 100,
|
||||||
Cell: ({ renderedCellValue, row }) => (
|
Cell: ({ renderedCellValue, row }) => (
|
||||||
<Group>
|
<Link href={`/manage/users/${row.original.id}/general`}>
|
||||||
<UserAvatar size="sm" user={row.original} />
|
<Group>
|
||||||
<Anchor component={Link} href={`/manage/users/${row.original.id}/general`}>
|
<Avatar size="sm"></Avatar>
|
||||||
{renderedCellValue}
|
{renderedCellValue}
|
||||||
</Anchor>
|
</Group>
|
||||||
</Group>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
header: t("user.field.email.label"),
|
header: t("user.field.email.label"),
|
||||||
size: 300,
|
|
||||||
Cell: ({ renderedCellValue, row }) => (
|
Cell: ({ renderedCellValue, row }) => (
|
||||||
<Group wrap="nowrap" gap="sm">
|
<Group>
|
||||||
{row.original.email ? renderedCellValue : <Text>-</Text>}
|
{row.original.email ? renderedCellValue : <Text>-</Text>}
|
||||||
{row.original.emailVerified && (
|
{row.original.emailVerified && (
|
||||||
<Tooltip label={t("user.field.email.verified")} position="top">
|
<ThemeIcon radius="xl" size="sm">
|
||||||
<IconCheck color="var(--mantine-color-green-4)" size="1rem" />
|
<IconCheck size="1rem" />
|
||||||
</Tooltip>
|
</ThemeIcon>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
@@ -59,7 +55,7 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useMantineReactTable({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
@@ -70,12 +66,11 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
|
|||||||
enableFullScreenToggle: false,
|
enableFullScreenToggle: false,
|
||||||
layoutMode: "grid-no-grow",
|
layoutMode: "grid-no-grow",
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
renderTopToolbarCustomActions: () =>
|
renderTopToolbarCustomActions: () => (
|
||||||
credentialsProviderEnabled ? (
|
<Button component={Link} href="/manage/users/create">
|
||||||
<Button component={Link} href="/manage/users/create">
|
Create New User
|
||||||
{t("management.page.user.create.title")}
|
</Button>
|
||||||
</Button>
|
),
|
||||||
) : null,
|
|
||||||
state: {
|
state: {
|
||||||
isLoading,
|
isLoading,
|
||||||
},
|
},
|
||||||
+4
-6
@@ -1,18 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
|
import { Avatar, Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
|
||||||
import { IconUserCheck } from "@tabler/icons-react";
|
import { IconUserCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||||
|
|
||||||
import { StepperNavigationComponent } from "./stepper-navigation";
|
import { StepperNavigationComponent } from "./stepper-navigation.component";
|
||||||
|
|
||||||
export const UserCreateStepperComponent = () => {
|
export const UserCreateStepperComponent = () => {
|
||||||
const t = useScopedI18n("management.page.user.create");
|
const t = useScopedI18n("management.page.user.create");
|
||||||
@@ -124,8 +123,7 @@ export const UserCreateStepperComponent = () => {
|
|||||||
<form>
|
<form>
|
||||||
<Card p="xl">
|
<Card p="xl">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<CustomPasswordInput
|
<PasswordInput
|
||||||
withPasswordRequirements
|
|
||||||
label={tUserField("password.label")}
|
label={tUserField("password.label")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
@@ -152,7 +150,7 @@ export const UserCreateStepperComponent = () => {
|
|||||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<Card p="xl">
|
<Card p="xl">
|
||||||
<Stack maw={300} align="center" mx="auto">
|
<Stack maw={300} align="center" mx="auto">
|
||||||
<UserAvatar size="xl" user={{ name: generalForm.values.username, image: null }} />
|
<Avatar size="xl">{generalForm.values.username}</Avatar>
|
||||||
<Text tt="uppercase" fw="bolder" size="xl">
|
<Text tt="uppercase" fw="bolder" size="xl">
|
||||||
{generalForm.values.username}
|
{generalForm.values.username}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
File renamed without changes.
Loaded 100 of 624 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user