Compare commits

..
Author SHA1 Message Date
Meier Lukas 27b04e620e wip: experiment with dropping to a div and removing item 2024-07-07 12:37:24 +02:00
624 changed files with 7589 additions and 71492 deletions

No files matched your search

+1 -2
View File
@@ -5,5 +5,4 @@ npm-debug.log
README.md README.md
.next .next
.git .git
dev dev
.build
+2
View File
@@ -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
View File
@@ -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
}
+17 -58
View File
@@ -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."
+14 -33
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
20.18.0 20.15.0
+1 -2
View File
@@ -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"
] ]
} }
+2 -11
View File
@@ -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
View File
@@ -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"]
+5 -14
View File
@@ -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
View File
@@ -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>
);
};
@@ -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;
@@ -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"];
@@ -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;
}
@@ -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")}
+26 -41
View File
@@ -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 }}>
@@ -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}
/>
);
};
@@ -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 };
@@ -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}
+2 -21
View File
@@ -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,
+26 -36
View File
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
@@ -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>;
@@ -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"];
} }
@@ -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>;
@@ -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>;
@@ -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>
@@ -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>
); );
@@ -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>
@@ -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>
@@ -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,
}, },
@@ -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>
Loaded 100 of 624 files, more files were not shown because too many files have changed in this diff. Show more