chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-08-02 19:52:15 +00:00
committed by GitHub
426 changed files with 20042 additions and 4933 deletions

View File

@@ -6,7 +6,8 @@
"packageRules": [ "packageRules": [
{ {
"matchPackagePatterns": [ "matchPackagePatterns": [
"^@homarr/" "^@homarr/",
"tsx" // Disabled for now as version 0.14.4 did not work with the current version of homarr. It resulted in a ERR_MODULE_NOT_FOUND error
], ],
"enabled": false "enabled": false
}, },

View File

@@ -1,31 +0,0 @@
name: Build apps and migration script
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
env:
FORCE_COLOR: 3
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm build

View File

@@ -1,4 +1,4 @@
name: Code quality analysis name: "[Quality] Code Analysis"
on: on:
pull_request: pull_request:
@@ -8,7 +8,7 @@ on:
merge_group: merge_group:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
# You can leverage Vercel Remote Caching with Turbo to speed up your builds # You can leverage Vercel Remote Caching with Turbo to speed up your builds
@@ -72,3 +72,15 @@ jobs:
# Only works if you set `reportOnFailure: true` in your vite config as specified above # Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always() if: always()
uses: davelosert/vitest-coverage-report-action@v2 uses: davelosert/vitest-coverage-report-action@v2
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm build

View File

@@ -1,7 +1,5 @@
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file # https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
name: "[Conventions] Semantic Commits"
name: Conventional Commits
on: on:
pull_request: pull_request:
@@ -12,5 +10,5 @@ jobs:
name: Conventional Commits name: Conventional Commits
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0 - uses: webiny/action-conventional-commits@v1.3.0

View File

@@ -1,4 +1,4 @@
name: "Lint PR" name: "[Conventions] Semantic PRs"
on: on:
pull_request_target: pull_request_target:
@@ -11,8 +11,7 @@ permissions:
pull-requests: read pull-requests: read
jobs: jobs:
main: validate-pull-request-title:
name: Validate PR title
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v5

View File

@@ -1,18 +1,25 @@
name: Docker image name: "[Deployment] Release"
on: on:
pull_request: push:
types:
- closed
branches: branches:
- main - main
workflow_dispatch: {} workflow_dispatch:
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
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
@@ -26,6 +33,7 @@ jobs:
node-version: [20] node-version: [20]
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,8 +45,9 @@ jobs:
uses: ietf-tools/semver-action@v1 uses: ietf-tools/semver-action@v1
with: with:
token: ${{ github.token }} token: ${{ github.token }}
branch: master branch: dev
- 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
@@ -52,16 +61,19 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: "pnpm" 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 }}
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
@@ -70,23 +82,27 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: | tags: |
type=raw,value=latest type=raw,value=alpha
type=raw,value=${{ steps.semver.outputs.next }} type=raw,value=early-adopters
# 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@v5 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64 # we currently do't build for linux/arm64 as it's really slow and we'll move to a self hosted runner for that or use the official github runner, once it's available
context: . context: .
push: false push: true
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 }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master

View File

@@ -0,0 +1,101 @@
name: "[Deployment] Automatic Weekly Release"
on:
schedule:
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
workflow_dispatch:
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
permissions:
contents: write
pull-requests: write
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
steps:
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: dev
- name: Create pull request
run: "gh pr create --title \"chore(release): automatic release ${{ steps.semver.outputs.next }}\" --body \"**This is an automatic release**.<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"
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
id: obtainApprovalToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Approve PR
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
- id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge
uses: "pascalgn/automerge-action@v0.16.3"
env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to master
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Discord Fail Notification
if: failure() && github.events.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"

View File

@@ -1,4 +1,4 @@
name: Approve Renovate PRs name: "[Dependency Updates] Auto Approve"
on: on:
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
@@ -6,17 +6,20 @@ on:
jobs: jobs:
approve-renovate-prs: approve-renovate-prs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Obtain token
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Install GitHub CLI - name: Install GitHub CLI
run: sudo apt-get install -y gh run: sudo apt-get install -y gh
- name: Approve Renovate PRs - name: Approve Renovate PRs
env: env:
GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: | run: |
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do 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" gh pr review $pr --approve --body "Automatically approved by GitHub Action"

4
.gitignore vendored
View File

