Compare commits

..
40 Commits
Author SHA1 Message Date
Crowdin Bot acddd9183e New Crowdin translations by GitHub Action
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-30 00:57:15 +00:00
shamoon e6ad43a76c Tweak: improve nextauth error logging for easier debugging (#7070)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-28 12:23:55 -07:00
dependabot[bot] c46b3e6cff Chore(deps): Bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#7065)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 17:43:25 +00:00
dependabot[bot] e913243149 Chore(deps): Bump crowdin/github-action from 2.17.0 to 2.17.1 (#7066)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 17:37:38 +00:00
dependabot[bot] 99e16c6da0 Chore(deps): Bump astral-sh/setup-uv from 10.0.0 to 10.0.1 (#7067)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 10:31:08 -07:00
Vaibhav Srivastava c0b6eb3501 Documentation: fix typo ommitted -> omitted (#7064)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-27 00:20:59 -07:00
shamoon a17763ca9f Accessibility: improve sr labeling of search ui (#7052)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
2026-08-23 21:49:31 -07:00
shamoon bd39fb48b9 Chore: narrow/specify some workflow permissions (#7051)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
2026-08-23 16:51:28 -07:00
shamoon f2dc80e39e Tweak: move favicon population earlier (#7041)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-21 14:06:21 -07:00
shamoon 7d48b7ea78 Fix QL Copilot thing 2026-08-21 14:00:24 -07:00
shamoon 4c361422ca Chore: full react hooks eslint compliance (#7040) 2026-08-21 14:00:02 -07:00
shamoon 7c26cf6d97 Add 'minor' label to release drafter configuration
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-21 12:15:44 -07:00
shamoon f0bd255adb Fix yaml parsing in 2.1.1 (#7037) 2026-08-21 09:48:25 -07:00
github-actions[bot]andCrowdin Bot 728c6fbe18 New Crowdin translations by GitHub Action (#7027)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-21 15:46:27 +00:00
dependabot[bot]andshamoon 6a96289717 Chore(deps-dev): Bump eslint-plugin-react-hooks from 5.2.0 to 7.1.1 (#7033)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 08:22:55 -07:00
shamoon fa47a2d86c Chore: again, improve k8s error logging 2026-08-21 08:11:48 -07:00
shamoon cc22d04429 Fixhancement: Handle k8s ipv6 node tls bug (#7035) 2026-08-21 07:21:38 -07:00
dependabot[bot]andshamoon a414f376b9 Chore(deps): Bump js-yaml from 4.3.1 to 5.3.0 (#7032)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-08-21 07:18:27 -07:00
dependabot[bot] bdd77342d8 Chore(deps-dev): Bump @testing-library/jest-dom from 7.0.0 to 7.0.1 in the testing-library group (#7029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 13:57:53 +00:00
dependabot[bot] 63d2172ef7 Chore(deps): Bump swr from 2.5.0 to 2.5.1 (#7031)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 13:51:09 +00:00
dependabot[bot] 85e4e439bc Chore(deps): Bump @kubernetes/client-node from 1.4.0 to 2.0.0 (#7030)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 06:44:33 -07:00
shamoon 36aa241689 Merge branch 'main' into dev 2026-08-21 06:37:32 -07:00
shamoon 0933ba015a Docs: clarify Claude use of mcp-remote 2026-08-21 06:34:09 -07:00
shamoon c57117daae Fix: move mcp method ordering 2026-08-21 06:29:55 -07:00
shamoon 93763b7c10 2.1.0
Docker CI / Docker Build & Push (push) Has been cancelled
Docs / Test Build Docs (push) Has been cancelled
Docs / Build & Deploy Docs (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-20 19:14:17 -07:00
github-actions[bot]andCrowdin Bot 6c551ace34 New Crowdin translations by GitHub Action (#6983)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-20 19:13:24 -07:00
shamoon 7886742d56 Just test to keep this from changing 2026-08-20 16:46:59 -07:00
shamoon a253861061 Tweak: read auth providers in-process on the sign-in page, set env earlier (#7023) 2026-08-20 16:44:15 -07:00
dependabot[bot] edfb28ab5d Chore(deps): Bump astral-sh/setup-uv from 9.0.0 to 10.0.0 (#7021)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 10:00:42 -07:00
shamoon 3ab6b040b2 Fixhancement: handle linkwarden API change (#7018)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-19 10:25:24 -07:00
shamoon 9cabb46bb7 Fix: fix calendar hover with white theme (#7017) 2026-08-19 10:03:52 -07:00
shamoon d7cc2d3f19 Tweakhancement: use routeNamespace as fallback for k8s gateway integration (#7009)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-18 10:55:33 -07:00
shamoon b5084ba8b3 Fix: fix k8s gateway error logging (#7008) 2026-08-18 10:51:29 -07:00
shamoon dca0c9ab81 Fix: fix duplicati widget with no jobs (#7005) 2026-08-18 08:44:44 -07:00
shamoon d4fd14b724 Merge branch 'main' into dev
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-18 00:54:00 -07:00
Zhelyan Radoevandshamoon 600c9eee78 Enhancement: add authorizationsLast24H field to Authentik widget, opt… (#6989)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-08-16 07:21:51 -07:00
shamoon 198c86fb49 Fix: ensure authentik widget v1 fallback (#6991)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-15 11:05:46 -07:00
shamoon f711d290a7 Fix: re-enable global lint, fix un-caught warnings / errors (#6990) 2026-08-15 11:01:29 -07:00
shamoon 342afa2215 Enhancement: widget support for Pulse v6 API changes (#6987)
Lint / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-08-15 00:11:08 -07:00
shamoon 27704f962e Tweak: whitelist custom.css from auth (#6986) 2026-08-14 23:52:46 -07:00
225 changed files with 3136 additions and 1966 deletions
+1
View File
@@ -10,6 +10,7 @@ version-resolver:
- 'breaking-change' - 'breaking-change'
minor: minor:
labels: labels:
- 'minor'
- 'enhancement' - 'enhancement'
- 'feature' - 'feature'
patch: patch:
+5 -1
View File
@@ -10,6 +10,10 @@ on:
] ]
branches: [ dev ] branches: [ dev ]
permissions:
contents: write
pull-requests: write
jobs: jobs:
synchronize-with-crowdin: synchronize-with-crowdin:
name: Crowdin Sync name: Crowdin Sync
@@ -19,7 +23,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2 uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+3 -2
View File
@@ -17,6 +17,8 @@ on:
env: env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
permissions: {}
jobs: jobs:
@@ -27,7 +29,6 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -101,7 +102,7 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
+6 -4
View File
@@ -10,8 +10,6 @@ on:
permissions: permissions:
contents: read contents: read
pages: write
id-token: write
jobs: jobs:
@@ -25,7 +23,7 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- run: sudo apt-get install pngquant - run: sudo apt-get install pngquant
- name: Test Docs Build - name: Test Docs Build
run: uv run --frozen zensical build --clean run: uv run --frozen zensical build --clean
@@ -33,6 +31,10 @@ jobs:
name: Build & Deploy Docs name: Build & Deploy Docs
if: github.repository == 'gethomepage/homepage' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.repository == 'gethomepage/homepage' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment: environment:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
@@ -43,7 +45,7 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- run: sudo apt-get install pngquant - run: sudo apt-get install pngquant
- name: Build Docs - name: Build Docs
run: uv run --frozen zensical build --clean run: uv run --frozen zensical build --clean
+5 -3
View File
@@ -6,15 +6,15 @@ on:
discussion: discussion:
types: [labeled] types: [labeled]
permissions: permissions: {}
discussions: write
issues: write
jobs: jobs:
close-needs-discussion: close-needs-discussion:
name: Issues Need Discussion name: Issues Need Discussion
if: github.event_name == 'issues' && github.event.label.name == 'needs-discussion' if: github.event_name == 'issues' && github.event.label.name == 'needs-discussion'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
@@ -49,6 +49,8 @@ jobs:
name: Discussions Need Information name: Discussions Need Information
if: github.event_name == 'discussion' && github.event.label.name == 'needs-information' if: github.event_name == 'discussion' && github.event.label.name == 'needs-information'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
discussions: write
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
+3
View File
@@ -6,6 +6,9 @@ on:
workflow_dispatch: workflow_dispatch:
merge_group: merge_group:
permissions:
contents: read
jobs: jobs:
lint: lint:
name: Linting Checks name: Linting Checks
-1
View File
@@ -2,7 +2,6 @@ name: PR Quality
permissions: permissions:
contents: read contents: read
issues: read
pull-requests: write pull-requests: write
on: on:
-1
View File
@@ -5,7 +5,6 @@ on:
types: [labeled] types: [labeled]
permissions: permissions:
issues: write
pull-requests: write pull-requests: write
jobs: jobs:
+14 -4
View File
@@ -5,10 +5,7 @@ on:
- cron: '0 3 * * *' - cron: '0 3 * * *'
workflow_dispatch: workflow_dispatch:
permissions: permissions: {}
issues: write
pull-requests: write
discussions: write
concurrency: concurrency:
group: lock group: lock
@@ -17,6 +14,9 @@ jobs:
stale: stale:
name: 'Stale' name: 'Stale'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps: steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10 - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
with: with:
@@ -31,6 +31,10 @@ jobs:
lock-threads: lock-threads:
name: 'Lock Old Threads' name: 'Lock Old Threads'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
discussions: write
issues: write
pull-requests: write
steps: steps:
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2 - uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
with: with:
@@ -56,6 +60,8 @@ jobs:
close-answered-discussions: close-answered-discussions:
name: 'Close Answered Discussions' name: 'Close Answered Discussions'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
discussions: write
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
@@ -112,6 +118,8 @@ jobs:
close-outdated-discussions: close-outdated-discussions:
name: 'Close Outdated Discussions' name: 'Close Outdated Discussions'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
discussions: write
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
@@ -203,6 +211,8 @@ jobs:
close-unsupported-feature-requests: close-unsupported-feature-requests:
name: 'Close Unsupported Feature Requests' name: 'Close Unsupported Feature Requests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
discussions: write
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
+3
View File
@@ -5,6 +5,9 @@ on:
push: push:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
vitest: vitest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+4
View File
@@ -7,6 +7,10 @@ As of version v0.6.30 homepage supports adding your own custom css & javascript.
To add custom css simply edit the `custom.css` file under your config directory, similarly for javascript you would edit `custom.js`. You can then target elements in homepage with various classes / ids to customize things to your liking. To add custom css simply edit the `custom.css` file under your config directory, similarly for javascript you would edit `custom.js`. You can then target elements in homepage with various classes / ids to customize things to your liking.
!!! warning
When Homepage authentication is enabled, `custom.css` remains publicly accessible so it can style the sign-in page. Do not include secrets or sensitive internal URLs in this file. `custom.js` remains protected and is only loaded after authentication.
You can also set a specific `id` for a service or bookmark to target with your custom css or javascript, e.g. You can also set a specific `id` for a service or bookmark to target with your custom css or javascript, e.g.
```yaml ```yaml
+17
View File
@@ -49,6 +49,23 @@ environment:
HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32" HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32"
``` ```
## Connecting Claude Desktop
Claude Desktop can use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to communicate with the homepage MCP endpoint in `claude_desktop_config.json`:
```json
{
"mcpServers": {
"homepage": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/api/mcp", "--header", "X-Homepage-MCP-Token: your-token"]
}
}
}
```
The **Add custom connector** option will not work: remote connectors authenticate with OAuth and cannot supply `HOMEPAGE_MCP_TOKEN`.
## Read-only by default ## Read-only by default
The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with: The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with:
+1 -1
View File
@@ -74,6 +74,6 @@ For OIDC login (overrides password login):
Homepage grants access to any identity that the configured OIDC provider authorizes for this client. Configure client assignments, groups, or access policies at the identity provider. Homepage does not apply additional claim-based authorization. Homepage grants access to any identity that the configured OIDC provider authorizes for this client. Configure client assignments, groups, or access policies at the identity provider. Homepage does not apply additional claim-based authorization.
All app pages and `/api` routes except `/api/healthcheck` will require a signed-in session. Static assets remain public. All app pages and `/api` routes except `/api/healthcheck` and `/api/config/custom.css` will require a signed-in session. Static assets remain public.
Configure your OIDC provider with the a callback URI like `https://homepage.example.com/api/auth/callback/homepage-oidc`. Configure your OIDC provider with the a callback URI like `https://homepage.example.com/api/auth/callback/homepage-oidc`.
@@ -85,6 +85,7 @@ To ensure cohesiveness of various widgets, the following should be used as a gui
- Note that we reserve the right to decline widgets for projects that are very young (eg < ~1y) or those with a small reach (eg low GitHub stars). Again, this is in an effort to keep overall widget maintenance under control. - Note that we reserve the right to decline widgets for projects that are very young (eg < ~1y) or those with a small reach (eg low GitHub stars). Again, this is in an effort to keep overall widget maintenance under control.
- Widgets should be only one row of blocks - Widgets should be only one row of blocks
- Widgets should be no more than 4 blocks wide and generally conform to the styling / design choices of other widgets - Widgets should be no more than 4 blocks wide and generally conform to the styling / design choices of other widgets
- Use `withWidgetFields(service, defaultFields)` from `utils/widget-fields` to apply default fields and cap configured fields without mutating the service. The helper limits widgets to 4 fields.
- Minimize the number of API calls - Minimize the number of API calls
- Avoid the use of custom proxy unless absolutely necessary - Avoid the use of custom proxy unless absolutely necessary
- Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself. - Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself.
+2 -2
View File
@@ -5,7 +5,7 @@ description: Authentik Widget Configuration
Learn more about [Authentik](https://github.com/goauthentik/authentik). Learn more about [Authentik](https://github.com/goauthentik/authentik).
This widget reads the number of active users in the system, as well as logins for the last 24 hours. This widget reads the number of active users in the system, as well as logins for the last 24 hours, and authorizations when using widget version 2.
You will need to generate an API token for an existing user under `Admin Portal` > `Directory` > `Tokens & App passwords`. You will need to generate an API token for an existing user under `Admin Portal` > `Directory` > `Tokens & App passwords`.
Make sure to set Intent to "API Token". Make sure to set Intent to "API Token".
@@ -15,7 +15,7 @@ The account you made the API token for also needs the following **Assigned globa
- authentik Core -> Can view User (Model: User) - authentik Core -> Can view User (Model: User)
- authentik Events -> Can view Event (Model: Event) - authentik Events -> Can view Event (Model: Event)
Allowed fields: `["users", "loginsLast24H", "failedLoginsLast24H"]`. Allowed fields: `["users", "loginsLast24H", "failedLoginsLast24H", "authorizationsLast24H"]` (`authorizationsLast24H` works with v2 only).
| Authentik Version | Homepage Widget Version | | Authentik Version | Homepage Widget Version |
| ----------------- | ----------------------- | | ----------------- | ----------------------- |
+1 -1
View File
@@ -15,5 +15,5 @@ widget:
url: https://proxmoxbackupserver.host:port url: https://proxmoxbackupserver.host:port
username: api_token_id username: api_token_id
password: api_token_secret password: api_token_secret
datastore: datastore_name #optional; if ommitted, will display a combination of all datastores used / total datastore: datastore_name #optional; if omitted, will display a combination of all datastores used / total
``` ```
+1
View File
@@ -12,5 +12,6 @@ widget:
type: pulse type: pulse
url: http://pulse.host.or.ip:7655 url: http://pulse.host.or.ip:7655
key: your-api-token # `monitoring:read` scope is required key: your-api-token # `monitoring:read` scope is required
version: 2 # required for Pulse v6, defaults to 1
fields: ["nodes", "vms", "lxcs"] # optional fields: ["nodes", "vms", "lxcs"] # optional
``` ```
+2
View File
@@ -17,6 +17,8 @@ const compat = new FlatCompat({
export default defineConfig([ export default defineConfig([
{ {
files: ["**/*.{js,mjs,cjs,jsx}"],
extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")), extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")),
plugins: { plugins: {
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "2.0.0", "version": "2.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -15,7 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.10", "@headlessui/react": "^2.2.10",
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^2.0.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
"dockerode": "^5.0.0", "dockerode": "^5.0.0",
@@ -23,7 +23,7 @@
"gamedig": "^5.3.3", "gamedig": "^5.3.3",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
"js-yaml": "^4.3.1", "js-yaml": "^5.3.0",
"json-rpc-2.0": "^1.7.1", "json-rpc-2.0": "^1.7.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
@@ -39,7 +39,7 @@
"react-i18next": "^17.0.11", "react-i18next": "^17.0.11",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
"swr": "^2.4.2", "swr": "^2.5.1",
"systeminformation": "^5.33.1", "systeminformation": "^5.33.1",
"tough-cookie": "^6.0.2", "tough-cookie": "^6.0.2",
"urbackup-server-api": "^0.92.2", "urbackup-server-api": "^0.92.2",
@@ -53,7 +53,7 @@
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.0", "@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"eslint": "^9.25.1", "eslint": "^9.25.1",
@@ -63,7 +63,7 @@
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"postcss": "^8.5.26", "postcss": "^8.5.26",
"prettier": "^3.8.4", "prettier": "^3.8.4",
+504 -294
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg>

After

Width:  |  Height:  |  Size: 96 B

+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Wag asseblief" "wait": "Wag asseblief"
}, },
"search": { "search": {
"placeholder": "Soek…" "placeholder": "Soek…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "SVE", "cpu": "SVE",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Gebruikers", "users": "Gebruikers",
"loginsLast24H": "Aantekenings (24h)", "loginsLast24H": "Aantekenings (24h)",
"failedLoginsLast24H": "Mislukte Aantekenings (24h)" "failedLoginsLast24H": "Mislukte Aantekenings (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "GEH", "mem": "GEH",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "الرجاء الإنتظار" "wait": "الرجاء الإنتظار"
}, },
"search": { "search": {
"placeholder": "بحث …" "placeholder": "بحث …",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "المعالج", "cpu": "المعالج",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "المستخدمون", "users": "المستخدمون",
"loginsLast24H": "تسجيلات الدخول (٢٤س)", "loginsLast24H": "تسجيلات الدخول (٢٤س)",
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)" "failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "الذاكرة", "mem": "الذاكرة",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Моля изчакайте" "wait": "Моля изчакайте"
}, },
"search": { "search": {
"placeholder": "Търсене…" "placeholder": "Търсене…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "Процесор", "cpu": "Процесор",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Потребители", "users": "Потребители",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Si us plau, espereu" "wait": "Si us plau, espereu"
}, },
"search": { "search": {
"placeholder": "Cerca…" "placeholder": "Cerca…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Usuaris", "users": "Usuaris",
"loginsLast24H": "Inicis de sessió (24h)", "loginsLast24H": "Inicis de sessió (24h)",
"failedLoginsLast24H": "Errors d'inici de sessió (24h)" "failedLoginsLast24H": "Errors d'inici de sessió (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+30 -27
View File
@@ -21,7 +21,7 @@
"seconds": "s" "seconds": "s"
}, },
"auth": { "auth": {
"signout": "Sign out" "signout": "Odhlásit se"
}, },
"widget": { "widget": {
"missing_type": "Chybí typ widgetu: {{type}}", "missing_type": "Chybí typ widgetu: {{type}}",
@@ -39,7 +39,9 @@
"wait": "Počkejte prosím" "wait": "Počkejte prosím"
}, },
"search": { "search": {
"placeholder": "Hledat…" "placeholder": "Hledat…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "Využití procesoru", "cpu": "Využití procesoru",
@@ -75,13 +77,13 @@
"no_data": "Nejsou k dispozici žádná data úložiště" "no_data": "Nejsou k dispozici žádná data úložiště"
}, },
"duplicati": { "duplicati": {
"jobs": "Jobs", "jobs": "Úlohy",
"stored": "Stored", "stored": "Uloženo",
"lastBackup": "Last Run", "lastBackup": "Naposledy spuštěno",
"nextRun": "Next Run", "nextRun": "Další spuštění",
"running": "Running", "running": "Běží",
"warnings": "Warnings", "warnings": "Upozornění",
"errors": "Errors" "errors": "Chyby"
}, },
"docker": { "docker": {
"rx": "RX", "rx": "RX",
@@ -133,7 +135,7 @@
"series": "Seriály", "series": "Seriály",
"episodes": "Epizody", "episodes": "Epizody",
"songs": "Skladby", "songs": "Skladby",
"albums": "Albums" "albums": "Alba"
}, },
"esphome": { "esphome": {
"offline": "Offline", "offline": "Offline",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Uživatelé", "users": "Uživatelé",
"loginsLast24H": "Příhlášení (24h)", "loginsLast24H": "Příhlášení (24h)",
"failedLoginsLast24H": "Neúspěšná přihlášení (24h)" "failedLoginsLast24H": "Neúspěšná přihlášení (24h)",
"authorizationsLast24H": "Autorizace (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "Využití paměti", "mem": "Využití paměti",
@@ -951,15 +954,15 @@
"storage": "Úložiště" "storage": "Úložiště"
}, },
"maintainerr": { "maintainerr": {
"itemsHandled": "Items Handled", "itemsHandled": "Zpracované položky",
"episodesHandled": "Episodes Handled", "episodesHandled": "Zpracované epizody",
"moviesHandled": "Movies Handled", "moviesHandled": "Zpracované filmy",
"showsHandled": "Shows Handled", "showsHandled": "Zpracované seriály",
"seasonsHandled": "Seasons Handled", "seasonsHandled": "Zpracované série",
"reclaimable": "Reclaimable", "reclaimable": "Uvolnitelné",
"movieReclaimable": "Movie Reclaimable", "movieReclaimable": "Uvolnitelné filmy",
"showReclaimable": "Show Reclaimable", "showReclaimable": "Uvolnitelné seriály",
"totalCapacity": "Total Capacity" "totalCapacity": "Celková kapacita"
}, },
"netdata": { "netdata": {
"warnings": "Upozornění", "warnings": "Upozornění",
@@ -1241,14 +1244,14 @@
"steps": "Kroky" "steps": "Kroky"
}, },
"syncthing": { "syncthing": {
"connected": "Connected", "connected": "Připojeno",
"synced": "Synced", "synced": "Synchronizováno",
"errors": "Errors", "errors": "Chyby",
"storage": "Storage" "storage": "Úložiště"
}, },
"sportarr": { "sportarr": {
"wanted": "Wanted", "wanted": "Požadované",
"queued": "Queued", "queued": "Ve frontě",
"leagues": "Leagues" "leagues": "Ligy"
} }
} }
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Vent venligst" "wait": "Vent venligst"
}, },
"search": { "search": {
"placeholder": "Søg…" "placeholder": "Søg…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Login (24 timer)", "loginsLast24H": "Login (24 timer)",
"failedLoginsLast24H": "Mislykkede logins (24 timer)" "failedLoginsLast24H": "Mislykkede logins (24 timer)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Bitte warten" "wait": "Bitte warten"
}, },
"search": { "search": {
"placeholder": "Suche…" "placeholder": "Suche…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Benutzer", "users": "Benutzer",
"loginsLast24H": "Anmeldungen (24 h)", "loginsLast24H": "Anmeldungen (24 h)",
"failedLoginsLast24H": "Fehlversuche (24 h)" "failedLoginsLast24H": "Fehlversuche (24 h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "RAM", "mem": "RAM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Παρακαλώ περιμένετε" "wait": "Παρακαλώ περιμένετε"
}, },
"search": { "search": {
"placeholder": "Αναζήτηση…" "placeholder": "Αναζήτηση…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "Επεξεργαστής", "cpu": "Επεξεργαστής",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Συνδέσεις (24h)", "loginsLast24H": "Συνδέσεις (24h)",
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)" "failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Please wait" "wait": "Please wait"
}, },
"search": { "search": {
"placeholder": "Search…" "placeholder": "Search…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Bonvolu atendi" "wait": "Bonvolu atendi"
}, },
"search": { "search": {
"placeholder": "Serĉi…" "placeholder": "Serĉi…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "Ĉefprocesoro", "cpu": "Ĉefprocesoro",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+6 -3
View File
@@ -39,7 +39,9 @@
"wait": "Espera, por favor" "wait": "Espera, por favor"
}, },
"search": { "search": {
"placeholder": "Buscar…" "placeholder": "Buscar…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -133,7 +135,7 @@
"series": "Series", "series": "Series",
"episodes": "Episodios", "episodes": "Episodios",
"songs": "Canciones", "songs": "Canciones",
"albums": "Albums" "albums": "Álbumes"
}, },
"esphome": { "esphome": {
"offline": "Fuera de línea", "offline": "Fuera de línea",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Usuarios", "users": "Usuarios",
"loginsLast24H": "Inicios de sesión (24h)", "loginsLast24H": "Inicios de sesión (24h)",
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)" "failedLoginsLast24H": "Inicios de sesión fallidos (24h)",
"authorizationsLast24H": "Autenticaciones (24 h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Itxaron mesedez" "wait": "Itxaron mesedez"
}, },
"search": { "search": {
"placeholder": "Bilatu…" "placeholder": "Bilatu…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Odota, ole hyvä" "wait": "Odota, ole hyvä"
}, },
"search": { "search": {
"placeholder": "Hae…" "placeholder": "Hae…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Kirjautumisia (24h)", "loginsLast24H": "Kirjautumisia (24h)",
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)" "failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+6 -3
View File
@@ -21,7 +21,7 @@
"seconds": "s" "seconds": "s"
}, },
"auth": { "auth": {
"signout": "Sign out" "signout": "Se déconnecter"
}, },
"widget": { "widget": {
"missing_type": "Type de widget manquant : {{type}}", "missing_type": "Type de widget manquant : {{type}}",
@@ -39,7 +39,9 @@
"wait": "Veuillez patienter" "wait": "Veuillez patienter"
}, },
"search": { "search": {
"placeholder": "Recherche…" "placeholder": "Recherche…",
"provider": "Moteur de recherche : {{name}}",
"select_provider": "Sélectionnez un moteur de recherche"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Utilisateurs", "users": "Utilisateurs",
"loginsLast24H": "Connexions (24 h)", "loginsLast24H": "Connexions (24 h)",
"failedLoginsLast24H": "Connexions échouées (24 h)" "failedLoginsLast24H": "Connexions échouées (24 h)",
"authorizationsLast24H": "Authentifications (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "RAM", "mem": "RAM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "המתן בבקשה" "wait": "המתן בבקשה"
}, },
"search": { "search": {
"placeholder": "חיפוש…" "placeholder": "חיפוש…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "מעבד", "cpu": "מעבד",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "משתמשים", "users": "משתמשים",
"loginsLast24H": "כניסות (24h)", "loginsLast24H": "כניסות (24h)",
"failedLoginsLast24H": "כניסות שנכשלו (24h)" "failedLoginsLast24H": "כניסות שנכשלו (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "זיכרון", "mem": "זיכרון",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Please wait" "wait": "Please wait"
}, },
"search": { "search": {
"placeholder": "Search…" "placeholder": "Search…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Pričekaj" "wait": "Pričekaj"
}, },
"search": { "search": {
"placeholder": "Traži …" "placeholder": "Traži …",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Korisnici", "users": "Korisnici",
"loginsLast24H": "Prijave (24 h)", "loginsLast24H": "Prijave (24 h)",
"failedLoginsLast24H": "Neuspjele prijave (24 h)" "failedLoginsLast24H": "Neuspjele prijave (24 h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Kérjük várjon" "wait": "Kérjük várjon"
}, },
"search": { "search": {
"placeholder": "Keresés…" "placeholder": "Keresés…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "Processzor", "cpu": "Processzor",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Felhasználók", "users": "Felhasználók",
"loginsLast24H": "Bejelentkezések (24 óra)", "loginsLast24H": "Bejelentkezések (24 óra)",
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)" "failedLoginsLast24H": "Sikertelen bejelentkezések (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Harap tunggu" "wait": "Harap tunggu"
}, },
"search": { "search": {
"placeholder": "Telusuri…" "placeholder": "Telusuri…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Login (24j)", "loginsLast24H": "Login (24j)",
"failedLoginsLast24H": "Login Gagal (24j)" "failedLoginsLast24H": "Login Gagal (24j)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Attendi per favore" "wait": "Attendi per favore"
}, },
"search": { "search": {
"placeholder": "Cerca…" "placeholder": "Cerca…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Utenti", "users": "Utenti",
"loginsLast24H": "Accessi (24h)", "loginsLast24H": "Accessi (24h)",
"failedLoginsLast24H": "Accessi Falliti (24h)" "failedLoginsLast24H": "Accessi Falliti (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "お待ちください" "wait": "お待ちください"
}, },
"search": { "search": {
"placeholder": "検索…" "placeholder": "検索…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "ログイン (24時間)", "loginsLast24H": "ログイン (24時間)",
"failedLoginsLast24H": "ログイン失敗(24時間)" "failedLoginsLast24H": "ログイン失敗(24時間)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "잠시만 기다리세요" "wait": "잠시만 기다리세요"
}, },
"search": { "search": {
"placeholder": "검색…" "placeholder": "검색…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "사용자", "users": "사용자",
"loginsLast24H": "로그인 (24시간)", "loginsLast24H": "로그인 (24시간)",
"failedLoginsLast24H": "실패한 로그인 (24시간)" "failedLoginsLast24H": "실패한 로그인 (24시간)",
"authorizationsLast24H": "인증(24시간)"
}, },
"proxmox": { "proxmox": {
"mem": "메모리", "mem": "메모리",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Lūdzu, uzgaidiet" "wait": "Lūdzu, uzgaidiet"
}, },
"search": { "search": {
"placeholder": "Meklēt…" "placeholder": "Meklēt…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Sila tunggu" "wait": "Sila tunggu"
}, },
"search": { "search": {
"placeholder": "Carian…" "placeholder": "Carian…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logmasuk (24j)", "loginsLast24H": "Logmasuk (24j)",
"failedLoginsLast24H": "Logmasuk Gagal (24j)" "failedLoginsLast24H": "Logmasuk Gagal (24j)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Even geduld" "wait": "Even geduld"
}, },
"search": { "search": {
"placeholder": "Zoeken…" "placeholder": "Zoeken…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Gebruikers", "users": "Gebruikers",
"loginsLast24H": "Logins (24u)", "loginsLast24H": "Logins (24u)",
"failedLoginsLast24H": "Mislukte Logins (24u)" "failedLoginsLast24H": "Mislukte Logins (24u)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "GEH", "mem": "GEH",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Vennligst vent" "wait": "Vennligst vent"
}, },
"search": { "search": {
"placeholder": "Søk…" "placeholder": "Søk…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Mislykket innlogginger (24t)" "failedLoginsLast24H": "Mislykket innlogginger (24t)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+7 -4
View File
@@ -21,7 +21,7 @@
"seconds": "s" "seconds": "s"
}, },
"auth": { "auth": {
"signout": "Sign out" "signout": "Wyloguj się"
}, },
"widget": { "widget": {
"missing_type": "Brakujący typ widżetu: {{type}}", "missing_type": "Brakujący typ widżetu: {{type}}",
@@ -39,7 +39,9 @@
"wait": "Proszę czekać" "wait": "Proszę czekać"
}, },
"search": { "search": {
"placeholder": "Szukaj…" "placeholder": "Szukaj…",
"provider": "Wyszukiwarka: {{name}}",
"select_provider": "Wybierz wyszukiwarkę"
}, },
"resources": { "resources": {
"cpu": "Procesor", "cpu": "Procesor",
@@ -133,7 +135,7 @@
"series": "Seriale", "series": "Seriale",
"episodes": "Odcinki", "episodes": "Odcinki",
"songs": "Piosenki", "songs": "Piosenki",
"albums": "Albums" "albums": "Albumy"
}, },
"esphome": { "esphome": {
"offline": "Offline", "offline": "Offline",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Użytkownicy", "users": "Użytkownicy",
"loginsLast24H": "Logowania (24h)", "loginsLast24H": "Logowania (24h)",
"failedLoginsLast24H": "Nieudane logowania (24h)" "failedLoginsLast24H": "Nieudane logowania (24h)",
"authorizationsLast24H": "Uwierzytelnienia (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "RAM", "mem": "RAM",
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Por favor, aguarde" "wait": "Por favor, aguarde"
}, },
"search": { "search": {
"placeholder": "Pesquisar…" "placeholder": "Pesquisar…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Usuários", "users": "Usuários",
"loginsLast24H": "Inícios de sessão (24h)", "loginsLast24H": "Inícios de sessão (24h)",
"failedLoginsLast24H": "Inícios de sessão falhados (24h)" "failedLoginsLast24H": "Inícios de sessão falhados (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Va rugăm așteptați" "wait": "Va rugăm așteptați"
}, },
"search": { "search": {
"placeholder": "Caută…" "placeholder": "Caută…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "Procesor", "cpu": "Procesor",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Autentificări (24h)", "loginsLast24H": "Autentificări (24h)",
"failedLoginsLast24H": "Conectări eșuate (24h)" "failedLoginsLast24H": "Conectări eșuate (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+65 -62
View File
@@ -21,7 +21,7 @@
"seconds": "сек" "seconds": "сек"
}, },
"auth": { "auth": {
"signout": "Sign out" "signout": "Выход"
}, },
"widget": { "widget": {
"missing_type": "Отсутствует тип виджета: {{type}}", "missing_type": "Отсутствует тип виджета: {{type}}",
@@ -39,7 +39,9 @@
"wait": "Пожалуйста, подождите" "wait": "Пожалуйста, подождите"
}, },
"search": { "search": {
"placeholder": "Поиск…" "placeholder": "Поиск…",
"provider": "Поисковая система: {{name}}",
"select_provider": "Выберите поисковую систему"
}, },
"resources": { "resources": {
"cpu": "ЦП", "cpu": "ЦП",
@@ -75,13 +77,13 @@
"no_data": "Нет доступных данных о хранилище" "no_data": "Нет доступных данных о хранилище"
}, },
"duplicati": { "duplicati": {
"jobs": "Jobs", "jobs": "Работы",
"stored": "Stored", "stored": "Сохранено",
"lastBackup": "Last Run", "lastBackup": "Последний запуск",
"nextRun": "Next Run", "nextRun": "Следующий запуск",
"running": "Running", "running": "Запущено",
"warnings": "Warnings", "warnings": "Предупреждения",
"errors": "Errors" "errors": "Ошибки"
}, },
"docker": { "docker": {
"rx": "RX", "rx": "RX",
@@ -133,7 +135,7 @@
"series": "Сериалы", "series": "Сериалы",
"episodes": "Эпизоды", "episodes": "Эпизоды",
"songs": "Песни", "songs": "Песни",
"albums": "Albums" "albums": "Альбомы"
}, },
"esphome": { "esphome": {
"offline": "Не в сети", "offline": "Не в сети",
@@ -235,17 +237,17 @@
"rutorrent": { "rutorrent": {
"active": "Активно", "active": "Активно",
"upload": "Отдача", "upload": "Отдача",
"download": "Скачивание" "download": "Загрузка"
}, },
"transmission": { "transmission": {
"download": "Скачивание", "download": "Загрузка",
"upload": "Отдача", "upload": "Отдача",
"leech": "Лич", "leech": "Лич",
"seed": "Сид" "seed": "Сид"
}, },
"qbittorrent": { "qbittorrent": {
"download": "Скачивание", "download": "Загрузка",
"upload": "Загрузка", "upload": "Отдача",
"leech": "Лич", "leech": "Лич",
"seed": "Сид" "seed": "Сид"
}, },
@@ -258,8 +260,8 @@
"invalid": "Некорректный" "invalid": "Некорректный"
}, },
"deluge": { "deluge": {
"download": "Скачивание", "download": "Загрузка",
"upload": "Загрузка", "upload": "Отдача",
"leech": "Лич", "leech": "Лич",
"seed": "Сид" "seed": "Сид"
}, },
@@ -268,8 +270,8 @@
"cachemissbytes": "Мисс байты кэша" "cachemissbytes": "Мисс байты кэша"
}, },
"downloadstation": { "downloadstation": {
"download": "Скачивание", "download": "Загрузка",
"upload": "Загрузка", "upload": "Отдача",
"leech": "Лич", "leech": "Лич",
"seed": "Сид" "seed": "Сид"
}, },
@@ -334,8 +336,8 @@
"latency": "Задержка" "latency": "Задержка"
}, },
"speedtest": { "speedtest": {
"upload": "Загрузка", "upload": "Отдача",
"download": "Скачивание", "download": "Загрузка",
"ping": "Пинг" "ping": "Пинг"
}, },
"portainer": { "portainer": {
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Пользователи", "users": "Пользователи",
"loginsLast24H": "Входы (24ч)", "loginsLast24H": "Входы (24ч)",
"failedLoginsLast24H": "Неудачные входы (24ч)" "failedLoginsLast24H": "Неудачные входы (24ч)",
"authorizationsLast24H": "Входы (24ч)"
}, },
"proxmox": { "proxmox": {
"mem": "Память", "mem": "Память",
@@ -480,9 +483,9 @@
"vms": "Виртуальные машины" "vms": "Виртуальные машины"
}, },
"pulse": { "pulse": {
"nodes": "Nodes", "nodes": "Узлы",
"vms": "VMs", "vms": "Виртуальные машины",
"lxcs": "LXCs" "lxcs": "Контейнеры LXC"
}, },
"glances": { "glances": {
"cpu": "ЦП", "cpu": "ЦП",
@@ -581,7 +584,7 @@
"up": "В сети", "up": "В сети",
"pending": "Ожидают", "pending": "Ожидают",
"down": "Не в сети", "down": "Не в сети",
"ok": "Ok" "ok": "OK"
}, },
"healthchecks": { "healthchecks": {
"new": "Новый", "new": "Новый",
@@ -649,7 +652,7 @@
"total": "Всего" "total": "Всего"
}, },
"pangolin": { "pangolin": {
"orgs": "Orgs", "orgs": "Организации",
"sites": "Сайты", "sites": "Сайты",
"resources": "Ресурсы", "resources": "Ресурсы",
"targets": "Цели", "targets": "Цели",
@@ -687,7 +690,7 @@
"limit": "Лимит" "limit": "Лимит"
}, },
"opnsense": { "opnsense": {
"cpu": "ЦП", "cpu": "Нагрузка ЦП",
"memory": "Активно ОЗУ", "memory": "Активно ОЗУ",
"wanUpload": "WAN Загрузка", "wanUpload": "WAN Загрузка",
"wanDownload": "WAN скачивание" "wanDownload": "WAN скачивание"
@@ -822,7 +825,7 @@
"gross_percent_today": "Сегодня", "gross_percent_today": "Сегодня",
"gross_percent_1y": "Один год", "gross_percent_1y": "Один год",
"gross_percent_max": "Все время", "gross_percent_max": "Все время",
"net_worth": "Net Worth" "net_worth": "Общая стоимость"
}, },
"audiobookshelf": { "audiobookshelf": {
"podcasts": "Подкасты", "podcasts": "Подкасты",
@@ -951,15 +954,15 @@
"storage": "Хранилище" "storage": "Хранилище"
}, },
"maintainerr": { "maintainerr": {
"itemsHandled": "Items Handled", "itemsHandled": "Элементы обрабатываются",
"episodesHandled": "Episodes Handled", "episodesHandled": "Серии обрабатываются",
"moviesHandled": "Movies Handled", "moviesHandled": "Фильмы обрабатываются",
"showsHandled": "Shows Handled", "showsHandled": "Шоу обрабатываются",
"seasonsHandled": "Seasons Handled", "seasonsHandled": "Сезоны обрабатываются",
"reclaimable": "Reclaimable", "reclaimable": "Восстановленные",
"movieReclaimable": "Movie Reclaimable", "movieReclaimable": "Восстановленный фильм",
"showReclaimable": "Show Reclaimable", "showReclaimable": "Восстановленное шоу",
"totalCapacity": "Total Capacity" "totalCapacity": "Общая ёмкость"
}, },
"netdata": { "netdata": {
"warnings": "Предупреждения", "warnings": "Предупреждения",
@@ -968,7 +971,7 @@
"ntfy": { "ntfy": {
"title": "Название", "title": "Название",
"priority": "Приоритет", "priority": "Приоритет",
"lastReceived": "Last Received", "lastReceived": "Последние полученные",
"message": "Сообщение", "message": "Сообщение",
"tags": "Теги", "tags": "Теги",
"none": "Отсутствует", "none": "Отсутствует",
@@ -1105,7 +1108,7 @@
"apps": "Приложения", "apps": "Приложения",
"synced": "Синхронизированные", "synced": "Синхронизированные",
"outOfSync": "Не синхронизированные", "outOfSync": "Не синхронизированные",
"healthy": "Healthy", "healthy": "Здоров",
"degraded": "Деградированные", "degraded": "Деградированные",
"progressing": "Выполняются", "progressing": "Выполняются",
"missing": "Отсутствует", "missing": "Отсутствует",
@@ -1152,18 +1155,18 @@
"other": "Другое" "other": "Другое"
}, },
"checkmk": { "checkmk": {
"serviceErrors": "Service issues", "serviceErrors": "Проблемы сервиса",
"hostErrors": "Host issues" "hostErrors": "Проблемы узла"
}, },
"komodo": { "komodo": {
"total": "Всего", "total": "Всего",
"running": "Запущено", "running": "Запущено",
"stopped": "Остановлено", "stopped": "Остановлено",
"down": "Не в сети", "down": "Не в сети",
"unhealthy": "Unhealthy", "unhealthy": "Неисправен",
"unknown": "Неизвестно", "unknown": "Неизвестно",
"servers": "Серверы", "servers": "Серверы",
"stacks": "Stacks", "stacks": "Стеки",
"containers": "Контейнеры" "containers": "Контейнеры"
}, },
"filebrowser": { "filebrowser": {
@@ -1186,7 +1189,7 @@
"DISABLE_DISK": "Диск отключен", "DISABLE_DISK": "Диск отключен",
"SWAP_DSBL": "Swap отключён", "SWAP_DSBL": "Swap отключён",
"INVALID_EXPANSION": "Неверное Расширение", "INVALID_EXPANSION": "Неверное Расширение",
"PARITY_NOT_BIGGEST": "Parity Not Biggest", "PARITY_NOT_BIGGEST": "Четность не наибольшая",
"TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков", "TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков",
"NEW_DISK_TOO_SMALL": "Новый диск слишком мал", "NEW_DISK_TOO_SMALL": "Новый диск слишком мал",
"NO_DATA_DISKS": "Нет дисков данных", "NO_DATA_DISKS": "Нет дисков данных",
@@ -1195,18 +1198,18 @@
"cpu": "ЦП", "cpu": "ЦП",
"memoryUsed": "Использовано ОЗУ", "memoryUsed": "Использовано ОЗУ",
"memoryAvailable": "Доступная память", "memoryAvailable": "Доступная память",
"arrayUsed": "Array Used", "arrayUsed": "Используемый массив",
"arrayFree": "Array Free", "arrayFree": "Свободный массив",
"poolUsed": "{{pool}} Использовано", "poolUsed": "{{pool}} Использовано",
"poolFree": "{{pool}} Свободно" "poolFree": "{{pool}} Свободно"
}, },
"backrest": { "backrest": {
"num_plans": "Plans", "num_plans": "Планы",
"num_success_30": "Successes", "num_success_30": "Успешные",
"num_failure_30": "Ошибки", "num_failure_30": "Ошибки",
"num_success_latest": "Succeeding", "num_success_latest": "Успешно",
"num_failure_latest": "Failing", "num_failure_latest": "Неудачные",
"bytes_added_30": "Bytes Added" "bytes_added_30": "Байты добавлены"
}, },
"yourspotify": { "yourspotify": {
"songs": "Треков", "songs": "Треков",
@@ -1235,20 +1238,20 @@
"environment_not_found": "Среда не найдена" "environment_not_found": "Среда не найдена"
}, },
"sparkyfitness": { "sparkyfitness": {
"eaten": "Eaten", "eaten": "Съедено",
"burned": "Burned", "burned": "Сожжено",
"remaining": "Remaining", "remaining": "Осталось",
"steps": "Steps" "steps": "Шаги"
}, },
"syncthing": { "syncthing": {
"connected": "Connected", "connected": "Подключено",
"synced": "Synced", "synced": "Синхронизировано",
"errors": "Errors", "errors": "Ошибки",
"storage": "Storage" "storage": "Хранилище"
}, },
"sportarr": { "sportarr": {
"wanted": "Wanted", "wanted": "Требуется",
"queued": "Queued", "queued": "В очереди",
"leagues": "Leagues" "leagues": "Лиги"
} }
} }
+9 -6
View File
@@ -21,7 +21,7 @@
"seconds": "s" "seconds": "s"
}, },
"auth": { "auth": {
"signout": "Sign out" "signout": "Odhlásiť sa"
}, },
"widget": { "widget": {
"missing_type": "Chýba typ widgetu: {{type}}", "missing_type": "Chýba typ widgetu: {{type}}",
@@ -39,7 +39,9 @@
"wait": "Počkajte prosím" "wait": "Počkajte prosím"
}, },
"search": { "search": {
"placeholder": "Hľadať…" "placeholder": "Hľadať…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -133,7 +135,7 @@
"series": "Seriálov", "series": "Seriálov",
"episodes": "Epizód", "episodes": "Epizód",
"songs": "Skladieb", "songs": "Skladieb",
"albums": "Albums" "albums": "Albumov"
}, },
"esphome": { "esphome": {
"offline": "Nedostupné", "offline": "Nedostupné",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Používateľov", "users": "Používateľov",
"loginsLast24H": "Prihlás. (24 hod.)", "loginsLast24H": "Prihlás. (24 hod.)",
"failedLoginsLast24H": "Neúspešné prihlás. (24 hod.)" "failedLoginsLast24H": "Neúspešné prihlás. (24 hod.)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "RAM", "mem": "RAM",
@@ -959,7 +962,7 @@
"reclaimable": "Reclaimable", "reclaimable": "Reclaimable",
"movieReclaimable": "Movie Reclaimable", "movieReclaimable": "Movie Reclaimable",
"showReclaimable": "Show Reclaimable", "showReclaimable": "Show Reclaimable",
"totalCapacity": "Total Capacity" "totalCapacity": "Celková kapacita"
}, },
"netdata": { "netdata": {
"warnings": "Upozornenia", "warnings": "Upozornenia",
@@ -1248,7 +1251,7 @@
}, },
"sportarr": { "sportarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "V poradí",
"leagues": "Leagues" "leagues": "Leagues"
} }
} }
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Prosimo počakajte" "wait": "Prosimo počakajte"
}, },
"search": { "search": {
"placeholder": "Iskanje…" "placeholder": "Iskanje…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Prijave (24h)", "loginsLast24H": "Prijave (24h)",
"failedLoginsLast24H": "Neveljavne prijave (24h)" "failedLoginsLast24H": "Neveljavne prijave (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Молим сачекајте" "wait": "Молим сачекајте"
}, },
"search": { "search": {
"placeholder": "Претражи…" "placeholder": "Претражи…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "Процесор", "cpu": "Процесор",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Корисника", "users": "Корисника",
"loginsLast24H": "Пријаве (24ч)", "loginsLast24H": "Пријаве (24ч)",
"failedLoginsLast24H": "Неуспешне пријаве (24ч)" "failedLoginsLast24H": "Неуспешне пријаве (24ч)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "Меморија", "mem": "Меморија",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Vänligen vänta" "wait": "Vänligen vänta"
}, },
"search": { "search": {
"placeholder": "Sök…" "placeholder": "Sök…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Inloggningar (24h)", "loginsLast24H": "Inloggningar (24h)",
"failedLoginsLast24H": "Misslyckade inloggningar (24h)" "failedLoginsLast24H": "Misslyckade inloggningar (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "దయచేసి వేచి ఉండండి" "wait": "దయచేసి వేచి ఉండండి"
}, },
"search": { "search": {
"placeholder": "వెతకండి…" "placeholder": "వెతకండి…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "సీపియూ", "cpu": "సీపియూ",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "లాగిన్లు (24గం)", "loginsLast24H": "లాగిన్లు (24గం)",
"failedLoginsLast24H": "విఫలమైన లాగిన్‌లు (24గం)" "failedLoginsLast24H": "విఫలమైన లాగిన్‌లు (24గం)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "โปรดรอ" "wait": "โปรดรอ"
}, },
"search": { "search": {
"placeholder": "ค้นหา…" "placeholder": "ค้นหา…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "ซีพียู", "cpu": "ซีพียู",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Lütfen bekleyin" "wait": "Lütfen bekleyin"
}, },
"search": { "search": {
"placeholder": "Ara…" "placeholder": "Ara…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "İşlemci", "cpu": "İşlemci",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Kullanıcılar", "users": "Kullanıcılar",
"loginsLast24H": "Girişler (24 Saat)", "loginsLast24H": "Girişler (24 Saat)",
"failedLoginsLast24H": "Başarısız Girişler (24 Saat)" "failedLoginsLast24H": "Başarısız Girişler (24 Saat)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "Bellek", "mem": "Bellek",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Будь ласка, зачекайте" "wait": "Будь ласка, зачекайте"
}, },
"search": { "search": {
"placeholder": "Пошук…" "placeholder": "Пошук…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "ЦП", "cpu": "ЦП",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Користувачі", "users": "Користувачі",
"loginsLast24H": "Вхід (протягом доби)", "loginsLast24H": "Вхід (протягом доби)",
"failedLoginsLast24H": "Невдалі входи (протягом доби)" "failedLoginsLast24H": "Невдалі входи (протягом доби)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "Пам'ять", "mem": "Пам'ять",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "Vui lòng chờ" "wait": "Vui lòng chờ"
}, },
"search": { "search": {
"placeholder": "Tìm kiếm…" "placeholder": "Tìm kiếm…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "Logins (24h)", "loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)" "failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "請稍後" "wait": "請稍後"
}, },
"search": { "search": {
"placeholder": "搜索緊…" "placeholder": "搜索緊…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "登錄( 24小时)", "loginsLast24H": "登錄( 24小时)",
"failedLoginsLast24H": "登錄失敗( 24鐘頭)" "failedLoginsLast24H": "登錄失敗( 24鐘頭)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "请稍候" "wait": "请稍候"
}, },
"search": { "search": {
"placeholder": "搜索…" "placeholder": "搜索…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "登录 (24h)", "loginsLast24H": "登录 (24h)",
"failedLoginsLast24H": "登录失败 (24h)" "failedLoginsLast24H": "登录失败 (24h)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "内存", "mem": "内存",
+5 -2
View File
@@ -39,7 +39,9 @@
"wait": "請稍候" "wait": "請稍候"
}, },
"search": { "search": {
"placeholder": "搜尋…" "placeholder": "搜尋…",
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -471,7 +473,8 @@
"authentik": { "authentik": {
"users": "Users", "users": "Users",
"loginsLast24H": "登入 (過去 24 小時)", "loginsLast24H": "登入 (過去 24 小時)",
"failedLoginsLast24H": "登入失敗 (過去 24 小時)" "failedLoginsLast24H": "登入失敗 (過去 24 小時)",
"authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "MEM",
@@ -73,6 +73,7 @@ describe("pages/api/auth/[...nextauth]", () => {
it("routes sanitized NextAuth logs through the Homepage logger", async () => { it("routes sanitized NextAuth logs through the Homepage logger", async () => {
const mod = await import("pages/api/auth/[...nextauth]"); const mod = await import("pages/api/auth/[...nextauth]");
const sensitiveMetadata = { const sensitiveMetadata = {
error: Object.assign(new Error("State cookie was missing."), { access_token: "sensitive-access-token" }),
clientSecret: "sensitive-client-secret", clientSecret: "sensitive-client-secret",
access_token: "sensitive-access-token", access_token: "sensitive-access-token",
id_token: "sensitive-id-token", id_token: "sensitive-id-token",
@@ -82,7 +83,7 @@ describe("pages/api/auth/[...nextauth]", () => {
mod.authOptions.logger.warn("NEXTAUTH_URL", sensitiveMetadata); mod.authOptions.logger.warn("NEXTAUTH_URL", sensitiveMetadata);
mod.authOptions.logger.debug("OAUTH_CALLBACK_RESPONSE", sensitiveMetadata); mod.authOptions.logger.debug("OAUTH_CALLBACK_RESPONSE", sensitiveMetadata);
expect(errorMock).toHaveBeenCalledWith("%s", "OAUTH_CALLBACK_ERROR"); expect(errorMock).toHaveBeenCalledWith("%s: %s", "OAUTH_CALLBACK_ERROR", "State cookie was missing.");
expect(warnMock).toHaveBeenCalledWith("%s", "NEXTAUTH_URL"); expect(warnMock).toHaveBeenCalledWith("%s", "NEXTAUTH_URL");
expect(debugMock).toHaveBeenCalledWith("%s", "OAUTH_CALLBACK_RESPONSE"); expect(debugMock).toHaveBeenCalledWith("%s", "OAUTH_CALLBACK_RESPONSE");
expect(JSON.stringify([...errorMock.mock.calls, ...warnMock.mock.calls, ...debugMock.mock.calls])).not.toContain( expect(JSON.stringify([...errorMock.mock.calls, ...warnMock.mock.calls, ...debugMock.mock.calls])).not.toContain(
+60 -6
View File
@@ -3,21 +3,27 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const { getSettingsMock } = vi.hoisted(() => ({ const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({
getSettingsMock: vi.fn(), getSettingsMock: vi.fn(),
authOptionsMock: vi.fn(),
})); }));
vi.mock("utils/config/config", () => ({ vi.mock("utils/config/config", () => ({
getSettings: getSettingsMock, getSettings: getSettingsMock,
})); }));
vi.mock("pages/api/auth/[...nextauth]", () => ({
get authOptions() {
return authOptionsMock();
},
}));
vi.mock("next/router", () => ({ vi.mock("next/router", () => ({
useRouter: () => ({ useRouter: () => ({
query: {}, query: {},
}), }),
})); }));
import { getProviders } from "next-auth/react";
import SignInPage, { getServerSideProps } from "pages/auth/signin"; import SignInPage, { getServerSideProps } from "pages/auth/signin";
describe("pages/auth/signin", () => { describe("pages/auth/signin", () => {
@@ -33,7 +39,7 @@ describe("pages/auth/signin", () => {
/>, />,
); );
expect(screen.getByText("Authentication not configured")).toBeInTheDocument(); expect(screen.getByText("Authentication error")).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true); expect(document.documentElement.classList.contains("dark")).toBe(true);
@@ -61,7 +67,7 @@ describe("pages/auth/signin", () => {
}); });
it("getServerSideProps returns providers and only public sign-in settings", async () => { it("getServerSideProps returns providers and only public sign-in settings", async () => {
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } }); authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] });
getSettingsMock.mockReturnValueOnce({ getSettingsMock.mockReturnValueOnce({
theme: "dark", theme: "dark",
color: "slate", color: "slate",
@@ -79,11 +85,10 @@ describe("pages/auth/signin", () => {
const res = await getServerSideProps({}); const res = await getServerSideProps({});
expect(getProviders).toHaveBeenCalled();
expect(getSettingsMock).toHaveBeenCalled(); expect(getSettingsMock).toHaveBeenCalled();
expect(res).toEqual({ expect(res).toEqual({
props: { props: {
providers: { foo: { id: "foo", name: "Foo" } }, providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
settings: { settings: {
theme: "dark", theme: "dark",
color: "slate", color: "slate",
@@ -96,4 +101,53 @@ describe("pages/auth/signin", () => {
expect(res.props.settings).not.toHaveProperty("providers"); expect(res.props.settings).not.toHaveProperty("providers");
expect(res.props.settings).not.toHaveProperty("layout"); expect(res.props.settings).not.toHaveProperty("layout");
}); });
it("getServerSideProps falls back to no providers when auth options fail to load", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
authOptionsMock.mockImplementationOnce(() => {
throw new Error("Homepage auth is enabled but HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) is missing.");
});
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
const res = await getServerSideProps({});
expect(res.props.providers).toEqual({});
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
it("getServerSideProps passes only id, name and type from each provider", async () => {
authOptionsMock.mockReturnValueOnce({
providers: [
{
id: "homepage-oidc",
name: "Homepage OIDC",
type: "oauth",
issuer: "https://oidc.example",
clientId: "canary-client-id",
clientSecret: "canary-client-secret",
wellKnown: "https://oidc.example/.well-known/openid-configuration",
authorization: { params: { scope: "openid email profile" } },
profile: () => ({}),
},
{
id: "credentials",
name: "Password",
type: "credentials",
credentials: { password: { label: "Password", type: "password" } },
authorize: () => null,
},
],
});
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
const res = await getServerSideProps({});
expect(res.props.providers).toEqual({
"homepage-oidc": { id: "homepage-oidc", name: "Homepage OIDC", type: "oauth" },
credentials: { id: "credentials", name: "Password", type: "credentials" },
});
// These props get serialized into the sign-in page, which is unauthenticated
expect(JSON.stringify(res.props)).not.toMatch(/canary-client-secret|canary-client-id|oidc\.example/);
});
}); });
+53 -5
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { ColorContext } from "utils/contexts/color"; import { ColorContext } from "utils/contexts/color";
@@ -25,6 +25,7 @@ const {
throwIn: null, throwIn: null,
validateData: [], validateData: [],
hashData: null, hashData: null,
hashConfig: null,
mutateHash: vi.fn(), mutateHash: vi.fn(),
servicesData: [], servicesData: [],
bookmarksData: [], bookmarksData: [],
@@ -59,9 +60,12 @@ const {
const serverSideTranslations = vi.fn(async (language) => ({ _translations: language })); const serverSideTranslations = vi.fn(async (language) => ({ _translations: language }));
const logger = { error: vi.fn() }; const logger = { error: vi.fn() };
const useSWR = vi.fn((key) => { const useSWR = vi.fn((key, config) => {
if (key === "/api/validate") return { data: state.validateData }; if (key === "/api/validate") return { data: state.validateData };
if (key === "/api/hash") return { data: state.hashData, mutate: state.mutateHash }; if (key === "/api/hash") {
state.hashConfig = config;
return { data: state.hashData, mutate: state.mutateHash };
}
if (key === "/api/services") return { data: state.servicesData }; if (key === "/api/services") return { data: state.servicesData };
if (key === "/api/bookmarks") return { data: state.bookmarksData }; if (key === "/api/bookmarks") return { data: state.bookmarksData };
if (key === "/api/widgets") return { data: state.widgetsData }; if (key === "/api/widgets") return { data: state.widgetsData };
@@ -172,6 +176,7 @@ describe("pages/index getStaticProps", () => {
state.throwIn = null; state.throwIn = null;
state.validateData = []; state.validateData = [];
state.hashData = null; state.hashData = null;
state.hashConfig = null;
state.servicesData = []; state.servicesData = [];
state.bookmarksData = []; state.bookmarksData = [];
state.widgetsData = []; state.widgetsData = [];
@@ -351,6 +356,7 @@ describe("pages/index Index routing + SWR branches", () => {
} }
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
act(() => state.hashConfig.onSuccess(state.hashData));
await waitFor(() => { await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate"); expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
@@ -379,11 +385,24 @@ describe("pages/index Index routing + SWR branches", () => {
localStorage.removeItem("hash"); localStorage.removeItem("hash");
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
act(() => state.hashConfig.onSuccess(state.hashData));
await waitFor(() => { await waitFor(() => {
expect(localStorage.getItem("hash")).toBe("first-hash"); expect(localStorage.getItem("hash")).toBe("first-hash");
}); });
}); });
it("ignores a hash response without a hash", async () => {
state.validateData = [];
localStorage.setItem("hash", "old-hash");
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
expect(() => act(() => state.hashConfig.onSuccess(null))).not.toThrow();
expect(() => act(() => state.hashConfig.onSuccess({}))).not.toThrow();
expect(localStorage.getItem("hash")).toBe("old-hash");
expect(document.querySelector(".animate-spin")).toBeFalsy();
});
}); });
describe("pages/index Home behavior", () => { describe("pages/index Home behavior", () => {
@@ -419,11 +438,27 @@ describe("pages/index Home behavior", () => {
fireEvent.keyDown(document.body, { key: "a" }); fireEvent.keyDown(document.body, { key: "a" });
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3"); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3");
expect(state.quickLaunchProps.searchString).toBe("a");
fireEvent.keyDown(document.body, { key: "Escape" }); fireEvent.keyDown(document.body, { key: "Escape" });
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3"); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3");
}); });
it("opens search on space without seeding the query with whitespace", async () => {
await renderIndex({
initialSettings: { title: "Homepage", layout: {} },
settings: { title: "Homepage", layout: {}, language: "en" },
});
await waitFor(() => {
expect(state.quickLaunchProps).toBeTruthy();
});
fireEvent.keyDown(document.body, { key: " " });
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3");
expect(state.quickLaunchProps.searchString).toBe("");
});
it("renders services and bookmark groups when present", async () => { it("renders services and bookmark groups when present", async () => {
await renderIndex({ await renderIndex({
initialSettings: { title: "Homepage", layout: {} }, initialSettings: { title: "Homepage", layout: {} },
@@ -489,7 +524,7 @@ describe("pages/index Home behavior", () => {
state.widgetsData = []; state.widgetsData = [];
const { setTheme, setColor, setSettings } = await renderIndex({ const { setTheme, setColor, setSettings } = await renderIndex({
initialSettings: { title: "Homepage", layout: {} }, initialSettings: { title: "Homepage", layout: {}, favicon: "/x.ico" },
settings: { settings: {
title: "Homepage", title: "Homepage",
layout: {}, layout: {},
@@ -498,7 +533,6 @@ describe("pages/index Home behavior", () => {
color: "emerald", color: "emerald",
disableIndexing: true, disableIndexing: true,
base: "/base/", base: "/base/",
favicon: "/x.ico",
}, },
theme: "dark", theme: "dark",
color: "slate", color: "slate",
@@ -516,6 +550,20 @@ describe("pages/index Home behavior", () => {
expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico"); expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico");
}); });
// Safari reads the head without running JS, so the favicon has to be there before the
// settings context is populated, and mask-icon must not be
it("renders a custom favicon before the settings context is populated", async () => {
await renderIndex({
initialSettings: { title: "Homepage", layout: {}, favicon: "/x.ico" },
settings: {},
});
expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico");
expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href")).toBe("/x.ico");
expect(document.querySelector('link[rel="mask-icon"]')).toBeNull();
expect(document.querySelector('link[rel="shortcut icon"]')).toBeNull();
});
it("marks information widgets as right-aligned for known widget types", async () => { it("marks information widgets as right-aligned for known widget types", async () => {
await renderIndex({ await renderIndex({
initialSettings: { title: "Homepage", layout: {} }, initialSettings: { title: "Homepage", layout: {} },
-1
View File
@@ -15,7 +15,6 @@ export default class ErrorBoundary extends React.Component {
// You can also log error messages to an error reporting service here // You can also log error messages to an error reporting service here
if (error || errorInfo) { if (error || errorInfo) {
// eslint-disable-next-line no-console
console.error("component error: %s, info: %s", error, errorInfo); console.error("component error: %s, info: %s", error, errorInfo);
} }
} }
+129 -111
View File
@@ -15,6 +15,69 @@ const MOBILE_BUTTON_POSITIONS = {
"bottom-right": "bottom-4 right-4", "bottom-right": "bottom-4 right-4",
}; };
function parseUrl(searchString) {
if (!/.+[.:].+/.test(searchString)) return null; // basic test for probably a url
try {
return new URL(searchString.toLowerCase().startsWith("http") ? searchString : `https://${searchString}`);
} catch {
return null;
}
}
function getSearchResults({
hideVisitURL,
searchDescriptions,
searchProvider,
searchString,
searchSuggestions,
servicesAndBookmarks,
t,
url,
}) {
if (searchString.trim().length === 0) return [];
const results = servicesAndBookmarks.flatMap((result) => {
const nameMatch = result.name.toLowerCase().includes(searchString);
const descriptionMatch = searchDescriptions && result.description?.toLowerCase().includes(searchString);
if (!nameMatch && !descriptionMatch) return [];
return [{ ...result, ...(searchDescriptions && { priority: nameMatch ? 2 : +descriptionMatch }) }];
});
if (searchDescriptions) {
results.sort((a, b) => b.priority - a.priority);
}
if (searchProvider) {
results.push({
href: searchProvider.url + encodeURIComponent(searchString),
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`,
type: "search",
});
if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl && searchSuggestions[1]) {
results.push(
...searchSuggestions[1].map((suggestion) => ({
href: searchProvider.url + encodeURIComponent(suggestion),
name: suggestion,
type: "searchSuggestion",
})),
);
}
}
if (!hideVisitURL && url) {
results.unshift({
href: url.toString(),
name: `${t("quicklaunch.visit")} URL`,
type: "url",
});
}
return results;
}
export default function QuickLaunch({ servicesAndBookmarks, searchString, setSearchString, isOpen, setSearching }) { export default function QuickLaunch({ servicesAndBookmarks, searchString, setSearchString, isOpen, setSearching }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -23,9 +86,8 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
const searchField = useRef(); const searchField = useRef();
const [results, setResults] = useState([]);
const [currentItemIndex, setCurrentItemIndex] = useState(null); const [currentItemIndex, setCurrentItemIndex] = useState(null);
const [url, setUrl] = useState(null); const url = parseUrl(searchString);
const [searchSuggestions, setSearchSuggestions] = useState([]); const [searchSuggestions, setSearchSuggestions] = useState([]);
const { data: widgets } = useSWR("/api/widgets"); const { data: widgets } = useSWR("/api/widgets");
@@ -62,7 +124,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
: null; : null;
function openCurrentItem(newWindow) { function openCurrentItem(newWindow) {
const result = results[currentItemIndex]; const result = results[activeItemIndex];
window.open( window.open(
result.href, result.href,
newWindow ? "_blank" : (result.target ?? searchProvider?.target ?? settings.target ?? "_blank"), newWindow ? "_blank" : (result.target ?? searchProvider?.target ?? settings.target ?? "_blank"),
@@ -81,17 +143,9 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
function handleSearchChange(event) { function handleSearchChange(event) {
const rawSearchString = event.target.value; const rawSearchString = event.target.value;
try { setCurrentItemIndex(null);
if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url // urls keep their casing, everything else is lowercased for matching
let urlString = rawSearchString; setSearchString(parseUrl(rawSearchString) ? rawSearchString : rawSearchString.toLowerCase());
if (urlString.toLowerCase().indexOf("http") !== 0) urlString = `https://${rawSearchString}`;
setUrl(new URL(urlString)); // basic validation
setSearchString(rawSearchString);
return;
} catch (e) {
setUrl(null);
}
setSearchString(rawSearchString.toLowerCase());
} }
function handleSearchKeyDown(event) { function handleSearchKeyDown(event) {
@@ -103,18 +157,19 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
} else if (event.key === "Enter" && results.length) { } else if (event.key === "Enter" && results.length) {
closeAndReset(); closeAndReset();
openCurrentItem(event.metaKey); openCurrentItem(event.metaKey);
} else if (event.key === "ArrowDown" && results[currentItemIndex + 1]) { } else if (event.key === "ArrowDown" && results[activeItemIndex + 1]) {
setCurrentItemIndex(currentItemIndex + 1); setCurrentItemIndex(activeItemIndex + 1);
event.preventDefault(); event.preventDefault();
} else if (event.key === "ArrowUp" && currentItemIndex > 0) { } else if (event.key === "ArrowUp" && activeItemIndex > 0) {
setCurrentItemIndex(currentItemIndex - 1); setCurrentItemIndex(activeItemIndex - 1);
event.preventDefault(); event.preventDefault();
} else if ( } else if (
event.key === "ArrowRight" && event.key === "ArrowRight" &&
results[currentItemIndex] && results[activeItemIndex] &&
results[currentItemIndex].type === "searchSuggestion" results[activeItemIndex].type === "searchSuggestion"
) { ) {
setSearchString(results[currentItemIndex].name); setCurrentItemIndex(null);
setSearchString(results[activeItemIndex].name);
} }
} }
@@ -138,88 +193,51 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
} }
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); if (
searchString.trim().length === 0 ||
if (searchString.trim().length === 0) setResults([]); !searchProvider?.showSearchSuggestions ||
else { !searchProvider.suggestionUrl ||
let newResults = servicesAndBookmarks.filter((r) => { searchString.trim() === searchSuggestions[0]?.trim()
const nameMatch = r.name.toLowerCase().includes(searchString); ) {
let descriptionMatch; return undefined;
if (searchDescriptions) {
descriptionMatch = r.description?.toLowerCase().includes(searchString);
r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch; // eslint-disable-line no-param-reassign
}
return nameMatch || descriptionMatch;
});
if (searchDescriptions) {
newResults = newResults.sort((a, b) => b.priority - a.priority);
}
if (searchProvider) {
newResults.push({
href: searchProvider.url + encodeURIComponent(searchString),
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`,
type: "search",
});
if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl) {
if (searchString.trim() !== searchSuggestions[0]?.trim()) {
fetch(
`/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
searchProvider.name ?? "Custom"
}`,
{ signal: abortController.signal },
)
.then(async (searchSuggestionResult) => {
const newSearchSuggestions = await searchSuggestionResult.json();
if (newSearchSuggestions) {
if (newSearchSuggestions[1].length > 4) {
newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);
}
setSearchSuggestions(newSearchSuggestions);
}
})
.catch(() => {
// If there is an error, just ignore it. There just will be no search suggestions.
});
}
if (searchSuggestions[1]) {
newResults = newResults.concat(
searchSuggestions[1].map((suggestion) => ({
href: searchProvider.url + encodeURIComponent(suggestion),
name: suggestion,
type: "searchSuggestion",
})),
);
}
}
}
if (!hideVisitURL && url) {
newResults.unshift({
href: url.toString(),
name: `${t("quicklaunch.visit")} URL`,
type: "url",
});
}
setResults(newResults);
if (newResults.length) {
setCurrentItemIndex(0);
}
} }
return () => { const abortController = new AbortController();
abortController.abort();
}; fetch(
// eslint-disable-next-line react-hooks/exhaustive-deps `/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
}, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchSuggestions, searchProvider, url]); searchProvider.name ?? "Custom"
}`,
{ signal: abortController.signal },
)
.then(async (searchSuggestionResult) => {
const newSearchSuggestions = await searchSuggestionResult.json();
if (newSearchSuggestions) {
setCurrentItemIndex(null);
setSearchSuggestions([newSearchSuggestions[0], newSearchSuggestions[1].slice(0, 4)]);
}
})
.catch(() => {
// If there is an error, just ignore it. There just will be no search suggestions.
});
return () => abortController.abort();
}, [searchProvider, searchString, searchSuggestions]);
const results = getSearchResults({
hideVisitURL,
searchDescriptions,
searchProvider,
searchString,
searchSuggestions,
servicesAndBookmarks,
t,
url,
});
const activeItemIndex = currentItemIndex ?? (results.length ? 0 : null);
const [hidden, setHidden] = useState(true);
useEffect(() => { useEffect(() => {
function handleBackdropClick(event) { function handleBackdropClick(event) {
if (event.target?.tagName === "DIV") closeAndReset(); if (event.target?.tagName === "DIV") closeAndReset();
@@ -228,14 +246,11 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
if (isOpen) { if (isOpen) {
searchField.current.focus(); searchField.current.focus();
document.body.addEventListener("click", handleBackdropClick); document.body.addEventListener("click", handleBackdropClick);
setHidden(false);
} else { } else {
document.body.removeEventListener("click", handleBackdropClick);
searchField.current.blur(); searchField.current.blur();
setTimeout(() => {
setHidden(true);
}, 300); // disable on close
} }
return () => document.body.removeEventListener("click", handleBackdropClick);
}, [isOpen, closeAndReset]); }, [isOpen, closeAndReset]);
function highlightText(text) { function highlightText(text) {
@@ -244,7 +259,6 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
<span> <span>
{parts.map((part, i) => {parts.map((part, i) =>
part.toLowerCase() === searchString.toLowerCase() ? ( part.toLowerCase() === searchString.toLowerCase() ? (
// eslint-disable-next-line react/no-array-index-key
<span key={`${searchString}_${i}`} className="bg-theme-300/10"> <span key={`${searchString}_${i}`} className="bg-theme-300/10">
{part} {part}
</span> </span>
@@ -260,13 +274,17 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
<> <>
<div <div
className={classNames( className={classNames(
"relative z-40 ease-in-out duration-300 transition-opacity", "relative z-40 ease-in-out",
hidden && !isOpen && "hidden", isOpen ? "visible opacity-100" : "invisible opacity-0 pointer-events-none",
!hidden && isOpen && "opacity-100",
!isOpen && "opacity-0",
)} )}
style={{
transitionProperty: "opacity, visibility",
transitionDuration: "300ms, 0s",
transitionDelay: isOpen ? "0s, 0s" : "0s, 300ms",
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-hidden={!isOpen}
> >
<div className="fixed inset-0 bg-gray-500 opacity-50" /> <div className="fixed inset-0 bg-gray-500 opacity-50" />
<div className="fixed inset-0 z-20 overflow-y-auto"> <div className="fixed inset-0 z-20 overflow-y-auto">
@@ -298,7 +316,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
onKeyDown={handleItemKeyDown} onKeyDown={handleItemKeyDown}
className={classNames( className={classNames(
"flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200", "flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200",
i === currentItemIndex && "bg-theme-300/50 dark:bg-theme-700/50", i === activeItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
)} )}
> >
<div className="flex flex-row items-center mr-4 pointer-events-none"> <div className="flex flex-row items-center mr-4 pointer-events-none">
+48 -7
View File
@@ -46,13 +46,25 @@ function Wrapper({ servicesAndBookmarks = [], initialOpen = true } = {}) {
const [isOpen, setSearching] = useState(initialOpen); const [isOpen, setSearching] = useState(initialOpen);
return ( return (
<QuickLaunch <>
servicesAndBookmarks={servicesAndBookmarks} <button
searchString={searchString} type="button"
setSearchString={setSearchString} data-testid="seed-key"
isOpen={isOpen} onClick={() => {
setSearching={setSearching} setSearchString((current) => `${current}g`);
/> setSearching(true);
}}
>
seed
</button>
<QuickLaunch
servicesAndBookmarks={servicesAndBookmarks}
searchString={searchString}
setSearchString={setSearchString}
isOpen={isOpen}
setSearching={setSearching}
/>
</>
); );
} }
@@ -151,6 +163,35 @@ describe("components/quicklaunch", () => {
openSpy.mockRestore(); openSpy.mockRestore();
}); });
it("does not carry a previous url result into a search seeded by a keypress", async () => {
renderWithProviders(<Wrapper />, {
settings: {
target: "_self",
quicklaunch: {
provider: "duckduckgo",
showSearchSuggestions: false,
},
},
});
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "example.com" } });
expect(await screen.findByText("quicklaunch.visit URL")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Escape" });
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
// reopen by "typing" a character, the way pages/index does
fireEvent.click(screen.getByTestId("seed-key"));
expect(input).toHaveValue("g");
expect(screen.queryByText("quicklaunch.visit URL")).not.toBeInTheDocument();
});
it("closes on Escape and clears the search string after the timeout", async () => { it("closes on Escape and clears the search string after the timeout", async () => {
renderWithProviders(<Wrapper />, { renderWithProviders(<Wrapper />, {
settings: { settings: {
+3 -3
View File
@@ -13,13 +13,13 @@ export default function Error({ error }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (typeof error === "string") { if (typeof error === "string") {
error = { message: error }; // eslint-disable-line no-param-reassign error = { message: error };
} else if (typeof error === "number") { } else if (typeof error === "number") {
error = { message: `Error ${error}` }; // eslint-disable-line no-param-reassign error = { message: `Error ${error}` };
} }
if (error?.data?.error) { if (error?.data?.error) {
error = error.data.error; // eslint-disable-line no-param-reassign error = error.data.error;
} }
return ( return (
+6 -11
View File
@@ -1,9 +1,11 @@
import { useTranslation } from "next-i18next/pages"; import { useTranslation } from "next-i18next/pages";
import { useEffect, useState } from "react"; import { useMemo } from "react";
import Container from "../widget/container"; import Container from "../widget/container";
import Raw from "../widget/raw"; import Raw from "../widget/raw";
import useCurrentTime from "utils/hooks/use-current-time";
const textSizes = { const textSizes = {
"4xl": "text-4xl", "4xl": "text-4xl",
"3xl": "text-3xl", "3xl": "text-3xl",
@@ -18,17 +20,10 @@ const textSizes = {
export default function DateTime({ options }) { export default function DateTime({ options }) {
const { text_size: textSize, locale, format } = options; const { text_size: textSize, locale, format } = options;
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [date, setDate] = useState("");
const dateLocale = locale ?? i18n.language; const dateLocale = locale ?? i18n.language;
const currentTime = useCurrentTime(1000);
useEffect(() => { const dateFormatter = useMemo(() => new Intl.DateTimeFormat(dateLocale, { ...format }), [dateLocale, format]);
const dateFormat = new Intl.DateTimeFormat(dateLocale, { ...format }); const date = currentTime === null ? "" : dateFormatter.format(new Date(currentTime));
setDate(dateFormat.format(new Date()));
const interval = setInterval(() => {
setDate(dateFormat.format(new Date()));
}, 1000);
return () => clearInterval(interval);
}, [date, setDate, dateLocale, format]);
return ( return (
<Container options={options} additionalClassNames="information-widget-datetime"> <Container options={options} additionalClassNames="information-widget-datetime">
+2 -3
View File
@@ -7,10 +7,9 @@ import Resource from "../widget/resource";
export default function Network({ options, refresh = 1500 }) { export default function Network({ options, refresh = 1500 }) {
const { t } = useTranslation(); const { t } = useTranslation();
// eslint-disable-next-line no-param-reassign const network = options.network === true ? "default" : options.network;
if (options.network === true) options.network = "default";
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, { const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${network}`, {
refreshInterval: refresh, refreshInterval: refresh,
}); });
@@ -22,10 +22,12 @@ describe("components/widgets/resources/network", () => {
it("normalizes options.network=true to default interfaceName in the request", () => { it("normalizes options.network=true to default interfaceName in the request", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined }); useSWR.mockReturnValue({ data: undefined, error: undefined });
const options = Object.freeze({ network: true });
render(<Network options={{ network: true }} />); render(<Network options={options} />);
expect(useSWR).toHaveBeenCalledWith(expect.stringContaining("interfaceName=default"), expect.any(Object)); expect(useSWR).toHaveBeenCalledWith(expect.stringContaining("interfaceName=default"), expect.any(Object));
expect(options.network).toBe(true);
}); });
it("renders rates and usage percentage when data is present", () => { it("renders rates and usage percentage when data is present", () => {
+53 -54
View File
@@ -11,7 +11,7 @@ import {
} from "@headlessui/react"; } from "@headlessui/react";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "next-i18next/pages"; import { useTranslation } from "next-i18next/pages";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { BiLogoBing } from "react-icons/bi"; import { BiLogoBing } from "react-icons/bi";
import { FiSearch } from "react-icons/fi"; import { FiSearch } from "react-icons/fi";
import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si"; import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si";
@@ -57,12 +57,12 @@ export const searchProviders = {
}, },
}; };
function getAvailableProviderIds(options) { function getAvailableProviderIds(provider) {
if (options.provider && Array.isArray(options.provider)) { if (provider && Array.isArray(provider)) {
return options.provider.filter((value) => searchProviders.hasOwnProperty(value)); return provider.filter((value) => searchProviders.hasOwnProperty(value));
} }
if (options.provider && searchProviders[options.provider]) { if (provider && searchProviders[provider]) {
return [options.provider]; return [provider];
} }
return null; return null;
} }
@@ -79,23 +79,32 @@ export function getStoredProvider() {
return null; return null;
} }
function subscribeToStoredProvider(onStoreChange) {
const handleStorage = (event) => {
if (event.key === localStorageKey) onStoreChange();
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}
export default function Search({ options }) { export default function Search({ options }) {
const { t } = useTranslation(); const { t } = useTranslation();
const availableProviderIds = getAvailableProviderIds(options) ?? []; // options is a fresh object each render, so memo on provider itself
const availableProviderIds = useMemo(() => getAvailableProviderIds(options.provider) ?? [], [options.provider]);
const storedProvider = useSyncExternalStore(subscribeToStoredProvider, getStoredProvider, () => null);
const storedProviderId = Object.keys(searchProviders).find(
(providerId) => searchProviders[providerId] === storedProvider,
);
const initialProvider = availableProviderIds.includes(storedProviderId)
? storedProvider
: searchProviders[availableProviderIds[0] ?? "google"];
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]); const [providerOverride, setProviderOverride] = useState(null);
const [searchSuggestions, setSearchSuggestions] = useState([]); const [searchSuggestions, setSearchSuggestions] = useState([]);
const selectedProvider = providerOverride ?? initialProvider;
useEffect(() => {
const storedProvider = getStoredProvider();
let storedProviderKey = null;
storedProviderKey = Object.keys(searchProviders).find((pkey) => searchProviders[pkey] === storedProvider);
if (storedProvider && availableProviderIds.includes(storedProviderKey)) {
setSelectedProvider(storedProvider);
}
}, [availableProviderIds]);
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@@ -129,8 +138,6 @@ export default function Search({ options }) {
}; };
}, [selectedProvider, options, query, searchSuggestions]); }, [selectedProvider, options, query, searchSuggestions]);
let currentSuggestion;
function doSearch(value) { function doSearch(value) {
const q = encodeURIComponent(value); const q = encodeURIComponent(value);
const { url } = selectedProvider; const { url } = selectedProvider;
@@ -141,13 +148,11 @@ export default function Search({ options }) {
} }
setQuery(""); setQuery("");
currentSuggestion = null;
} }
const handleSearchKeyDown = (event) => { const handleSearchKeyDown = (event) => {
const useSuggestion = searchSuggestions.length && currentSuggestion; if (event.key === "Enter" && !searchSuggestions[1]?.length) {
if (event.key === "Enter") { doSearch(event.target.value);
doSearch(useSuggestion ? currentSuggestion : event.target.value);
} }
}; };
@@ -156,7 +161,7 @@ export default function Search({ options }) {
} }
const onChangeProvider = (provider) => { const onChangeProvider = (provider) => {
setSelectedProvider(provider); setProviderOverride(provider);
localStorage.setItem(localStorageKey, provider.name); localStorage.setItem(localStorageKey, provider.name);
}; };
@@ -165,7 +170,7 @@ export default function Search({ options }) {
<Raw> <Raw>
<div className="flex-col relative h-8 my-4 min-w-fit z-20"> <div className="flex-col relative h-8 my-4 min-w-fit z-20">
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" /> <div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
<Combobox value={query}> <Combobox value={query} onChange={doSearch}>
<ComboboxInput <ComboboxInput
type="text" type="text"
className=" className="
@@ -177,6 +182,7 @@ export default function Search({ options }) {
focus:border-theme-500 dark:focus:border-white/50 focus:border-theme-500 dark:focus:border-white/50
border border-theme-300 dark:border-theme-200/50" border border-theme-300 dark:border-theme-200/50"
placeholder={t("search.placeholder")} placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
onChange={(event) => { onChange={(event) => {
setQuery(event.target.value); setQuery(event.target.value);
}} }}
@@ -184,7 +190,6 @@ export default function Search({ options }) {
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
autoComplete="off" autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus} autoFocus={options.focus}
onBlur={(e) => e.preventDefault()} onBlur={(e) => e.preventDefault()}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
@@ -204,8 +209,8 @@ export default function Search({ options }) {
bg-theme-600/40 dark:bg-white/10 bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50" focus:ring-theme-500 dark:focus:ring-white/50"
> >
<selectedProvider.icon className="text-white w-3 h-3" /> <selectedProvider.icon className="text-white w-3 h-3" aria-hidden="true" />
<span className="sr-only">{t("search.search")}</span> <span className="sr-only">{t("search.provider", { name: selectedProvider.name })}</span>
</ListboxButton> </ListboxButton>
</div> </div>
<Transition <Transition
@@ -218,6 +223,7 @@ export default function Search({ options }) {
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<ListboxOptions <ListboxOptions
aria-label={t("search.select_provider")}
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
bg-theme-100 dark:bg-theme-600 shadow-lg bg-theme-100 dark:bg-theme-600 shadow-lg
ring-1 ring-black ring-opacity-5 focus:outline-hidden" ring-1 ring-black ring-opacity-5 focus:outline-hidden"
@@ -234,7 +240,8 @@ export default function Search({ options }) {
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100", active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
)} )}
> >
<p.icon className="h-4 w-4 mx-4 my-2" /> <p.icon className="h-4 w-4 mx-4 my-2" aria-hidden="true" />
<span className="sr-only">{p.name}</span>
</li> </li>
)} )}
</ListboxOption> </ListboxOption>
@@ -248,32 +255,24 @@ export default function Search({ options }) {
{searchSuggestions[1]?.length > 0 && ( {searchSuggestions[1]?.length > 0 && (
<ComboboxOptions className="mt-1 rounded-md bg-theme-50 dark:bg-theme-800 border border-theme-300 dark:border-theme-200/30 cursor-pointer shadow-lg"> <ComboboxOptions className="mt-1 rounded-md bg-theme-50 dark:bg-theme-800 border border-theme-300 dark:border-theme-200/30 cursor-pointer shadow-lg">
<div className="p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs"> <div className="p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs">
<ComboboxOption key={query} value={query} /> <ComboboxOption key={query} value={query}>
<span className="sr-only">{query}</span>
</ComboboxOption>
{searchSuggestions[1].map((suggestion) => ( {searchSuggestions[1].map((suggestion) => (
<ComboboxOption <ComboboxOption key={suggestion} value={suggestion} className="flex w-full">
key={suggestion} {({ active }) => (
value={suggestion} <div
onMouseDown={() => { className={classNames(
doSearch(suggestion); "px-2 py-1 rounded-md w-full flex-nowrap",
}} active ? "bg-theme-300/20 dark:bg-white/10" : "",
className="flex w-full" )}
> >
{({ active }) => { <span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
if (active) currentSuggestion = suggestion; <span className="mr-4 whitespace-pre opacity-50">
return ( {suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
<div </span>
className={classNames( </div>
"px-2 py-1 rounded-md w-full flex-nowrap", )}
active ? "bg-theme-300/20 dark:bg-white/10" : "",
)}
>
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
<span className="mr-4 whitespace-pre opacity-50">
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
</span>
</div>
);
}}
</ComboboxOption> </ComboboxOption>
))} ))}
</div> </div>
+37 -2
View File
@@ -9,6 +9,7 @@ import { renderWithProviders } from "test-utils/render-with-providers";
vi.mock("@headlessui/react", async () => { vi.mock("@headlessui/react", async () => {
const React = await import("react"); const React = await import("react");
const { Fragment, createContext, useContext } = React; const { Fragment, createContext, useContext } = React;
const ComboboxContext = createContext(null);
const ListboxContext = createContext(null); const ListboxContext = createContext(null);
function passthrough({ as: As = "div", children, ...props }) { function passthrough({ as: As = "div", children, ...props }) {
@@ -18,9 +19,21 @@ vi.mock("@headlessui/react", async () => {
} }
return { return {
Combobox: passthrough, Combobox: ({ onChange, children }) => (
<ComboboxContext.Provider value={{ onChange }}>
<div>{children}</div>
</ComboboxContext.Provider>
),
ComboboxInput: (props) => <input {...props} />, ComboboxInput: (props) => <input {...props} />,
ComboboxOption: passthrough, ComboboxOption: ({ as: As = "div", value, children, ...props }) => {
const ctx = useContext(ComboboxContext);
const content = typeof children === "function" ? children({ active: false }) : children;
return (
<As value={value} onMouseDown={() => ctx?.onChange?.(value)} {...props}>
{content}
</As>
);
},
ComboboxOptions: passthrough, ComboboxOptions: passthrough,
Listbox: ({ value, onChange, children, ...props }) => ( Listbox: ({ value, onChange, children, ...props }) => (
<ListboxContext.Provider value={{ value, onChange }}> <ListboxContext.Provider value={{ value, onChange }}>
@@ -165,6 +178,28 @@ describe("components/widgets/search", () => {
openSpy.mockRestore(); openSpy.mockRestore();
}); });
it("gives every provider option an accessible name", () => {
renderWithProviders(
<Search options={{ provider: ["google", "duckduckgo", "brave"], showSearchSuggestions: false }} />,
{ settings: {} },
);
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
"Google",
"DuckDuckGo",
"Brave",
]);
});
it("labels the search input and the provider button", () => {
renderWithProviders(<Search options={{ provider: ["google", "duckduckgo"], showSearchSuggestions: false }} />, {
settings: {},
});
expect(screen.getByRole("textbox")).toHaveAccessibleName("search.placeholder");
expect(screen.getByRole("button")).toHaveAccessibleName(/search\.provider/);
});
it("fetches search suggestions and triggers a search when a suggestion is selected", async () => { it("fetches search suggestions and triggers a search when a suggestion is selected", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
@@ -14,12 +14,11 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Widget({ options }) { export default function Widget({ options }) {
const { t } = useTranslation(); const { t } = useTranslation();
// eslint-disable-next-line no-param-reassign, no-multi-assign const widgetOptions = { ...options, service_group: "unifi_console", service_name: "unifi_console" };
options.service_group = options.service_name = "unifi_console"; const { data: statsData, error: statsError } = useWidgetAPI(widgetOptions, "stat/sites", { index: options.index });
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
if (statsError) { if (statsError) {
return <Error options={options} />; return <Error options={widgetOptions} />;
} }
const defaultSite = options.site const defaultSite = options.site
@@ -28,27 +27,27 @@ export default function Widget({ options }) {
if (!defaultSite) { if (!defaultSite) {
return ( return (
<Container options={options} additionalClassNames="information-widget-unifi-console"> <Container options={widgetOptions} additionalClassNames="information-widget-unifi-console">
<PrimaryText>{t("unifi.wait")}</PrimaryText> <PrimaryText>{t("unifi.wait")}</PrimaryText>
<WidgetIcon icon={SiUbiquiti} /> <WidgetIcon icon={SiUbiquiti} />
</Container> </Container>
); );
} }
const wan = defaultSite.health.find((h) => h.subsystem === "wan"); const getHealth = (subsystem) => {
const lan = defaultSite.health.find((h) => h.subsystem === "lan"); const health = defaultSite.health.find((item) => item.subsystem === subsystem) ?? { status: "unknown" };
const wlan = defaultSite.health.find((h) => h.subsystem === "wlan"); return { ...health, up: health.status === "ok", show: health.status !== "unknown" };
[wan, lan, wlan].forEach((s) => { };
s.up = s.status === "ok"; // eslint-disable-line no-param-reassign const wan = getHealth("wan");
s.show = s.status !== "unknown"; // eslint-disable-line no-param-reassign const lan = getHealth("lan");
}); const wlan = getHealth("wlan");
const name = wan.gw_name ?? defaultSite.desc; const name = wan.gw_name ?? defaultSite.desc;
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null; const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
const dataEmpty = !(wan.show || lan.show || wlan.show || uptime); const dataEmpty = !(wan.show || lan.show || wlan.show || uptime);
return ( return (
<Container options={options} additionalClassNames="information-widget-unifi-console"> <Container options={widgetOptions} additionalClassNames="information-widget-unifi-console">
<Raw> <Raw>
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> <div className="flex-none flex flex-row items-center mr-3 py-1.5">
<div className="flex flex-col"> <div className="flex flex-col">
@@ -40,32 +40,41 @@ describe("components/widgets/unifi_console", () => {
it("renders a wait state when no site is available yet", () => { it("renders a wait state when no site is available yet", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const options = Object.freeze({ index: 0 });
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } }); renderWithProviders(<UnifiConsole options={options} />, { settings: { target: "_self" } });
expect(screen.getByText("unifi.wait")).toBeInTheDocument(); expect(screen.getByText("unifi.wait")).toBeInTheDocument();
expect(useWidgetAPI).toHaveBeenCalledWith(
{ index: 0, service_group: "unifi_console", service_name: "unifi_console" },
"stat/sites",
{ index: 0 },
);
expect(options).toEqual({ index: 0 });
}); });
it("renders site name and uptime when data is available", () => { it("renders site name and uptime when data is available", () => {
const data = {
data: [
{
name: "default",
desc: "Home",
health: [
{
subsystem: "wan",
status: "ok",
gw_name: "Router",
"gw_system-stats": { uptime: 172800 },
},
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
};
const originalData = structuredClone(data);
useWidgetAPI.mockReturnValue({ useWidgetAPI.mockReturnValue({
data: { data,
data: [
{
name: "default",
desc: "Home",
health: [
{
subsystem: "wan",
status: "ok",
gw_name: "Router",
"gw_system-stats": { uptime: 172800 },
},
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
error: undefined, error: undefined,
}); });
@@ -75,6 +84,7 @@ describe("components/widgets/unifi_console", () => {
// common.number is mocked to return the numeric value as a string. // common.number is mocked to return the numeric value as a string.
expect(screen.getByText("2")).toBeInTheDocument(); expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("unifi.days")).toBeInTheDocument(); expect(screen.getByText("unifi.days")).toBeInTheDocument();
expect(data).toEqual(originalData);
}); });
it("selects a site by description when options.site is set", () => { it("selects a site by description when options.site is set", () => {
@@ -10,7 +10,6 @@ import WidgetIcon from "./widget_icon";
export function getAllClasses(options, additionalClassNames = "") { export function getAllClasses(options, additionalClassNames = "") {
if (options?.style?.header === "boxedWidgets") { if (options?.style?.header === "boxedWidgets") {
if (options?.style?.cardBlur !== undefined) { if (options?.style?.cardBlur !== undefined) {
// eslint-disable-next-line no-param-reassign
additionalClassNames = [ additionalClassNames = [
additionalClassNames, additionalClassNames,
`backdrop-blur${options.style.cardBlur.length ? "-" : ""}${options.style.cardBlur}`, `backdrop-blur${options.style.cardBlur.length ? "-" : ""}${options.style.cardBlur}`,
+7
View File
@@ -0,0 +1,7 @@
import { applyNextAuthEnv } from "utils/env";
export function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
applyNextAuthEnv();
}
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("instrumentation", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
delete process.env.NEXTAUTH_SECRET;
delete process.env.NEXTAUTH_URL;
delete process.env.HOMEPAGE_AUTH_SECRET;
delete process.env.HOMEPAGE_EXTERNAL_URL;
process.env.NEXT_RUNTIME = "nodejs";
});
afterEach(() => {
process.env = originalEnv;
});
it("maps HOMEPAGE_* auth envs to their NextAuth equivalents", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const { register } = await import("./instrumentation");
register();
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
});
it("does not override explicitly configured NextAuth envs", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
process.env.NEXTAUTH_SECRET = "explicit-secret";
process.env.NEXTAUTH_URL = "https://explicit.example";
const { register } = await import("./instrumentation");
register();
expect(process.env.NEXTAUTH_SECRET).toBe("explicit-secret");
expect(process.env.NEXTAUTH_URL).toBe("https://explicit.example");
});
it("is a no-op outside the node runtime", async () => {
process.env.NEXT_RUNTIME = "edge";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const { register } = await import("./instrumentation");
register();
expect(process.env.NEXTAUTH_URL).toBeUndefined();
});
});
+2 -1
View File
@@ -33,7 +33,8 @@ export async function middleware(req) {
} }
const pathname = new URL(req.url).pathname; const pathname = new URL(req.url).pathname;
if (authEnabled && !pathname.startsWith("/api/healthcheck")) { const isPublicAuthPath = pathname.startsWith("/api/healthcheck") || pathname === "/api/config/custom.css";
if (authEnabled && !isPublicAuthPath) {
// The MCP API handler authorizes both bearer tokens and Homepage sessions. // The MCP API handler authorizes both bearer tokens and Homepage sessions.
if (pathname === "/api/mcp") { if (pathname === "/api/mcp") {
return withPrivateCache(NextResponse.next()); return withPrivateCache(NextResponse.next());
+25
View File
@@ -100,6 +100,31 @@ describe("middleware", () => {
expect(res.type).toBe("next"); expect(res.type).toBe("next");
}); });
it("allows custom CSS without auth so it can style the signin page", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
const middleware = await loadMiddleware();
const res = await middleware(createReq("localhost:3000", "http://localhost:3000/api/config/custom.css"));
expect(getToken).not.toHaveBeenCalled();
expect(NextResponse.next).toHaveBeenCalled();
expect(res.type).toBe("next");
});
it("continues to require auth for custom JavaScript", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
getToken.mockResolvedValueOnce(null);
const middleware = await loadMiddleware();
const res = await middleware(createReq("localhost:3000", "http://localhost:3000/api/config/custom.js"));
expect(getToken).toHaveBeenCalled();
expect(res.type).toBe("redirect");
});
it.each(["false", "0", "no", "off", ""])("treats HOMEPAGE_AUTH_ENABLED=%j as disabled", async (value) => { it.each(["false", "0", "no", "off", ""])("treats HOMEPAGE_AUTH_ENABLED=%j as disabled", async (value) => {
process.env.HOMEPAGE_AUTH_ENABLED = value; process.env.HOMEPAGE_AUTH_ENABLED = value;
-2
View File
@@ -1,4 +1,3 @@
/* eslint-disable react/jsx-props-no-spreading */
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next/pages"; import { appWithTranslation } from "next-i18next/pages";
import Head from "next/head"; import Head from "next/head";
@@ -13,7 +12,6 @@ import { ThemeProvider } from "utils/contexts/theme";
import nextI18nextConfig from "../../next-i18next.config"; import nextI18nextConfig from "../../next-i18next.config";
// eslint-disable-next-line no-unused-vars
const tailwindSafelist = [ const tailwindSafelist = [
// TODO: remove pending https://github.com/tailwindlabs/tailwindcss/pull/17147 // TODO: remove pending https://github.com/tailwindlabs/tailwindcss/pull/17147
"backdrop-blur", "backdrop-blur",
+14 -11
View File
@@ -3,7 +3,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from "next-auth/providers/credentials";
import { isAuthEnabled } from "utils/env"; import { applyNextAuthEnv, isAuthEnabled } from "utils/env";
import createLogger from "utils/logger"; import createLogger from "utils/logger";
const MIN_AUTH_SECRET_LENGTH = 32; const MIN_AUTH_SECRET_LENGTH = 32;
@@ -12,20 +12,13 @@ const authEnabled = isAuthEnabled();
const issuer = process.env.HOMEPAGE_OIDC_ISSUER; const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID; const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET; const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET;
const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL;
const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD; const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
const homepageAuthPasswordDigest = homepageAuthPassword const homepageAuthPasswordDigest = homepageAuthPassword
? createHash("sha256").update(homepageAuthPassword, "utf8").digest() ? createHash("sha256").update(homepageAuthPassword, "utf8").digest()
: null; : null;
// Map HOMEPAGE_* envs to what NextAuth expects // Also done in instrumentation.js
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) { applyNextAuthEnv();
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
}
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
process.env.NEXTAUTH_URL = homepageExternalUrl;
}
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile"; const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer; const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
@@ -78,6 +71,16 @@ function logFailedPasswordSignIn() {
createLogger("nextauth").warn("Failed password sign-in attempt"); createLogger("nextauth").warn("Failed password sign-in attempt");
} }
function logNextAuthError(code, metadata) {
const error = metadata instanceof Error ? metadata : metadata?.error;
if (error?.message) {
createLogger("nextauth").error("%s: %s", code, error.message);
} else {
createLogger("nextauth").error("%s", code);
}
}
let providers = []; let providers = [];
if (authEnabled) { if (authEnabled) {
if (hasOidcConfig) { if (hasOidcConfig) {
@@ -147,7 +150,7 @@ export const authOptions = {
signIn: "/auth/signin", signIn: "/auth/signin",
}, },
logger: { logger: {
error: (code) => createLogger("nextauth").error("%s", code), error: logNextAuthError,
warn: (code) => createLogger("nextauth").warn("%s", code), warn: (code) => createLogger("nextauth").warn("%s", code),
debug: (code) => createLogger("nextauth").debug("%s", code), debug: (code) => createLogger("nextauth").debug("%s", code),
}, },
+6 -5
View File
@@ -15,6 +15,12 @@ export default async function handler(req, res) {
return res.status(404).end("Not Found"); return res.status(404).end("Not Found");
} }
// Method check precedes auth so CORS preflights aren't answered with a 401.
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).end("Method Not Allowed");
}
const tokenError = mcpTokenConfigError(); const tokenError = mcpTokenConfigError();
if (tokenError) { if (tokenError) {
createLogger("mcp").error(tokenError); createLogger("mcp").error(tokenError);
@@ -25,11 +31,6 @@ export default async function handler(req, res) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).end("Method Not Allowed");
}
const response = handleMcpRequest(req.body); const response = handleMcpRequest(req.body);
if (!response) { if (!response) {
return res.status(202).end(); return res.status(202).end();
+34
View File
@@ -214,4 +214,38 @@ describe("pages/api/mcp", () => {
expect(res.status).toHaveBeenCalledWith(405); expect(res.status).toHaveBeenCalledWith(405);
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST"); expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
}); });
it("answers unauthenticated CORS preflights with 405 rather than 401", async () => {
process.env.HOMEPAGE_MCP_ENABLED = "true";
process.env.HOMEPAGE_MCP_TOKEN = "mcp-tok-0123456789abcdefghijklmnopqrstuv";
const handler = await loadHandler();
const res = mockResponse();
// a preflight never carries the Authorization header the browser strips
await handler(
{
method: "OPTIONS",
headers: {
origin: "https://claude.ai",
"access-control-request-method": "POST",
"access-control-request-headers": "authorization,content-type",
},
},
res,
);
expect(res.status).toHaveBeenCalledWith(405);
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
expect(getServerSession).not.toHaveBeenCalled();
});
it("still returns 404 for non-POST requests while disabled", async () => {
delete process.env.HOMEPAGE_MCP_ENABLED;
const handler = await loadHandler();
const res = mockResponse();
await handler({ method: "OPTIONS", headers: {} }, res);
expect(res.status).toHaveBeenCalledWith(404);
});
}); });
+13 -6
View File
@@ -1,5 +1,5 @@
import classNames from "classnames"; import classNames from "classnames";
import { getProviders, signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BiShieldQuarter } from "react-icons/bi"; import { BiShieldQuarter } from "react-icons/bi";
@@ -94,10 +94,8 @@ export default function SignIn({ providers, settings }) {
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300"> <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
<BiShieldQuarter className="h-6 w-6" /> <BiShieldQuarter className="h-6 w-6" />
</div> </div>
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100"> <h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">Authentication error</h1>
Authentication not configured <p className="mt-3 text-sm text-gray-600 dark:text-slate-400">Auth is disabled or misconfigured.</p>
</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
</div> </div>
</main> </main>
</> </>
@@ -202,7 +200,16 @@ export default function SignIn({ providers, settings }) {
} }
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const providers = await getProviders(); // Avoid getProviders() fetch
let providers = {};
try {
// Dynamic so a bad config throws in here rather than at page load
const { authOptions } = await import("pages/api/auth/[...nextauth]");
providers = Object.fromEntries(authOptions.providers.map(({ id, name, type }) => [id, { id, name, type }]));
} catch (e) {
console.error("Unable to load auth providers: %s", e.message);
}
const homepageSettings = getSettings(); const homepageSettings = getSettings();
const settings = Object.fromEntries( const settings = Object.fromEntries(
PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [ PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [
+36 -30
View File
@@ -1,4 +1,3 @@
/* eslint-disable react/no-array-index-key */
import classNames from "classnames"; import classNames from "classnames";
import BookmarksGroup from "components/bookmarks/group"; import BookmarksGroup from "components/bookmarks/group";
import ErrorBoundary from "components/errorboundry"; import ErrorBoundary from "components/errorboundry";
@@ -13,7 +12,7 @@ import dynamic from "next/dynamic";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Script from "next/script"; import Script from "next/script";
import { useContext, useEffect, useMemo, useState } from "react"; import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { BiError } from "react-icons/bi"; import { BiError } from "react-icons/bi";
import useSWR, { SWRConfig } from "swr"; import useSWR, { SWRConfig } from "swr";
import { ColorContext } from "utils/contexts/color"; import { ColorContext } from "utils/contexts/color";
@@ -103,7 +102,29 @@ function Index({ initialSettings, fallback }) {
const [stale, setStale] = useState(false); const [stale, setStale] = useState(false);
const { data: errorsData } = useSWR("/api/validate"); const { data: errorsData } = useSWR("/api/validate");
const { error: validateError } = errorsData || {}; const { error: validateError } = errorsData || {};
const { data: hashData, mutate: mutateHash } = useSWR("/api/hash");
const handleHashData = useCallback((hashData) => {
if (typeof window === "undefined" || !hashData?.hash) return;
const previousHash = localStorage.getItem("hash");
if (!previousHash) {
localStorage.setItem("hash", hashData.hash);
}
if (previousHash && previousHash !== hashData.hash) {
setStale(true);
localStorage.setItem("hash", hashData.hash);
fetch("/api/revalidate").then((res) => {
if (res.ok) {
window.location.reload();
}
});
}
}, []);
const { mutate: mutateHash } = useSWR("/api/hash", { onSuccess: handleHashData });
useEffect(() => { useEffect(() => {
if (windowFocused) { if (windowFocused) {
@@ -111,29 +132,6 @@ function Index({ initialSettings, fallback }) {
} }
}, [windowFocused, mutateHash]); }, [windowFocused, mutateHash]);
useEffect(() => {
if (hashData) {
if (typeof window !== "undefined") {
const previousHash = localStorage.getItem("hash");
if (!previousHash) {
localStorage.setItem("hash", hashData.hash);
}
if (previousHash && previousHash !== hashData.hash) {
setStale(true);
localStorage.setItem("hash", hashData.hash);
fetch("/api/revalidate").then((res) => {
if (res.ok) {
window.location.reload();
}
});
}
}
}
}, [hashData]);
if (validateError) { if (validateError) {
return ( return (
<div className="w-full h-screen container m-auto justify-center p-10 pointer-events-none"> <div className="w-full h-screen container m-auto justify-center p-10 pointer-events-none">
@@ -175,7 +173,7 @@ function Index({ initialSettings, fallback }) {
</div> </div>
<div className="p-2 text-theme-100 dark:text-theme-200"> <div className="p-2 text-theme-100 dark:text-theme-200">
<pre className="opacity-50 font-bold pb-2"> <pre className="opacity-50 font-bold pb-2">
Reason: "{error.reason}" at line {error.mark?.line} Reason: &quot;{error.reason}&quot; at line {error.mark?.line}
</pre> </pre>
<pre className="font-italic">Check logs for details.</pre> <pre className="font-italic">Check logs for details.</pre>
</div> </div>
@@ -265,6 +263,13 @@ function Home({ initialSettings }) {
e.key.match(/([à-ü]|[À-Ü]|!)/g) || e.key.match(/([à-ü]|[À-Ü]|!)/g) ||
(e.key === "v" && (e.ctrlKey || e.metaKey)) (e.key === "v" && (e.ctrlKey || e.metaKey))
) { ) {
if (e.key.length === 1 && !(e.key === "v" && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
// whitespace opens the search but shouldn't be in it
if (e.key.trim()) {
setSearchString((currentSearchString) => currentSearchString + e.key);
}
}
setSearching(true); setSearching(true);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setSearchString(""); setSearchString("");
@@ -421,10 +426,11 @@ function Home({ initialSettings }) {
/> />
{settings.disableIndexing && <meta name="robots" content="noindex, nofollow" />} {settings.disableIndexing && <meta name="robots" content="noindex, nofollow" />}
{settings.base && <base href={settings.base} />} {settings.base && <base href={settings.base} />}
{settings.favicon ? ( {/* from props, not context so its set before running JS */}
{initialSettings.favicon ? (
<> <>
<link rel="icon" href={settings.favicon} /> <link rel="icon" href={initialSettings.favicon} />
<link rel="apple-touch-icon" sizes="180x180" href={settings.favicon} /> <link rel="apple-touch-icon" sizes="180x180" href={initialSettings.favicon} />
</> </>
) : ( ) : (
<> <>
-1
View File
@@ -1,5 +1,4 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { SettingsContext } from "utils/contexts/settings"; import { SettingsContext } from "utils/contexts/settings";
export function renderWithProviders(ui, { settings = {} } = {}) { export function renderWithProviders(ui, { settings = {} } = {}) {
+2 -3
View File
@@ -1,8 +1,6 @@
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import path from "path"; import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config"; import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
import { import {
cleanServiceGroups, cleanServiceGroups,
@@ -12,6 +10,7 @@ import {
servicesFromKubernetes, servicesFromKubernetes,
} from "utils/config/service-helpers"; } from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers"; import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
import { loadYaml } from "utils/config/yaml";
/** /**
* Compares services by weight then by name. * Compares services by weight then by name.
@@ -30,7 +29,7 @@ export async function bookmarksResponse() {
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml"); const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8"); const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents); const fileContents = substituteEnvironmentVars(rawFileContents);
const bookmarks = yaml.load(fileContents); const bookmarks = loadYaml(fileContents);
if (!bookmarks) return []; if (!bookmarks) return [];
+1 -4
View File
@@ -30,10 +30,7 @@ vi.mock("fs", () => ({
promises: fs, promises: fs,
})); }));
vi.mock("js-yaml", () => ({ vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => config); vi.mock("utils/config/config", () => config);
vi.mock("utils/config/widget-helpers", () => widgetHelpers); vi.mock("utils/config/widget-helpers", () => widgetHelpers);
+1 -1
View File
@@ -13,7 +13,7 @@ const { fs, yaml } = vi.hoisted(() => ({
})); }));
vi.mock("fs", () => fs); vi.mock("fs", () => fs);
vi.mock("js-yaml", () => ({ default: yaml, ...yaml })); vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
describe("utils/config/config checkAndCopyConfig", () => { describe("utils/config/config checkAndCopyConfig", () => {
const originalEnv = process.env; const originalEnv = process.env;
+4 -3
View File
@@ -1,9 +1,10 @@
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs"; import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import yaml from "js-yaml";
import cache from "memory-cache"; import cache from "memory-cache";
import { loadYaml } from "utils/config/yaml";
const cacheKey = "homepageEnvironmentVariables"; const cacheKey = "homepageEnvironmentVariables";
const homepageVarPrefix = "HOMEPAGE_VAR_"; const homepageVarPrefix = "HOMEPAGE_VAR_";
const homepageFilePrefix = "HOMEPAGE_FILE_"; const homepageFilePrefix = "HOMEPAGE_FILE_";
@@ -42,7 +43,7 @@ export default function checkAndCopyConfig(config) {
} }
try { try {
yaml.load(readFileSync(configYaml, "utf8")); loadYaml(readFileSync(configYaml, "utf8"));
return true; return true;
} catch (e) { } catch (e) {
return { ...e, config }; return { ...e, config };
@@ -85,7 +86,7 @@ export function getSettings() {
const settingsYaml = join(CONF_DIR, "settings.yaml"); const settingsYaml = join(CONF_DIR, "settings.yaml");
const rawFileContents = readFileSync(settingsYaml, "utf8"); const rawFileContents = readFileSync(settingsYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents); const fileContents = substituteEnvironmentVars(rawFileContents);
const initialSettings = yaml.load(fileContents) ?? {}; const initialSettings = loadYaml(fileContents) ?? {};
if (initialSettings.layout) { if (initialSettings.layout) {
// support yaml list but old spec was object so convert to that // support yaml list but old spec was object so convert to that
+2 -3
View File
@@ -1,9 +1,8 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import path from "path"; import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config"; import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
export function getDefaultDockerArgs(platform = process.platform) { export function getDefaultDockerArgs(platform = process.platform) {
if (platform !== "win32" && platform !== "darwin") { if (platform !== "win32" && platform !== "darwin") {
@@ -19,7 +18,7 @@ export default function getDockerArguments(server) {
const configFile = path.join(CONF_DIR, "docker.yaml"); const configFile = path.join(CONF_DIR, "docker.yaml");
const rawConfigData = readFileSync(configFile, "utf8"); const rawConfigData = readFileSync(configFile, "utf8");
const configData = substituteEnvironmentVars(rawConfigData); const configData = substituteEnvironmentVars(rawConfigData);
const servers = yaml.load(configData); const servers = loadYaml(configData);
if (!server) { if (!server) {
return getDefaultDockerArgs(); return getDefaultDockerArgs();
+1 -4
View File
@@ -21,10 +21,7 @@ vi.mock("fs", () => ({
readFileSync: fs.readFileSync, readFileSync: fs.readFileSync,
})); }));
vi.mock("js-yaml", () => ({ vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({ vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig, default: checkAndCopyConfig,
+7 -2
View File
@@ -1,17 +1,18 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { isIPv6 } from "net";
import path from "path"; import path from "path";
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node"; import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config"; import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
export function getKubernetes() { export function getKubernetes() {
checkAndCopyConfig("kubernetes.yaml"); checkAndCopyConfig("kubernetes.yaml");
const configFile = path.join(CONF_DIR, "kubernetes.yaml"); const configFile = path.join(CONF_DIR, "kubernetes.yaml");
const rawConfigData = readFileSync(configFile, "utf8"); const rawConfigData = readFileSync(configFile, "utf8");
const configData = substituteEnvironmentVars(rawConfigData); const configData = substituteEnvironmentVars(rawConfigData);
return yaml.load(configData); return loadYaml(configData);
} }
export const getKubeConfig = () => { export const getKubeConfig = () => {
@@ -21,6 +22,10 @@ export const getKubeConfig = () => {
switch (config?.mode) { switch (config?.mode) {
case "cluster": case "cluster":
kc.loadFromCluster(); kc.loadFromCluster();
// node < 26 can't match an IPv6 host against an IP SAN, so verify against the DNS SAN instead (nodejs/node#64032)
if (isIPv6(process.env.KUBERNETES_SERVICE_HOST ?? "")) {
kc.clusters = kc.clusters.map((cluster) => ({ ...cluster, tlsServerName: "kubernetes.default.svc" }));
}
break; break;
case "default": case "default":
kc.loadFromDefault(); kc.loadFromDefault();
+23 -4
View File
@@ -32,10 +32,7 @@ vi.mock("fs", () => ({
readFileSync: fs.readFileSync, readFileSync: fs.readFileSync,
})); }));
vi.mock("js-yaml", () => ({ vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({ vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig, default: checkAndCopyConfig,
@@ -46,6 +43,7 @@ vi.mock("@kubernetes/client-node", () => ({
ApiextensionsV1Api: class ApiextensionsV1Api {}, ApiextensionsV1Api: class ApiextensionsV1Api {},
KubeConfig: class KubeConfig { KubeConfig: class KubeConfig {
loadFromCluster() { loadFromCluster() {
this.clusters = [{ name: "inCluster", server: "https://host:443", skipTLSVerify: false }];
return kube.loadFromCluster(); return kube.loadFromCluster();
} }
loadFromDefault() { loadFromDefault() {
@@ -88,6 +86,27 @@ describe("utils/config/kubernetes", () => {
expect(kc2).not.toBeNull(); expect(kc2).not.toBeNull();
}); });
it("getKubeConfig sets tlsServerName when the in-cluster host is IPv6", () => {
vi.stubEnv("KUBERNETES_SERVICE_HOST", "fd00:1018:2000::1");
yaml.load.mockReturnValueOnce({ mode: "cluster" });
expect(getKubeConfig().clusters[0]).toMatchObject({
name: "inCluster",
tlsServerName: "kubernetes.default.svc",
});
vi.unstubAllEnvs();
});
it("getKubeConfig leaves tlsServerName unset for non-IPv6 in-cluster hosts", () => {
vi.stubEnv("KUBERNETES_SERVICE_HOST", "10.43.0.1");
yaml.load.mockReturnValueOnce({ mode: "cluster" });
expect(getKubeConfig().clusters[0].tlsServerName).toBeUndefined();
vi.unstubAllEnvs();
});
it("checkCRD returns true when the CRD exists", async () => { it("checkCRD returns true when the CRD exists", async () => {
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true }); apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
const logger = { error: vi.fn() }; const logger = { error: vi.fn() };
+2 -3
View File
@@ -1,14 +1,13 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import path from "path"; import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config"; import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
export function getProxmoxConfig() { export function getProxmoxConfig() {
checkAndCopyConfig("proxmox.yaml"); checkAndCopyConfig("proxmox.yaml");
const configFile = path.join(CONF_DIR, "proxmox.yaml"); const configFile = path.join(CONF_DIR, "proxmox.yaml");
const rawConfigData = readFileSync(configFile, "utf8"); const rawConfigData = readFileSync(configFile, "utf8");
const configData = substituteEnvironmentVars(rawConfigData); const configData = substituteEnvironmentVars(rawConfigData);
return yaml.load(configData); return loadYaml(configData);
} }
+1 -4
View File
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
readFileSync: fs.readFileSync, readFileSync: fs.readFileSync,
})); }));
vi.mock("js-yaml", () => ({ vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({ vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig, default: checkAndCopyConfig,

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