@@ -14,8 +14,8 @@ coverage
out/ out/
next-env.d.ts next-env.d.ts
# nest.js # artifacts
apps/nestjs/dist packages/db/migrations/*/migrate.cjs
# nitro # nitro
.nitro/ .nitro/

2
.nvmrc
View File

@@ -1 +1 @@
20.14.0 20.16.0

View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:migration:mysql:generate" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:migration:mysql:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:migration:sqlite:generate" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:migration:sqlite:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/db_push.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:push" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:push" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/db_studio.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:studio" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:studio" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/dev.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/docker_dev.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="docker:dev" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="docker:dev" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/format.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="format" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="format" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/format_fix.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="format:fix" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="format:fix" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/test.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="test" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="test" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

13
.run/test_ui.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="test:ui" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="test:ui" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

18
.vscode/settings.json vendored
View File

@@ -4,13 +4,23 @@
"mode": "auto" "mode": "auto"
} }
], ],
"eslint.experimental.useFlatConfig": true,
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"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": [
"superjson", "cqmin",
"homarr", "homarr",
"jellyfin",
"superjson",
"trpc", "trpc",
"Umami" "Umami",
] "Sonarr"
} ],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
"i18n-ally.enabledParsers": ["ts"],
"i18n-ally.extract.keyMaxLength": 0,
"i18n-ally.keystyle": "flat"
}

View File

@@ -1,8 +1,9 @@
FROM node:20.13.1-alpine AS base FROM node:20.16.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 . .
@@ -35,6 +36,7 @@ RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/next-out/json/ . COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
RUN corepack enable pnpm && pnpm install sharp -w RUN corepack enable pnpm && pnpm install sharp -w
@@ -43,10 +45,12 @@ COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ . COPY --from=builder /app/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 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'
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
@@ -83,5 +87,6 @@ COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.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"]

View File

@@ -0,0 +1,13 @@
import baseConfig from "@homarr/eslint-config/base";
import nextjsConfig from "@homarr/eslint-config/nextjs";
import reactConfig from "@homarr/eslint-config/react";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [".next/**"],
},
...baseConfig,
...reactConfig,
...nextjsConfig,
];

View File

@@ -1,6 +1,6 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import "./src/env.mjs";
import "@homarr/auth/env.mjs"; import "@homarr/auth/env.mjs";
import "./src/env.mjs";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@@ -9,6 +9,16 @@ 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 }) => {
if (isServer) {
config.module.rules.push({
test: /\.node$/,
loader: "node-loader",
});
}
return config;
},
experimental: { experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
}, },

View File

@@ -7,7 +7,7 @@
"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",
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint", "lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"start": "pnpm with-env next start", "start": "pnpm with-env next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -18,6 +18,7 @@
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/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",
@@ -31,34 +32,40 @@
"@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.10.0", "@mantine/colors-generator": "^7.11.2",
"@mantine/hooks": "^7.10.0", "@mantine/core": "^7.11.2",
"@mantine/modals": "^7.10.0", "@mantine/hooks": "^7.11.2",
"@mantine/tiptap": "^7.10.0", "@mantine/modals": "^7.11.2",
"@mantine/tiptap": "^7.11.2",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.10.1", "@t3-oss/env-nextjs": "^0.11.0",
"@tanstack/react-query": "^5.40.0", "@tanstack/react-query": "^5.51.21",
"@tanstack/react-query-devtools": "^5.40.0", "@tanstack/react-query-devtools": "^5.51.21",
"@tanstack/react-query-next-experimental": "5.40.0", "@tanstack/react-query-next-experimental": "5.51.21",
"@trpc/client": "11.0.0-rc.377", "@tabler/icons-react": "^3.11.0",
"@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
"@trpc/server": "next", "@trpc/server": "next",
"@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": "^2.4.2", "chroma-js": "^2.6.0",
"dayjs": "^1.11.11", "clsx": "^2.1.1",
"dayjs": "^1.11.12",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"flag-icons": "^7.2.2", "flag-icons": "^7.2.3",
"glob": "^10.4.1", "glob": "^11.0.0",
"jotai": "^2.8.2", "jotai": "^2.9.1",
"next": "^14.2.3", "mantine-react-table": "2.0.0-beta.6",
"postcss-preset-mantine": "^1.15.0", "next": "^14.2.5",
"react": "18.3.1", "postcss-preset-mantine": "^1.17.0",
"react-dom": "18.3.1", "prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"sass": "^1.77.2", "react-simple-code-editor": "^0.14.1",
"sass": "^1.77.8",
"superjson": "2.2.1", "superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
@@ -67,22 +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.12.12", "@types/node": "^20.14.14",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.57.0", "eslint": "^9.8.0",
"prettier": "^3.2.5", "node-loader": "^2.0.0",
"tsx": "4.11.0", "prettier": "^3.3.3",
"typescript": "^5.4.5" "typescript": "^5.5.4"
},
"eslintConfig": {
"root": true,
"extends": [
"@homarr/eslint-config/base",
"@homarr/eslint-config/nextjs",
"@homarr/eslint-config/react"
]
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1 @@
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="m0 0h1024v1024h-1024z"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="none" xlink:href="#a"/><g transform="translate(70 21.00012)"><path d="m105.302 154.943 7.522 714.549c-60.173 7.522-105.30242-22.565-105.30242-82.737l-7.52158-594.205c0-188.03894 172.996-233.1684 278.298-157.9526l534.032 308.3846c75.216 52.651 90.259 150.431 52.651 218.125-7.521-52.651-30.086-82.737-75.216-112.823l-601.726-338.471c-45.129-30.0862-82.737-22.5646-82.737 45.13z" fill="#24292e"/><path d="m0 376.079c45.1295 15.043 90.259 7.521 127.867-15.043l616.769-361.036c37.608 52.651 30.087 105.302-15.043 135.388l-518.989 300.863c-75.216 37.608-172.9961 0-210.604-60.172z" fill="#24292e" transform="translate(60.17249 531.0214)"/><path d="m0 413.687 368.557-210.604-361.03543-203.083z" fill="#ffc230" transform="translate(240.6902 282.8092)"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.97895 0 0 .97895 -2.2026 -2.2026)"><g stroke="#443c3c" stroke-width="1.5"><circle cx="513" cy="513" r="510" fill="#eee"/><circle cx="513" cy="513" r="440" fill="#443c3c"/><circle cx="513" cy="513" r="387" fill="#8e2222"/></g><g stroke-width="1.5"><circle cx="513" cy="513" r="378" fill="#eee" stroke="#8e2222"/><circle cx="511.67" cy="514.33" r="265" fill="#443c3c" stroke="#443c3c"/></g><g stroke="#8e2222"><path d="m176.71 682.24-5.71-356.67c0.634-53.106 17.5-47.829 30.454-49.405 198.58 10.83 270.91 71.252 275.35 73.499 13.323 5.018 20.937 31.782 20.302 31.123 0.634 0.658 4.441 420.6 3.807 419.94 3.172 22.455-13.323 21.002-13.958 20.343-124.99-98.152-297.56-122.85-298.19-123.51-12.055-0.795-12.055-15.326-12.055-15.326zm670.08 0.82 5.711-357.54c-0.635-53.236-17.501-47.946-30.456-49.526-198.6 10.857-270.93 71.426-275.38 73.679-13.325 5.03-20.939 31.859-20.304 31.199-0.635 0.66-4.442 421.63-3.807 420.97-3.173 22.51 13.325 21.053 13.959 20.393 125-98.392 297.58-123.15 298.22-123.81 12.056-0.797 12.056-15.363 12.056-15.363z" fill="#eee" stroke-width="10"/><path d="m174.14 739.57-5.802-356.67c0.645-53.106 17.782-47.829 30.945-49.405 201.79 10.83 275.28 71.252 279.8 73.499 13.539 5.018 21.275 31.782 20.63 31.123 0.645 0.658 4.513 420.6 3.868 419.94 3.224 22.455-13.539 21.002-14.183 20.343-127-98.152-302.36-122.85-303.01-123.51-12.249-0.795-12.249-15.326-12.249-15.326zm675.22 0.49 5.803-357.54c-0.645-53.236-17.784-47.946-30.948-49.526-201.81 10.857-275.31 71.426-279.82 73.679-13.54 5.03-21.277 31.859-20.632 31.199-0.645 0.66-4.513 421.63-3.869 420.97-3.224 22.51 13.54 21.053 14.184 20.393 127.02-98.392 302.39-123.15 303.03-123.81 12.25-0.797 12.25-15.363 12.25-15.363z" fill="#8e2222" stroke-width="5"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g clip-rule="evenodd"><path fill="#eee" fill-rule="evenodd" d="M47.978 24c0 6.602-2.331 12.26-6.993 16.974a3.773 3.773 0 0 1-.52.509 20.53 20.53 0 0 1-2.435 2.047C33.988 46.51 29.318 48 24.022 48c-5.304 0-9.966-1.49-13.986-4.47a21.726 21.726 0 0 1-2.988-2.556c-3.622-3.6-5.846-7.783-6.672-12.548-.162-.93-.27-1.874-.32-2.833a38.27 38.27 0 0 1 0-3.197c0-.052.014-.104.044-.155.346-5.887 2.662-10.973 6.948-15.259C11.762 2.327 17.42 0 24.022 0c6.624 0 12.279 2.327 16.963 6.982 4.662 4.743 6.993 10.416 6.993 17.018z"/><path fill="#3a3f51" fill-rule="evenodd" d="m43.098 9.405-4.957 4.957c-2.899 2.899-3.153 5.422-3.153 9.87 0 3.97.63 7.602 3.585 10.556 2.156 2.157 4.204 4.194 4.204 4.194a27.962 27.962 0 0 1-1.792 1.992 3.773 3.773 0 0 1-.52.509 20.05 20.05 0 0 1-1.749 1.538l-3.883-3.884c-3.452-3.452-6.196-3.784-10.756-3.784-4.375 0-7.352.403-10.556 3.607a2715.831 2715.831 0 0 0-4.105 4.116 21.196 21.196 0 0 1-2.368-2.102 27.739 27.739 0 0 1-1.737-1.903s2.168-2.18 4.238-4.25c3.066-3.065 3.563-6.62 3.563-10.589 0-3.872-.636-7.485-3.452-10.301C7.705 11.975 5 9.284 5 9.284a25.954 25.954 0 0 1 2.047-2.302A29.761 29.761 0 0 1 9.04 5.201l4.504 4.503c2.877 2.877 6.565 3.618 10.533 3.618 4.087 0 7.763-.791 10.756-3.784 1.84-1.841 4.27-4.26 4.27-4.26a25.168 25.168 0 0 1 1.882 1.704c.767.782 1.471 1.59 2.113 2.423z"/><path fill="#0cf" fill-rule="evenodd" d="M17.438 25.228a6.986 6.986 0 0 1-.1-1.228c0-.155.005-.303.012-.443 0-.014.004-.029.011-.044.096-1.63.738-3.039 1.925-4.227 1.306-1.29 2.874-1.936 4.703-1.936 1.837 0 3.404.645 4.703 1.936 1.29 1.313 1.936 2.884 1.936 4.714s-.645 3.397-1.936 4.703c-.045.051-.093.1-.144.143a6.056 6.056 0 0 1-.675.565c-1.121.826-2.416 1.239-3.884 1.239s-2.759-.413-3.873-1.24a5.818 5.818 0 0 1-.83-.707c-1.003-.996-1.619-2.155-1.848-3.475z"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width=".4426" d="m34.943 13.223-3.32 3.242M6.834 7.198l9.044 9.012M34.6 34.855l6.154 6.369m.41-34.056-6.22 6.056M7.18 41.107l6.053-6.063"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width="1.5491" d="m34.943 13.223-3.75 3.806m-18.12-3.617 3.806 3.795m-3.662 17.854 3.706-3.851m13.705-.309 3.99 3.971"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100px" height="54px" viewBox="0 0 100 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Logo tvdb</title>
<g id="Logo-tvdb" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M0,5.09590006 C0,1.81024006 2.9636,-0.441498938 6.46228,0.0733078623 L6.46228,0.0733078623 L52.10124,6.03470006 C54.15254,6.33652006 55.78724,8.54666006 55.78724,10.9536001 L55.78724,10.9536001 L55.78654,17.1835001 C51.94104,19.7605001 49.42044,24.0737001 49.42044,28.9596001 C49.42044,33.8924001 51.87974,38.1680001 55.78724,40.7361001 L55.78724,40.7361001 L55.78724,43.4756001 C55.78724,45.8825001 54.15254,48.0927001 52.10124,48.3945001 L52.10124,48.3945001 L11.60314,53.9266001 C8.10444,54.4417001 5.14084,52.1897001 5.14084,48.9040001 L5.14084,48.9040001 Z M19.68044,10.8218001 L13.66114,10.8218001 L13.66114,18.7064001 L9.84244,18.7064001 L9.84244,23.2621001 L13.66114,23.2621001 L13.66114,32.0227001 C13.4846091,37.5274601 15.6467584,39.9923503 20.6149401,40.0386142 L25.25134,40.0387001 L25.25134,35.4830001 L22.87064,35.4830001 C20.17484,35.3516001 19.59134,34.5631001 19.68074,31.0149001 L19.68074,23.2617001 L27.08014,23.2617001 L33.93424,40.0384001 L40.40294,40.0384001 L49.83694,18.7061001 L43.45734,18.7061001 L37.34794,33.3806001 L31.77694,18.7064001 L19.68044,18.7064001 L19.68044,10.8218001 Z" id="Combined-Shape" fill="#6CD591" fill-rule="nonzero"></path>
<path d="M88.60974,18.2771001 C92.51784,18.2771001 95.12314,19.2407001 97.09994,21.4310001 C98.71734,23.1831001 99.57074,25.7677001 99.57074,28.6584001 C99.57074,32.8634001 97.86394,36.1487001 94.76414,38.0323001 C92.74234,39.2590001 90.99054,39.6094001 87.03734,39.6094001 L77.24404,39.6094001 L77.24404,10.3925001 L83.26404,10.3925001 L83.26404,18.2771001 L88.60974,18.2771001 Z M83.26404,35.0537001 L87.71094,35.0537001 C91.26004,35.0537001 93.41634,32.6884001 93.41634,28.8334001 C93.41634,24.8035001 91.52964,22.8324001 87.71094,22.8324001 L83.26404,22.8324001 L83.26404,35.0537001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
<path d="M68.01354,10.3925001 L74.03354,10.3925001 L74.03354,39.6094001 L63.65594,39.6094001 C59.43354,39.6094001 57.41174,38.9962001 55.25524,37.1126001 C53.05394,35.1416001 51.93124,32.3384001 51.93124,28.7898001 C51.93124,25.1102001 53.14404,22.3070001 55.70494,20.2481001 C57.32204,18.9342001 59.52364,18.2771001 62.35354,18.2771001 L68.01384,18.2771001 L68.01384,10.3925001 L68.01354,10.3925001 Z M68.01354,22.8327001 L63.65594,22.8327001 C60.15224,22.8327001 58.04064,25.0667001 58.04064,28.7898001 C58.04064,32.6884001 60.19654,35.0537001 63.65594,35.0537001 L68.01354,35.0537001 L68.01354,22.8327001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path fill="#31BEEC" d="M90 38.197v19.137L48.942 80.999V61.864z"/><path d="M41.086 61.863V81L0 57.333V38.197l18.566 10.687c.02.016.043.03.067.04l22.453 12.94Z" fill="#0095D5"/><path fill="#AEADAE" d="m61.621 45.506-16.607 9.576-16.622-9.576 16.622-9.575z"/><path fill="#0095D5" d="M86.086 31.416 69.464 40.99 48.942 29.15V10z"/><path fill="#31BEEC" d="M41.086 10v19.15l-20.55 11.827-16.621-9.561z"/></g></svg>

After

Width:  |  Height:  |  Size: 484 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="100%" x2="0" y1="0" y2="100%" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8d30"/><stop offset="1" stop-color="#e32929"/></linearGradient></defs><circle cx="50%" cy="50%" r="50%" fill="url(#a)"/><path fill="#fff" d="M246.6 200.8h18.7v110.6h-18.7zm-182.3 0H83v110.7H64.3zm91.1 123.9h18.7V367h-18.7zm-45.7-47.5h18.7v68.5h-18.7zm91.2 0h18.6v68.4h-18.6zm228.2-76.5h18.7v110.7h-18.7zM338 145.5h18.7v42.3H338zm45.7 21.2h18.7v68.2h-18.7zm-91.5 0h18.7v68.1h-18.7z"/></svg>

After

Width:  |  Height:  |  Size: 577 B

View File

@@ -2,6 +2,7 @@ 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";
@@ -19,6 +20,8 @@ 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();

View File

@@ -1,75 +1,153 @@
"use client"; "use client";
import { useState } from "react"; import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core"; import { Button, Divider, PasswordInput, Stack, TextInput } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/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 type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
export const LoginForm = () => { import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface LoginFormProps {
providers: string[];
oidcClientName: string;
isOidcAutoLoginEnabled: boolean;
callbackUrl: string;
}
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user"); const t = useScopedI18n("user");
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string>();
const form = useZodForm(validation.user.signIn, { const form = useZodForm(validation.user.signIn, {
initialValues: { initialValues: {
name: "", name: "",
password: "", password: "",
credentialType: "basic",
}, },
}); });
const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => { const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
setIsLoading(true);
setError(undefined);
await signIn("credentials", {
...values,
redirect: false,
callbackUrl: "/",
})
.then((response) => {
if (!response?.ok || response.error) {
throw response?.error;
}
showSuccessNotification({ const onSuccess = useCallback(
title: t("action.login.notification.success.title"), async (response: Awaited<ReturnType<typeof signIn>>) => {
message: t("action.login.notification.success.message"), if (response && (!response.ok || response.error)) {
}); // eslint-disable-next-line @typescript-eslint/only-throw-error
router.push("/"); throw response.error;
}) }
.catch((error: Error | string) => {
setIsLoading(false); showSuccessNotification({
setError(error.toString()); title: t("action.login.notification.success.title"),
showErrorNotification({ message: t("action.login.notification.success.message"),
title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
});
}); });
};
// Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
if (response) {
await revalidatePathActionAsync("/");
router.push(callbackUrl);
}
},
[t, router, callbackUrl],
);
const onError = useCallback(() => {
setIsPending(false);
showErrorNotification({
title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
autoClose: 10000,
});
}, [t]);
const signInAsync = useCallback(
async (provider: string, options?: Parameters<typeof signIn>[1]) => {
setIsPending(true);
await signIn(provider, {
...options,
redirect: false,
callbackUrl: new URL(callbackUrl, window.location.href).href,
})
.then(onSuccess)
.catch(onError);
},
[setIsPending, onSuccess, onError, callbackUrl],
);
const isLoginInProgress = useRef(false);
useEffect(() => {
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
isLoginInProgress.current = true;
void signInAsync("oidc");
}
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <Stack gap="lg">
<Stack gap="lg"> {credentialInputsVisible && (
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} /> <>
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} /> <form onSubmit={form.onSubmit((credentials) => void signInAsync("credentials", credentials))}>
<Button type="submit" fullWidth loading={isLoading}> <Stack gap="lg">
{t("action.login.label")} <TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
</Button> <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
</Stack>
</form>
{error && ( {providers.includes("credentials") && (
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red"> <SubmitButton isPending={isPending} form={form} credentialType="basic">
{error} {t("action.login.label")}
</Alert> </SubmitButton>
)} )}
{providers.includes("ldap") && (
<SubmitButton isPending={isPending} form={form} credentialType="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>
)}
</Stack>
</Stack> </Stack>
); );
}; };
interface SubmitButtonProps {
isPending: boolean;
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
credentialType: "basic" | "ldap";
}
const SubmitButton = ({ isPending, form, credentialType, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().credentialType === credentialType;
return (
<Button
type="submit"
name={credentialType}
fullWidth
onClick={() => form.setFieldValue("credentialType", credentialType)}
loading={isPending && isCurrentProviderActive}
disabled={isPending && !isCurrentProviderActive}
>
{children}
</Button>
);
};
type FormType = z.infer<typeof validation.user.signIn>;

View File

@@ -1,11 +1,26 @@
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";
export default async function Login() { interface LoginProps {
searchParams: {
redirectAfterLogin?: string;
};
}
export default async function Login({ searchParams }: LoginProps) {
const session = await auth();
if (session) {
redirect(searchParams.redirectAfterLogin ?? "/");
}
const t = await getScopedI18n("user.page.login"); const t = await getScopedI18n("user.page.login");
return ( return (
@@ -21,7 +36,12 @@ export default async function Login() {
</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.redirectAfterLogin ?? "/"}
/>
</Card> </Card>
</Stack> </Stack>
</Center> </Center>

View File

@@ -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) => {

View File

@@ -33,6 +33,7 @@ export const generateColors = (hex: string) => {
return rgbaColors.map((color) => { return rgbaColors.map((color) => {
return ( return (
"#" + "#" +
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color color
.split("(")[1]! .split("(")[1]!
.replaceAll(" ", "") .replaceAll(" ", "")

View File

@@ -1,101 +0,0 @@
"use client";
import { useState } from "react";
import { Group, Stack, Tabs } from "@mantine/core";
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { CountBadge } from "@homarr/ui";
import type { Board } from "../../_types";
import { GroupsForm } from "./_access/group-access";
import { InheritTable } from "./_access/inherit-access";
import { UsersForm } from "./_access/user-access";
interface Props {
board: Board;
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
}
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
id: board.id,
},
{
initialData: initialPermissions,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const [counts, setCounts] = useState({
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
group: initialPermissions.groupPermissions.length,
});
return (
<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>
);
};

View File

@@ -1,13 +0,0 @@
import type { BoardPermission } from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
export interface BoardAccessFormType {
items: {
itemId: string;
permission: BoardPermission;
}[];
}
export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>();
export type OnCountChange = (callback: (prev: number) => number) => void;

View File

@@ -1,57 +0,0 @@
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardAccessDisplayRow } from "./board-access-table-rows";
import { GroupItemContent } from "./group-access";
export interface InheritTableProps {
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
}
const mapPermissions = {
"board-full-access": "board-full",
"board-modify-all": "board-change",
"board-view-all": "board-view",
} satisfies Partial<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>
);
};

View File

@@ -1,136 +0,0 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { Board } from "../../../_types";
import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
import type { BoardAccessFormType, OnCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
export interface FormProps {
board: Pick<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}
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;
}

View File

@@ -0,0 +1,69 @@
"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,
},
}}
/>
);
};

View File

@@ -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 })
: { : {
userPermissions: [], users: [],
groupPermissions: [], groups: [],
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}>
<AccessSettingsContent board={board} initialPermissions={permissions} /> <BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding> <AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent /> <DangerZoneSettingsContent />

View File

@@ -6,7 +6,6 @@ export const composeWrappers = (
wrappers: React.FunctionComponent<PropsWithChildren>[], wrappers: React.FunctionComponent<PropsWithChildren>[],
): React.FunctionComponent<PropsWithChildren> => { ): React.FunctionComponent<PropsWithChildren> => {
return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => { return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
// eslint-disable-next-line react/display-name
return (props) => ( return (props) => (
<Current> <Current>
<Acc {...props} /> <Acc {...props} />

View File

@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
import "@homarr/ui/styles.css"; 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 "~/styles/scroll-area.scss";
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core"; import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";

View File

@@ -1,5 +1,4 @@
.bannerContainer { .bannerContainer {
padding: 3rem;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
@@ -20,7 +19,7 @@
transform: translateY(0); transform: translateY(0);
} }
100% { 100% {
transform: translateY(-50%); transform: translateY(-50.8%);
} }
} }

View File

@@ -38,17 +38,17 @@ export const HeroBanner = () => {
const gridSpan = 12 / countIconGroups; const gridSpan = 12 / countIconGroups;
return ( return (
<Box className={classes.bannerContainer} bg="dark.6" pos="relative"> <Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
<Stack gap={0}> <Stack gap={0}>
<Title order={2} c="dimmed"> <Title fz={{ base: "h4", md: "h2" }} c="dimmed">
Welcome back to your Welcome back to your
</Title> </Title>
<Group gap="xs"> <Group gap="xs" wrap="nowrap">
<Image src="/logo/logo.png" w={40} h={40} /> <Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
<Title>Homarr Dashboard</Title> <Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
</Group> </Group>
</Stack> </Stack>
<Box className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute"> <Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<Grid> <Grid>
{Array(countIconGroups) {Array(countIconGroups)
.fill(0) .fill(0)

View File

@@ -21,11 +21,12 @@ import { setStaticParamsLocale } from "next-international/server";
import { getScopedI18n, getStaticParams } from "@homarr/translation/server"; import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
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 contributorsData from "../../../../../../../static-data/contributors.json"; import contributorsData from "../../../../../../../static-data/contributors.json";
import translatorsData from "../../../../../../../static-data/translators.json"; import translatorsData from "../../../../../../../static-data/translators.json";
import logo from "../../../../../public/logo/logo.png";
import classes from "./about.module.css"; import classes from "./about.module.css";
export async function generateMetadata() { export async function generateMetadata() {
@@ -48,9 +49,10 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
const attributes = await getPackageAttributesAsync(); const attributes = await getPackageAttributesAsync();
return ( return (
<div> <div>
<DynamicBreadcrumb />
<Center w="100%"> <Center w="100%">
<Group py="lg"> <Group py="lg">
<Image src={logo} width={100} height={100} alt="" /> <Image src={homarrLogoPath} width={100} height={100} alt="" />
<Stack gap={0}> <Stack gap={0}>
<Title order={1} tt="uppercase"> <Title order={1} tt="uppercase">
Homarr Homarr

View File

@@ -49,7 +49,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]); }, [app, mutate, t, openConfirmModal]);
return ( return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app"> <ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
); );

View File

@@ -36,10 +36,10 @@ export const AppForm = (props: AppFormProps) => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" /> <TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} /> <IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label="Description" /> <Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
<TextInput {...form.getInputProps("href")} label="URL" /> <TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<Group justify="end"> <Group justify="end">
<Button variant="default" component={Link} href="/manage/apps"> <Button variant="default" component={Link} href="/manage/apps">

View File

@@ -3,6 +3,7 @@ import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppEditForm } from "./_app-edit-form"; import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps { interface AppEditPageProps {
@@ -14,11 +15,14 @@ export default async function AppEditPage({ params }: AppEditPageProps) {
const t = await getI18n(); const t = await getI18n();
return ( return (
<Container> <>
<Stack> <DynamicBreadcrumb dynamicMappings={new Map([[params.id, app.name]])} nonInteractable={["edit"]} />
<Title>{t("app.page.edit.title")}</Title> <Container>
<AppEditForm app={app} /> <Stack>
</Stack> <Title>{t("app.page.edit.title")}</Title>
</Container> <AppEditForm app={app} />
</Stack>
</Container>
</>
); );
} }

View File

@@ -1,14 +1,22 @@
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form"; import { AppNewForm } from "./_app-new-form";
export default function AppNewPage() { export default async function AppNewPage() {
const t = await getI18n();
return ( return (
<Container> <>
<Stack> <DynamicBreadcrumb />
<Title>New app</Title> <Container>
<AppNewForm /> <Stack>
</Stack> <Title>{t("app.page.create.title")}</Title>
</Container> <AppNewForm />
</Stack>
</Container>
</>
); );
} }

View File

@@ -1,36 +1,29 @@
import Link from "next/link"; import Link from "next/link";
import { import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
ActionIcon,
ActionIconGroup,
Anchor,
Avatar,
Button,
Card,
Container,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react"; 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 { getI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppDeleteButton } from "./_app-delete-button"; import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() { export default async function AppsPage() {
const apps = await api.app.all(); const apps = await api.app.all();
const t = await getScopedI18n("app");
return ( return (
<Container> <ManageContainer>
<DynamicBreadcrumb />
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>Apps</Title> <Title>{t("page.list.title")}</Title>
<Button component={Link} href="/manage/apps/new"> <MobileAffixButton component={Link} href="/manage/apps/new">
New app {t("page.create.title")}
</Button> </MobileAffixButton>
</Group> </Group>
{apps.length === 0 && <AppNoResults />} {apps.length === 0 && <AppNoResults />}
{apps.length > 0 && ( {apps.length > 0 && (
@@ -41,7 +34,7 @@ export default async function AppsPage() {
</Stack> </Stack>
)} )}
</Stack> </Stack>
</Container> </ManageContainer>
); );
} }
@@ -49,10 +42,12 @@ interface AppCardProps {
app: RouterOutputs["app"]["all"][number]; app: RouterOutputs["app"]["all"][number];
} }
const AppCard = ({ app }: AppCardProps) => { const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
return ( return (
<Card> <Card>
<Group justify="space-between"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap"> <Group align="top" justify="start" wrap="nowrap">
<Avatar <Avatar
size="sm" size="sm"
@@ -65,14 +60,16 @@ const AppCard = ({ app }: AppCardProps) => {
}} }}
/> />
<Stack gap={0}> <Stack gap={0}>
<Text fw={500}>{app.name}</Text> <Text fw={500} lineClamp={1}>
{app.name}
</Text>
{app.description && ( {app.description && (
<Text size="sm" c="gray.6"> <Text size="sm" c="gray.6" lineClamp={4}>
{app.description} {app.description}
</Text> </Text>
)} )}
{app.href && ( {app.href && (
<Anchor href={app.href} size="sm" w="min-content"> <Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
{app.href} {app.href}
</Anchor> </Anchor>
)} )}
@@ -85,7 +82,7 @@ const AppCard = ({ app }: AppCardProps) => {
href={`/manage/apps/edit/${app.id}`} href={`/manage/apps/edit/${app.id}`}
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label="Edit app" aria-label={t("page.edit.title")}
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import { Button } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react"; import { IconCategoryPlus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -10,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal"; import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
interface CreateBoardButtonProps { interface CreateBoardButtonProps {
boardNames: string[]; boardNames: string[];
@@ -37,8 +37,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
}, [mutateAsync, boardNames, openModal]); }, [mutateAsync, boardNames, openModal]);
return ( return (
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}> <MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
{t("management.page.board.action.new.label")} {t("management.page.board.action.new.label")}
</Button> </MobileAffixButton>
); );
}; };

View File

@@ -10,6 +10,7 @@ import {
Group, Group,
Menu, Menu,
MenuTarget, MenuTarget,
Stack,
Text, Text,
Title, Title,
Tooltip, Tooltip,
@@ -22,6 +23,8 @@ import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button"; import { CreateBoardButton } from "./_components/create-board-button";
@@ -31,20 +34,23 @@ export default async function ManageBoardsPage() {
const boards = await api.board.getAllBoards(); const boards = await api.board.getAllBoards();
return ( return (
<> <ManageContainer>
<Group justify="space-between"> <DynamicBreadcrumb />
<Title mb="md">{t("title")}</Title> <Stack>
<CreateBoardButton boardNames={boards.map((board) => board.name)} /> <Group justify="space-between">
</Group> <Title mb="md">{t("title")}</Title>
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
</Group>
<Grid> <Grid mb={{ base: "xl", md: 0 }}>
{boards.map((board) => ( {boards.map((board) => (
<GridCol span={{ base: 12, md: 6, xl: 4 }} key={board.id}> <GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} /> <BoardCard board={board} />
</GridCol> </GridCol>
))} ))}
</Grid> </Grid>
</> </Stack>
</ManageContainer>
); );
} }
@@ -83,7 +89,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
{board.creator && ( {board.creator && (
<Group gap="xs"> <Group gap="xs">
<UserAvatar user={board.creator} size="sm" /> <UserAvatar user={board.creator} size="sm" />
<Text>{board.creator?.name}</Text> <Text>{board.creator.name}</Text>
</Group> </Group>
)} )}
</Group> </Group>

View File

@@ -0,0 +1,61 @@
"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}
/>
);
};

View File

@@ -12,7 +12,7 @@ import type { RouterOutputs } from "@homarr/api";
import { integrationSecretKindObject } from "@homarr/definitions"; import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons"; import { integrationSecretIcons } from "./integration-secret-icons";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);

View File

@@ -7,7 +7,7 @@ import { integrationSecretKindObject } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons"; import { integrationSecretIcons } from "./integration-secret-icons";
interface IntegrationSecretInputProps { interface IntegrationSecretInputProps {
withAsterisk?: boolean; withAsterisk?: boolean;
@@ -50,7 +50,7 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
<PasswordInput <PasswordInput
{...props} {...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)} label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t(`integration.secrets.secureNotice`)} description={t("integration.secrets.secureNotice")}
w="100%" w="100%"
leftSection={<Icon size={20} stroke={1.5} />} leftSection={<Icon size={20} stroke={1.5} />}
/> />

View File

@@ -56,7 +56,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
}, },
}); });
}} }}
aria-label="Delete integration" aria-label={t("title")}
> >
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>

View File

@@ -1,129 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { Alert, Anchor, Group, Loader } from "@mantine/core";
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface UseTestConnectionDirtyProps {
defaultDirty: boolean;
initialFormValue: {
url: string;
secrets: { kind: string; value: string | null }[];
};
}
export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
const [isDirty, setIsDirty] = useState(defaultDirty);
const prevFormValueRef = useRef(initialFormValue);
return {
onValuesChange: (values: typeof initialFormValue) => {
if (isDirty) return;
// If relevant values changed, set dirty
if (
prevFormValueRef.current.url !== values.url ||
!prevFormValueRef.current.secrets
.map((secret) => secret.value)
.every((secretValue, index) => values.secrets[index]?.value === secretValue)
) {
setIsDirty(true);
return;
}
// If relevant values changed back to last tested, set not dirty
setIsDirty(false);
},
isDirty,
removeDirty: () => {
prevFormValueRef.current = initialFormValue;
setIsDirty(false);
},
};
};
interface TestConnectionProps {
isDirty: boolean;
removeDirty: () => void;
integration: RouterInputs["integration"]["testConnection"] & { name: string };
}
export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
return (
<Group>
<Anchor
type="button"
component="button"
onClick={async () => {
await mutateAsync(integration, {
onSuccess: () => {
removeDirty();
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
},
onError: (error) => {
if (error.data?.zodError?.fieldErrors.url) {
showErrorNotification({
title: t("notification.invalidUrl.title"),
message: t("notification.invalidUrl.message"),
});
return;
}
if (error.message === "SECRETS_NOT_DEFINED") {
showErrorNotification({
title: t("notification.notAllSecretsProvided.title"),
message: t("notification.notAllSecretsProvided.message"),
});
return;
}
showErrorNotification({
title: t("notification.commonError.title"),
message: t("notification.commonError.message"),
});
},
});
}}
>
{t("action")}
</Anchor>
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
</Group>
);
};
interface TestConnectionIconProps {
isDirty: boolean;
isPending: boolean;
isSuccess: boolean;
isError: boolean;
size: number;
}
const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
if (isPending) return <Loader color="blue" size={size} />;
if (isDirty) return null;
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
return null;
};
export const TestConnectionNoticeAlert = () => {
const t = useI18n();
return (
<Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
{t("integration.testConnection.alertNotice")}
</Alert>
);
};

View File

@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/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 { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -15,9 +16,8 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_integration-secret-card"; import { SecretCard } from "../../_components/secrets/integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs"; import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
interface EditIntegrationForm { interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
@@ -30,30 +30,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
getAllSecretKindOptions(integration.kind).find((secretKinds) => getAllSecretKindOptions(integration.kind).find((secretKinds) =>
integration.secrets.every((secret) => secretKinds.includes(secret.kind)), integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(integration.kind); ) ?? getDefaultSecretKinds(integration.kind);
const initialFormValues = {
name: integration.name,
url: integration.url,
secrets: secretsKinds.map((kind) => ({
kind,
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})),
};
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
defaultDirty: true,
initialFormValue: initialFormValues,
});
const router = useRouter(); const router = useRouter();
const form = useZodForm(validation.integration.update.omit({ id: true }), { const form = useZodForm(validation.integration.update.omit({ id: true }), {
initialValues: initialFormValues, initialValues: {
onValuesChange, name: integration.name,
url: integration.url,
secrets: secretsKinds.map((kind) => ({
kind,
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})),
},
}); });
const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return;
await mutateAsync( await mutateAsync(
{ {
id: integration.id, id: integration.id,
@@ -71,7 +64,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}); });
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
}, },
onError: () => { onError: (error) => {
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message: testConnectionError.message
? testConnectionError.message
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
showErrorNotification({ showErrorNotification({
title: t("integration.page.edit.notification.error.title"), title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"), message: t("integration.page.edit.notification.error.message"),
@@ -84,8 +89,6 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack> <Stack>
<TestConnectionNoticeAlert />
<TextInput withAsterisk label={t("integration.field.name.label")} {...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")} />
@@ -95,20 +98,21 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
{secretsKinds.map((kind, index) => ( {secretsKinds.map((kind, index) => (
<SecretCard <SecretCard
key={kind} key={kind}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secret={secretsMap.get(kind)!} secret={secretsMap.get(kind)!}
onCancel={() => onCancel={() =>
new Promise((res) => { new Promise((resolve) => {
// When nothing changed, just close the secret card // When nothing changed, just close the secret card
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) { if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
return res(true); return resolve(true);
} }
openConfirmModal({ openConfirmModal({
title: t("integration.secrets.reset.title"), title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"), children: t("integration.secrets.reset.message"),
onCancel: () => res(false), onCancel: () => resolve(false),
onConfirm: () => { onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? ""); form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
res(true); resolve(true);
}, },
}); });
}) })
@@ -125,24 +129,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
</Stack> </Stack>
</Fieldset> </Fieldset>
<Group justify="space-between" align="center"> <Group justify="end" align="center">
<TestConnection <Button variant="default" component={Link} href="/manage/integrations">
isDirty={isDirty} {t("common.action.backToOverview")}
removeDirty={removeDirty} </Button>
integration={{ <Button type="submit" loading={isPending}>
id: integration.id, {t("integration.testConnection.action.edit")}
kind: integration.kind, </Button>
...form.values,
}}
/>
<Group>
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>
{t("common.action.save")}
</Button>
</Group>
</Group> </Group>
</Stack> </Stack>
</form> </form>

View File

@@ -1,9 +1,11 @@
import { Container, Group, Stack, Title } from "@mantine/core"; import { Container, Fieldset, 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 { getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { IntegrationAvatar } from "../../_integration-avatar"; import { IntegrationAvatar } from "../../_integration-avatar";
import { EditIntegrationForm } from "./_integration-edit-form"; import { EditIntegrationForm } from "./_integration-edit-form";
@@ -12,18 +14,28 @@ interface EditIntegrationPageProps {
} }
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) { export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
const t = await getScopedI18n("integration.page.edit"); const editT = 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 });
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
return ( return (
<Container> <>
<Stack> <DynamicBreadcrumb dynamicMappings={new Map([[params.id, integration.name]])} nonInteractable={["edit"]} />
<Group align="center"> <Container>
<IntegrationAvatar kind={integration.kind} size="md" /> <Stack>
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title> <Group align="center">
</Group> <IntegrationAvatar kind={integration.kind} size="md" />
<EditIntegrationForm integration={integration} /> <Title>{editT("title", { name: getIntegrationName(integration.kind) })}</Title>
</Stack> </Group>
</Container> <EditIntegrationForm integration={integration} />
<Title order={2}>{t("permission.title")}</Title>
<Fieldset>
<IntegrationAccessSettings integration={integration} initialPermissions={integrationPermissions} />
</Fieldset>
</Stack>
</Container>
</>
); );
} }

View File

@@ -3,7 +3,7 @@
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core"; import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions";
@@ -25,7 +25,7 @@ export const IntegrationCreateDropdownContent = () => {
); );
return ( return (
<Stack> <Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
<TextInput <TextInput
leftSection={<IconSearch stroke={1.5} size={20} />} leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")} placeholder={t("integration.page.list.search")}
@@ -47,6 +47,6 @@ export const IntegrationCreateDropdownContent = () => {
) : ( ) : (
<Menu.Item disabled>{t("common.noResults")}</Menu.Item> <Menu.Item disabled>{t("common.noResults")}</Menu.Item>
)} )}
</Stack> </Flex>
); );
}; };

View File

@@ -3,20 +3,21 @@
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 { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core"; import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } 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 { 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 type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_integration-secret-inputs"; import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
import { revalidatePathActionAsync } from "../../../../revalidatePathAction"; import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps { interface NewIntegrationFormProps {
@@ -28,27 +29,20 @@ interface NewIntegrationFormProps {
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = {
name: searchParams.name ?? "",
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
};
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
defaultDirty: true,
initialFormValue: initialFormValues,
});
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: initialFormValues, initialValues: {
onValuesChange, name: searchParams.name ?? "",
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
},
}); });
const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmitAsync = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return;
await mutateAsync( await mutateAsync(
{ {
kind: searchParams.kind, kind: searchParams.kind,
@@ -62,7 +56,19 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
}); });
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
}, },
onError: () => { onError: (error) => {
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message: testConnectionError.message
? testConnectionError.message
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
showErrorNotification({ showErrorNotification({
title: t("integration.page.create.notification.error.title"), title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"), message: t("integration.page.create.notification.error.message"),
@@ -75,8 +81,6 @@ 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>
<TestConnectionNoticeAlert />
<TextInput withAsterisk label={t("integration.field.name.label")} {...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")} />
@@ -92,28 +96,21 @@ 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>
<Group justify="space-between" align="center"> <Group justify="end" align="center">
<TestConnection <Button variant="default" component={Link} href="/manage/integrations">
isDirty={isDirty} {t("common.action.backToOverview")}
removeDirty={removeDirty} </Button>
integration={{ <Button type="submit" loading={isPending}>
id: null, {t("integration.testConnection.action.create")}
kind: searchParams.kind, </Button>
...form.values,
}}
/>
<Group>
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>
{t("common.action.create")}
</Button>
</Group>
</Group> </Group>
</Stack> </Stack>
</form> </form>
@@ -129,12 +126,20 @@ 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: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "), label:
value: kinds.join("-"), kinds.length === 0
? 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,

View File

@@ -7,6 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server";
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 { IntegrationAvatar } from "../_integration-avatar"; import { IntegrationAvatar } from "../_integration-avatar";
import { NewIntegrationForm } from "./_integration-new-form"; import { NewIntegrationForm } from "./_integration-new-form";
@@ -17,24 +18,28 @@ interface NewIntegrationPageProps {
} }
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) { export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind); const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
notFound(); notFound();
} }
const t = await getScopedI18n("integration.page.create"); const tCreate = await getScopedI18n("integration.page.create");
const currentKind = result.data; const currentKind = result.data;
return ( return (
<Container> <>
<Stack> <DynamicBreadcrumb />
<Group align="center"> <Container>
<IntegrationAvatar kind={currentKind} size="md" /> <Stack>
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title> <Group align="center">
</Group> <IntegrationAvatar kind={currentKind} size="md" />
<NewIntegrationForm searchParams={searchParams} /> <Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
</Stack> </Group>
</Container> <NewIntegrationForm searchParams={searchParams} />
</Stack>
</Container>
</>
); );
} }

View File

@@ -1,3 +1,5 @@
import { Fragment } from "react";
import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
AccordionControl, AccordionControl,
@@ -5,9 +7,11 @@ import {
AccordionPanel, AccordionPanel,
ActionIcon, ActionIcon,
ActionIconGroup, ActionIconGroup,
Affix,
Anchor, Anchor,
Box,
Button, Button,
Container, Divider,
Group, Group,
Menu, Menu,
MenuDropdown, MenuDropdown,
@@ -22,7 +26,7 @@ import {
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconChevronDown, IconPencil } from "@tabler/icons-react"; 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";
@@ -32,6 +36,8 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge } from "@homarr/ui"; import { CountBadge } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
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 { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
@@ -48,26 +54,48 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
return ( return (
<Container> <ManageContainer>
<DynamicBreadcrumb />
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
<Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}>
<MenuTarget> <Box>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button> <IntegrationSelectMenu>
</MenuTarget> <Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
<MenuDropdown> <MenuTarget>
<IntegrationCreateDropdownContent /> <Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuDropdown> </MenuTarget>
</Menu> </Affix>
</IntegrationSelectMenu>
</Box>
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
</Group> </Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} /> <IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</Stack> </Stack>
</Container> </ManageContainer>
); );
} }
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
return (
<Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent />
</MenuDropdown>
</Menu>
);
};
interface IntegrationListProps { interface IntegrationListProps {
integrations: RouterOutputs["integration"]["all"]; integrations: RouterOutputs["integration"]["all"];
activeTab?: IntegrationKind; activeTab?: IntegrationKind;
@@ -82,6 +110,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const grouppedIntegrations = integrations.reduce( const grouppedIntegrations = integrations.reduce(
(acc, integration) => { (acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) { if (!acc[integration.kind]) {
acc[integration.kind] = []; acc[integration.kind] = [];
} }
@@ -104,7 +133,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</Group> </Group>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel>
<Table> <Table visibleFrom="md">
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>{t("field.name.label")}</TableTh> <TableTh>{t("field.name.label")}</TableTh>
@@ -129,7 +158,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
href={`/manage/integrations/edit/${integration.id}`} href={`/manage/integrations/edit/${integration.id}`}
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label="Edit integration" aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -141,6 +170,34 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
))} ))}
</TableTbody> </TableTbody>
</Table> </Table>
<Stack gap="xs" hiddenFrom="md">
{integrations.map((integration, index) => (
<Fragment key={integration.id}>
{index !== 0 && <Divider />}
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
</Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</Stack>
</Fragment>
))}
</Stack>
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
))} ))}

View File

@@ -14,6 +14,7 @@ import {
IconMailForward, IconMailForward,
IconPlug, IconPlug,
IconQuestionMark, IconQuestionMark,
IconReport,
IconSettings, IconSettings,
IconTool, IconTool,
IconUser, IconUser,
@@ -21,6 +22,7 @@ import {
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
@@ -64,6 +66,7 @@ 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"),
@@ -86,6 +89,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconLogs, icon: IconLogs,
href: "/manage/tools/logs", href: "/manage/tools/logs",
}, },
{
label: t("items.tools.items.tasks"),
icon: IconReport,
href: "/manage/tools/tasks",
},
], ],
}, },
{ {

View File

@@ -3,8 +3,10 @@ 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 { 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 { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner"; import { HeroBanner } from "./_components/hero-banner";
@@ -13,6 +15,7 @@ interface LinkProps {
subtitle: string; subtitle: string;
count: number; count: number;
href: string; href: string;
hidden?: boolean;
} }
export async function generateMetadata() { export async function generateMetadata() {
@@ -36,13 +39,14 @@ export default async function ManagementPage() {
}, },
{ {
count: statistics.countUsers, count: statistics.countUsers,
href: "/manage/boards", href: "/manage/users",
subtitle: t("statisticLabel.authentication"), subtitle: t("statisticLabel.authentication"),
title: t("statistic.createUser"), title: t("statistic.createUser"),
}, },
{ {
hidden: !isProviderEnabled("credentials"),
count: statistics.countInvites, count: statistics.countInvites,
href: "/manage/boards", href: "/manage/users/invites",
subtitle: t("statisticLabel.authentication"), subtitle: t("statisticLabel.authentication"),
title: t("statistic.createInvite"), title: t("statistic.createInvite"),
}, },
@@ -67,27 +71,31 @@ export default async function ManagementPage() {
]; ];
return ( return (
<> <>
<DynamicBreadcrumb />
<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((link, index) => ( {links.map(
<Card component={Link} href={link.href} key={`link-${index}`} withBorder> (link) =>
<Group justify="space-between"> !link.hidden && (
<Group> <Card component={Link} href={link.href} key={link.href} withBorder>
<Text size="2.4rem" fw="bolder"> <Group justify="space-between" wrap="nowrap">
{link.count} <Group wrap="nowrap">
</Text> <Text size="2.4rem" fw="bolder">
<Stack gap={0}> {link.count}
<Text c="red" size="xs"> </Text>
{link.subtitle} <Stack gap={0}>
</Text> <Text c="red" size="xs">
<Text fw="bold">{link.title}</Text> {link.subtitle}
</Stack> </Text>
</Group> <Text fw="bold">{link.title}</Text>
<IconArrowRight /> </Stack>
</Group> </Group>
</Card> <IconArrowRight />
))} </Group>
</Card>
),
)}
</SimpleGrid> </SimpleGrid>
</> </>
); );

View File

@@ -109,7 +109,9 @@ const SwitchSetting = ({
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}> <UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
<Stack gap={0}> <Stack gap={0}>
<Text fw="bold">{title}</Text> <Text fw="bold">{title}</Text>
<Text c="gray.5">{text}</Text> <Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
{text}
</Text>
</Stack> </Stack>
</UnstyledButton> </UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} /> <Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />

View File

@@ -3,6 +3,7 @@ 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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings"; import { AnalyticsSettings } from "./_components/analytics.settings";
export async function generateMetadata() { export async function generateMetadata() {
@@ -18,9 +19,12 @@ export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll(); const serverSettings = await api.serverSettings.getAll();
const t = await getScopedI18n("management.page.settings"); const t = await getScopedI18n("management.page.settings");
return ( return (
<Stack> <>
<Title order={1}>{t("title")}</Title> <DynamicBreadcrumb />
<AnalyticsSettings initialData={serverSettings.analytics} /> <Stack>
</Stack> <Title order={1}>{t("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
</Stack>
</>
); );
} }

View File

@@ -0,0 +1,204 @@
"use client";
import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
const createColumns = (
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
accessorKey: "name",
header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Avatar variant="outline" radius="lg" size="md" src={row.original.iconUrl}>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
</Group>
);
},
},
{
accessorKey: "state",
header: t("docker.field.state.label"),
size: 120,
Cell({ cell }) {
return <ContainerStateBadge state={cell.row.original.state} />;
},
},
{
accessorKey: "image",
header: t("docker.field.containerImage.label"),
maxSize: 200,
Cell({ renderedCellValue }) {
return (
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
</Box>
);
},
},
{
accessorKey: "ports",
header: t("docker.field.ports.label"),
Cell({ cell }) {
return (
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} />
);
},
},
];
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data.timestamp);
const table = useTranslatedMantineReactTable({
data: data.containers,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: true,
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: data.containers.length }),
style: { minWidth: 300 },
autoFocus: true,
},
initialState: { density: "xs", showGlobalFilter: true },
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
return (
<Group gap={"sm"}>
{groupedAlert}
<Text fw={500}>
{tDocker("table.selected", {
selectCount: table.getSelectedRowModel().rows.length,
totalCount: table.getRowCount(),
})}
</Text>
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
</Group>
);
},
columns: createColumns(t),
});
return (
<>
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
<MantineReactTable table={table} />
</>
);
}
interface ContainerActionBarProps {
selectedIds: string[];
}
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
return (
<Group gap="xs">
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
</Group>
);
};
interface ContainerActionBarButtonProps {
icon: TablerIcon;
color: MantineColor;
action: "start" | "stop" | "restart" | "remove";
selectedIds: string[];
}
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const utils = clientApi.useUtils();
const handleClickAsync = async () => {
await mutateAsync(
{ ids: props.selectedIds },
{
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${props.action}.notification.success.title`),
message: t(`${props.action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`${props.action}.notification.error.title`),
message: t(`${props.action}.notification.error.message`),
});
},
},
);
};
return (
<Button
leftSection={<props.icon />}
color={props.color}
onClick={handleClickAsync}
loading={isPending}
variant="light"
radius="md"
>
{t(`${props.action}.label`)}
</Button>
);
};
const containerStates = {
created: "cyan",
running: "green",
paused: "yellow",
restarting: "orange",
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<DockerContainerState, MantineColor>;
const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStates[state]}>
{t(state)}
</Badge>
);
};

View File

@@ -0,0 +1,22 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerTable } from "./docker-table";
export default async function DockerPage() {
const { containers, timestamp } = await api.docker.getContainers();
const tDocker = await getScopedI18n("docker");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable containers={containers} timestamp={timestamp} />
</Stack>
</>
);
}

View File

@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
@@ -23,8 +24,11 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
export default function LogsManagementPage() { export default function LogsManagementPage() {
return ( return (
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black"> <>
<ClientSideTerminalComponent /> <DynamicBreadcrumb />
</Box> <Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent />
</Box>
</>
); );
} }

View File

@@ -0,0 +1,93 @@
"use client";
import React from "react";
import { ActionIcon, Badge, Card, Group, Stack, Text } from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlayerPlay } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { TaskStatus } from "@homarr/cron-job-status";
import type { TranslationKeys } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
interface JobsListProps {
initialJobs: RouterOutputs["cronJobs"]["getJobs"];
}
interface JobState {
job: JobsListProps["initialJobs"][number];
status: TaskStatus | null;
}
export const JobsList = ({ initialJobs }: JobsListProps) => {
const t = useScopedI18n("management.page.tool.tasks");
const [jobs, handlers] = useListState<JobState>(
initialJobs.map((job) => ({
job,
status: null,
})),
);
clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, {
onData: (data) => {
const jobByName = jobs.find((job) => job.job.name === data.name);
if (!jobByName) {
return;
}
handlers.applyWhere(
(job) => job.job.name === data.name,
(job) => ({ ...job, status: data }),
);
},
});
const { mutateAsync } = clientApi.cronJobs.triggerJob.useMutation();
const handleJobTrigger = React.useCallback(
async (job: JobState) => {
if (job.status?.status === "running") {
return;
}
await mutateAsync(job.job.name);
},
[mutateAsync],
);
return (
<Stack>
{jobs.map((job) => (
<Card key={job.job.name}>
<Group justify={"space-between"} gap={"md"}>
<Stack gap={0}>
<Group>
<Text>{t(`job.${job.job.name}.label` as TranslationKeys)}</Text>
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
</Group>
{job.status && <TimeAgo timestamp={job.status.lastExecutionTimestamp} />}
</Stack>
<ActionIcon
onClick={() => handleJobTrigger(job)}
disabled={job.status?.status === "running"}
variant={"default"}
size={"xl"}
radius={"xl"}
>
<IconPlayerPlay stroke={1.5} />
</ActionIcon>
</Group>
</Card>
))}
</Stack>
);
};
const TimeAgo = ({ timestamp }: { timestamp: string }) => {
const timeAgo = useTimeAgo(new Date(timestamp));
return (
<Text size={"sm"} c={"dimmed"}>
{timeAgo}
</Text>
);
};

View File

@@ -0,0 +1,25 @@
import { Box, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { JobsList } from "./_components/jobs-list";
export async function generateMetadata() {
const t = await getScopedI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
};
}
export default async function TasksPage() {
const jobs = await api.cronJobs.getJobs();
return (
<Box>
<Title mb={"md"}>Tasks</Title>
<JobsList initialJobs={jobs} />
</Box>
);
}

View File

@@ -93,24 +93,38 @@ 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 opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow> <Menu
opened={opened}
keepMounted
onChange={isCredentialsUser ? toggle : undefined}
position="bottom-start"
withArrow
>
<Menu.Target> <Menu.Target>
<UnstyledButton onClick={toggle}> <UnstyledButton
component={isCredentialsUser ? undefined : "div"}
style={{ cursor: !isCredentialsUser ? "default" : undefined }}
onClick={isCredentialsUser ? toggle : undefined}
>
<UserAvatar user={user} size={200} /> <UserAvatar user={user} size={200} />
<Button {isCredentialsUser && (
component="div" <Button
pos="absolute" component="div"
bottom={0} pos="absolute"
left={0} bottom={0}
size="compact-md" left={0}
fw="normal" size="compact-md"
variant="default" fw="normal"
leftSection={<IconPencil size={18} stroke={1.5} />} variant="default"
> leftSection={<IconPencil size={18} stroke={1.5} />}
{t("common.action.edit")} >
</Button> {t("common.action.edit")}
</Button>
)}
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
@@ -136,6 +150,6 @@ const fileToBase64Async = async (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(file); reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result?.toString() || ""); reader.onload = () => resolve(reader.result?.toString() ?? "");
reader.onerror = reject; reader.onerror = reject;
}); });

View File

@@ -51,8 +51,12 @@ 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,
@@ -64,14 +68,25 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} /> <TextInput
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} /> disabled={!isProviderCredentials}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Group justify="end"> {isProviderCredentials && (
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}> <Group justify="end">
{t("common.action.saveChanges")} <Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
</Button> {t("common.action.saveChanges")}
</Group> </Button>
</Group>
)}
</Stack> </Stack>
</form> </form>
); );

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Box, Group, Stack, Title } from "@mantine/core"; import { Alert, 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";
@@ -35,7 +36,7 @@ export async function generateMetadata({ params }: Props) {
const t = await getScopedI18n("management.page.user.edit"); const t = await getScopedI18n("management.page.user.edit");
return { return {
title: createMetaTitle(t("metaTitle", { username: user?.name })), title: createMetaTitle(t("metaTitle", { username: user.name })),
}; };
} }
@@ -53,8 +54,14 @@ export default async function EditUserPage({ params }: Props) {
notFound(); notFound();
} }
const isCredentialsUser = user.provider === "credentials";
return ( return (
<Stack> <Stack>
<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}>
@@ -67,13 +74,15 @@ export default async function EditUserPage({ params }: Props) {
<ProfileLanguageChange /> <ProfileLanguageChange />
<DangerZoneRoot> {isCredentialsUser && (
<DangerZoneItem <DangerZoneRoot>
label={t("user.action.delete.label")} <DangerZoneItem
description={t("user.action.delete.description")} label={t("user.action.delete.label")}
action={<DeleteUserButton user={user} />} description={t("user.action.delete.description")}
/> action={<DeleteUserButton user={user} />}
</DangerZoneRoot> />
</DangerZoneRoot>
)}
</Stack> </Stack>
); );
} }

View File

@@ -1,7 +1,6 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core"; import { Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconSettings, IconShieldLock } from "@tabler/icons-react"; import { IconSettings, IconShieldLock } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -9,6 +8,8 @@ import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-not-found"; import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { NavigationLink } from "../groups/[id]/_navigation"; import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access"; import { canAccessUserEditPage } from "./access";
@@ -27,23 +28,27 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
notFound(); notFound();
} }
const isCredentialsUser = user.provider === "credentials";
return ( return (
<Container size="xl"> <ManageContainer size="xl">
<DynamicBreadcrumb
dynamicMappings={
new Map([
[params.userId, user.name ?? ""],
["general", t("navigationStructure.manage.users.general")],
["security", t("navigationStructure.manage.users.security")],
])
}
/>
<Grid> <Grid>
<GridCol span={12}> <GridCol span={12}>
<Group justify="space-between" align="center"> <Group align="center">
<Group> <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> <Text c="gray.5">{t("user.name")}</Text>
<Text c="gray.5">{t("user.name")}</Text> </Stack>
</Stack>
</Group>
{session?.user.permissions.includes("admin") && (
<Button component={Link} href="/manage/users" color="gray" variant="light">
{tUser("back")}
</Button>
)}
</Group> </Group>
</GridCol> </GridCol>
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}> <GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
@@ -54,16 +59,18 @@ 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} />}
/> />
<NavigationLink {isCredentialsUser && (
href={`/manage/users/${params.userId}/security`} <NavigationLink
label={tUser("setting.security.title")} href={`/manage/users/${params.userId}/security`}
icon={<IconShieldLock size="1rem" stroke={1.5} />} label={tUser("setting.security.title")}
/> icon={<IconShieldLock size="1rem" stroke={1.5} />}
/>
)}
</Stack> </Stack>
</Stack> </Stack>
</GridCol> </GridCol>
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol> <GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
</Grid> </Grid>
</Container> </ManageContainer>
); );
} }

View File

@@ -28,6 +28,10 @@ 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>

View File

@@ -2,14 +2,16 @@
import { useMemo } from "react"; import { useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { Avatar, Button, Group, Text, ThemeIcon, Title } from "@mantine/core"; import { Anchor, 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, useMantineReactTable } from "mantine-react-table"; import { MantineReactTable } 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"];
@@ -29,12 +31,12 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
header: t("user.field.username.label"), header: t("user.field.username.label"),
grow: 100, grow: 100,
Cell: ({ renderedCellValue, row }) => ( Cell: ({ renderedCellValue, row }) => (
<Link href={`/manage/users/${row.original.id}/general`}> <Group>
<Group> <UserAvatar size="sm" user={row.original} />
<Avatar size="sm"></Avatar> <Anchor component={Link} href={`/manage/users/${row.original.id}/general`}>
{renderedCellValue} {renderedCellValue}
</Group> </Anchor>
</Link> </Group>
), ),
}, },
{ {
@@ -55,7 +57,7 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
[t], [t],
); );
const table = useMantineReactTable({ const table = useTranslatedMantineReactTable({
columns, columns,
data, data,
enableRowSelection: true, enableRowSelection: true,

View File

@@ -1,17 +1,18 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Avatar, Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core"; import { 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 { 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.component"; import { StepperNavigationComponent } from "./stepper-navigation";
export const UserCreateStepperComponent = () => { export const UserCreateStepperComponent = () => {
const t = useScopedI18n("management.page.user.create"); const t = useScopedI18n("management.page.user.create");
@@ -71,7 +72,8 @@ export const UserCreateStepperComponent = () => {
const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]); const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]);
const isCurrentFormValid = allForms[active] ? (allForms[active]!.isValid satisfies () => boolean) : () => true; const activeForm = allForms[active];
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
const canNavigateToNextStep = isCurrentFormValid(); const canNavigateToNextStep = isCurrentFormValid();
const controlledGoToNextStep = useCallback(async () => { const controlledGoToNextStep = useCallback(async () => {
@@ -149,7 +151,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">
<Avatar size="xl">{generalForm.values.username}</Avatar> <UserAvatar size="xl" user={{ name: generalForm.values.username, image: null }} />
<Text tt="uppercase" fw="bolder" size="xl"> <Text tt="uppercase" fw="bolder" size="xl">
{generalForm.values.username} {generalForm.values.username}
</Text> </Text>

View File

@@ -1,5 +1,6 @@
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { UserCreateStepperComponent } from "./_components/create-user-stepper"; import { UserCreateStepperComponent } from "./_components/create-user-stepper";
@@ -12,5 +13,10 @@ export async function generateMetadata() {
} }
export default function CreateUserPage() { export default function CreateUserPage() {
return <UserCreateStepperComponent />; return (
<>
<DynamicBreadcrumb />
<UserCreateStepperComponent />
</>
);
} }

View File

@@ -8,7 +8,7 @@ import { useConfirmModal, 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 { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal"; import { UserSelectModal } from "~/components/access/user-select-modal";
interface TransferGroupOwnershipProps { interface TransferGroupOwnershipProps {
group: { group: {

View File

@@ -1,11 +1,12 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core"; import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react"; import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { NavigationLink } from "./_navigation"; import { NavigationLink } from "./_navigation";
interface LayoutProps { interface LayoutProps {
@@ -18,7 +19,7 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
const group = await api.group.getById({ id: params.id }); const group = await api.group.getById({ id: params.id });
return ( return (
<Container size="xl"> <ManageContainer size="xl">
<Grid> <Grid>
<GridCol span={12}> <GridCol span={12}>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
@@ -54,6 +55,6 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
</GridCol> </GridCol>
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol> <GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
</Grid> </Grid>
</Container> </ManageContainer>
); );
} }

View File

@@ -1,14 +1,14 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
interface AddGroupMemberProps { interface AddGroupMemberProps {
groupId: string; groupId: string;
@@ -40,8 +40,8 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]); }, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
return ( return (
<Button color="teal" onClick={handleAddMember}> <MobileAffixButton color="teal" onClick={handleAddMember}>
{tMembersAdd("label")} {tMembersAdd("label")}
</Button> </MobileAffixButton>
); );
}; };

View File

@@ -1,8 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core"; import { Alert, Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env.mjs";
import { isProviderEnabled } from "@homarr/auth/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, UserAvatar } from "@homarr/ui"; import { SearchInput, UserAvatar } from "@homarr/ui";
@@ -24,12 +27,26 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
const group = await api.group.getById({ id: params.id }); const group = await api.group.getById({ id: params.id });
const filteredMembers = searchParams.search const filteredMembers = searchParams.search
? group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase())) ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
: group.members; : group.members;
const providerTypes = isProviderEnabled("credentials")
? env.AUTH_PROVIDERS.length > 1
? "mixed"
: "credentials"
: "external";
return ( return (
<Stack> <Stack>
<Title>{tMembers("title")}</Title> <Title>{tMembers("title")}</Title>
{providerTypes !== "credentials" && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t(`group.memberNotice.${providerTypes}`)}
</Alert>
)}
<Group justify="space-between"> <Group justify="space-between">
<SearchInput <SearchInput
placeholder={t("common.rtl", { placeholder={t("common.rtl", {
@@ -38,7 +55,9 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
})} })}
defaultValue={searchParams.search} defaultValue={searchParams.search}
/> />
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} /> {isProviderEnabled("credentials") && (
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
)}
</Group> </Group>
{filteredMembers.length === 0 && ( {filteredMembers.length === 0 && (
<Center py="sm"> <Center py="sm">
@@ -59,7 +78,7 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
} }
interface RowProps { interface RowProps {
member: RouterOutputs["group"]["getPaginated"]["items"][number]["members"][number]; member: RouterOutputs["group"]["getById"]["members"][number];
groupId: string; groupId: string;
} }
@@ -69,13 +88,13 @@ const Row = ({ member, groupId }: RowProps) => {
<TableTd> <TableTd>
<Group> <Group>
<UserAvatar size="sm" user={member} /> <UserAvatar size="sm" user={member} />
<Anchor component={Link} href={`/manage/users/${member.id}`}> <Anchor component={Link} href={`/manage/users/${member.id}/general`}>
{member.name} {member.name}
</Anchor> </Anchor>
</Group> </Group>
</TableTd> </TableTd>
<TableTd w={100}> <TableTd w={100}>
<RemoveGroupMember user={member} groupId={groupId} /> {member.provider === "credentials" && <RemoveGroupMember user={member} groupId={groupId} />}
</TableTd> </TableTd>
</TableTr> </TableTr>
); );

View File

@@ -101,7 +101,7 @@ interface PermissionRowProps {
const PermissionRow = ({ name, label, description }: PermissionRowProps) => { const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
return ( return (
<Group justify="space-between" align="center"> <Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0}> <Stack gap={0}>
<Text fw={500}>{label}</Text> <Text fw={500}>{label}</Text>
<Text c="gray.5">{description}</Text> <Text c="gray.5">{description}</Text>

View File

@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const AddGroup = () => { export const AddGroup = () => {
const t = useI18n(); const t = useI18n();
@@ -21,9 +22,9 @@ export const AddGroup = () => {
}, [openModal]); }, [openModal]);
return ( return (
<Button onClick={handleAddGroup} color="teal"> <MobileAffixButton onClick={handleAddGroup} color="teal">
{t("group.action.create.label")} {t("group.action.create.label")}
</Button> </MobileAffixButton>
); );
}; };

View File

@@ -1,17 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
Anchor,
Container,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -19,6 +7,8 @@ import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui"; import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AddGroup } from "./_add-group"; import { AddGroup } from "./_add-group";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
@@ -41,7 +31,8 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
const { items: groups, totalCount } = await api.group.getPaginated(searchParams); const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
return ( return (
<Container size="xl"> <ManageContainer size="xl">
<DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("group.title")}</Title> <Title>{t("group.title")}</Title>
<Group justify="space-between"> <Group justify="space-between">
@@ -72,7 +63,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} /> <TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group> </Group>
</Stack> </Stack>
</Container> </ManageContainer>
); );
} }

View File

@@ -6,12 +6,13 @@ import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; import { MantineReactTable } 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 { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { InviteCreateModal } from "./invite-create-modal"; import { InviteCreateModal } from "./invite-create-modal";
@@ -52,7 +53,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
[t], [t],
); );
const table = useMantineReactTable({ const table = useTranslatedMantineReactTable({
columns, columns,
data, data,
positionActionsColumn: "last", positionActionsColumn: "last",

View File

@@ -1,8 +1,21 @@
import { api } from "@homarr/api/server"; import { notFound } from "next/navigation";
import { api } from "@homarr/api/server";
import { isProviderEnabled } from "@homarr/auth/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { InviteListComponent } from "./_components/invite-list"; import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() { export default async function InvitesOverviewPage() {
if (!isProviderEnabled("credentials")) {
notFound();
}
const initialInvites = await api.invite.getAll(); const initialInvites = await api.invite.getAll();
return <InviteListComponent initialInvites={initialInvites} />; return (
<>
<DynamicBreadcrumb />
<InviteListComponent initialInvites={initialInvites} />
</>
);
} }

View File

@@ -1,8 +1,9 @@
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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list.component"; import { UserListComponent } from "./_components/user-list";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management.page.user.list"); const t = await getScopedI18n("management.page.user.list");
@@ -14,5 +15,10 @@ export async function generateMetadata() {
export default async function UsersPage() { export default async function UsersPage() {
const userList = await api.user.getAll(); const userList = await api.user.getAll();
return <UserListComponent initialUserList={userList} />; return (
<>
<DynamicBreadcrumb />
<UserListComponent initialUserList={userList} />
</>
);
} }

View File

@@ -1,14 +1,33 @@
import type { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { createHandlers } from "@homarr/auth"; import { createHandlers } from "@homarr/auth";
import { logger } from "@homarr/log";
export const GET = async (req: NextRequest) => { export const GET = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.GET(req); return await createHandlers(isCredentialsRequest(req)).handlers.GET(reqWithTrustedOrigin(req));
}; };
export const POST = async (req: NextRequest) => { export const POST = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.POST(req); return await createHandlers(isCredentialsRequest(req)).handlers.POST(reqWithTrustedOrigin(req));
}; };
const isCredentialsRequest = (req: NextRequest) => { const isCredentialsRequest = (req: NextRequest) => {
return req.url.includes("credentials") && req.method === "POST"; return req.url.includes("credentials") && req.method === "POST";
}; };
/**
* This is a workaround to allow the authentication to work with behind a proxy.
* See https://github.com/nextauthjs/next-auth/issues/10928#issuecomment-2162893683
*/
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
const proto = req.headers.get("x-forwarded-proto");
const host = req.headers.get("x-forwarded-host");
if (!proto || !host) {
logger.warn("Missing x-forwarded-proto or x-forwarded-host headers.");
return req;
}
const envOrigin = `${proto}://${host}`;
const { href, origin } = req.nextUrl;
logger.debug(`Rewriting origin from ${origin} to ${envOrigin}`);
return new NextRequest(href.replace(origin, envOrigin), req);
};

View File

@@ -0,0 +1,189 @@
import { useState } from "react";
import { Group, Stack, Tabs } from "@mantine/core";
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { CountBadge } from "@homarr/ui";
import { AccessProvider } from "./context";
import type { AccessFormType } from "./form";
import { GroupAccessForm } from "./group-access-form";
import { InheritAccessTable } from "./inherit-access-table";
import { UsersAccessForm } from "./user-access-form";
interface GroupAccessPermission<TPermission extends string> {
permission: TPermission;
group: {
id: string;
name: string;
};
}
interface UserAccessPermission<TPermission extends string> {
permission: TPermission;
user: {
name: string | null;
image: string | null;
id: string;
};
}
interface SimpleMutation<TPermission extends string> {
mutate: (
props: { entityId: string; permissions: { principalId: string; permission: TPermission }[] },
options: { onSuccess: () => void },
) => void;
isPending: boolean;
}
export interface AccessQueryData<TPermission extends string> {
inherited: GroupAccessPermission<GroupPermissionKey>[];
groups: GroupAccessPermission<TPermission>[];
users: UserAccessPermission<TPermission>[];
}
interface Props<TPermission extends string> {
permission: {
items: readonly TPermission[];
default: TPermission;
icons: Record<TPermission, TablerIcon>;
groupPermissionMapping: Record<TPermission, GroupPermissionKey>;
fullAccessGroupPermission: GroupPermissionKey;
};
query: {
data: AccessQueryData<TPermission>;
invalidate: () => Promise<void>;
};
groupsMutation: SimpleMutation<TPermission>;
usersMutation: SimpleMutation<TPermission>;
entity: {
id: string;
ownerId: string | null;
owner: {
id: string;
name: string | null;
image: string | null;
} | null;
};
translate: (key: TPermission) => string;
}
export const AccessSettings = <TPermission extends string>({
permission,
query,
groupsMutation,
usersMutation,
entity,
translate,
}: Props<TPermission>) => {
const [counts, setCounts] = useState({
user: query.data.users.length + (entity.owner ? 1 : 0),
group: query.data.groups.length,
});
const handleGroupSubmit = (values: AccessFormType<TPermission>) => {
groupsMutation.mutate(
{
entityId: entity.id,
permissions: values.items,
},
{
onSuccess() {
void query.invalidate();
},
},
);
};
const handleUserSubmit = (values: AccessFormType<TPermission>) => {
usersMutation.mutate(
{
entityId: entity.id,
permissions: values.items,
},
{
onSuccess() {
void query.invalidate();
},
},
);
};
return (
<AccessProvider<TPermission>
defaultPermission={permission.default}
icons={permission.icons}
permissions={permission.items}
translate={translate}
>
<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={query.data.inherited.length} icon={IconUserDown} />
</Tabs.List>
<Tabs.Panel value="user">
<UsersAccessForm<TPermission>
entity={entity}
accessQueryData={query.data}
handleCountChange={(callback) =>
setCounts(({ user, ...others }) => ({
user: callback(user),
...others,
}))
}
handleSubmit={handleUserSubmit}
isPending={usersMutation.isPending}
/>
</Tabs.Panel>
<Tabs.Panel value="group">
<GroupAccessForm<TPermission>
accessQueryData={query.data}
handleCountChange={(callback) =>
setCounts(({ group, ...others }) => ({
group: callback(group),
...others,
}))
}
handleSubmit={handleGroupSubmit}
isPending={groupsMutation.isPending}
/>
</Tabs.Panel>
<Tabs.Panel value="inherited">
<InheritAccessTable<TPermission>
accessQueryData={query.data}
fullAccessGroupPermission={permission.fullAccessGroupPermission}
mapPermissions={permission.groupPermissionMapping}
/>
</Tabs.Panel>
</Tabs>
</Stack>
</AccessProvider>
);
};
interface TabItemProps {
value: "user" | "group" | "inherited";
count: number;
icon: TablerIcon;
}
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
const t = useScopedI18n("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>
);
};

View File

@@ -1,43 +1,36 @@
import { useCallback } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback } 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 { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react"; import { Icon123, IconCheck } from "@tabler/icons-react";
import type { BoardPermission } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client";
import { boardPermissions } from "@homarr/definitions";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { OnCountChange } from "./form"; import { useAccessContext } from "./context";
import type { HandleCountChange } from "./form";
import { useFormContext } from "./form"; import { useFormContext } from "./form";
const icons = { interface AccessSelectRowProps {
"board-change": IconPencil,
"board-view": IconEye,
"board-full": IconSettings,
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
interface BoardAccessSelectRowProps {
itemContent: ReactNode; itemContent: ReactNode;
permission: BoardPermission; permission: string;
index: number; index: number;
onCountChange: OnCountChange; handleCountChange: HandleCountChange;
} }
export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => { export const AccessSelectRow = ({ itemContent, permission, index, handleCountChange }: AccessSelectRowProps) => {
const tRoot = useI18n(); const tRoot = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const { icons, getSelectData } = useAccessContext();
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),
); );
onCountChange((prev) => prev - 1); handleCountChange((prev) => prev - 1);
}, [form, index, onCountChange]); }, [form, index, handleCountChange]);
const Icon = icons[permission] ?? Icon123;
return ( return (
<TableTr> <TableTr>
@@ -50,10 +43,7 @@ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountCh
leftSection={<Icon size="1rem" />} leftSection={<Icon size="1rem" />}
renderOption={RenderOption} renderOption={RenderOption}
variant="unstyled" variant="unstyled"
data={boardPermissions.map((permission) => ({ data={getSelectData()}
value: permission,
label: tPermissions(`item.${permission}.label`),
}))}
{...form.getInputProps(`items.${index}.permission`)} {...form.getInputProps(`items.${index}.permission`)}
/> />
@@ -66,30 +56,6 @@ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountCh
); );
}; };
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",
@@ -98,7 +64,9 @@ const iconProps = {
}; };
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
const Icon = icons[option.value as BoardPermission]; const { icons } = useAccessContext();
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} />
@@ -107,3 +75,27 @@ 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>
);
};

Some files were not shown because too many files have changed in this diff Show More