Compare commits

...

31 Commits

Author SHA1 Message Date
shamoon 5873f8e7d5 2.1.2
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-21 09:58:19 -07:00
shamoon 6099837373 Merge branch 'dev' 2026-08-21 09:52:58 -07:00
shamoon f0bd255adb Fix yaml parsing in 2.1.1 (#7037) 2026-08-21 09:48:25 -07:00
shamoon 21bf7ed8e2 2.1.1 2026-08-21 08:47:00 -07:00
github-actions[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] 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] 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] 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 Radoev 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
117 changed files with 1795 additions and 1034 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
- run: sudo apt-get install pngquant
- name: Test Docs Build
run: uv run --frozen zensical build --clean
@@ -43,7 +43,7 @@ jobs:
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
- run: sudo apt-get install pngquant
- name: Build Docs
run: uv run --frozen zensical build --clean
+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.
!!! 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.
```yaml
+17
View File
@@ -49,6 +49,23 @@ environment:
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
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.
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`.
+2 -2
View File
@@ -5,7 +5,7 @@ description: Authentik Widget Configuration
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`.
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 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 |
| ----------------- | ----------------------- |
+1
View File
@@ -12,5 +12,6 @@ widget:
type: pulse
url: http://pulse.host.or.ip:7655
key: your-api-token # `monitoring:read` scope is required
version: 2 # required for Pulse v6, defaults to 1
fields: ["nodes", "vms", "lxcs"] # optional
```
+20
View File
@@ -17,6 +17,8 @@ const compat = new FlatCompat({
export default defineConfig([
{
files: ["**/*.{js,mjs,cjs,jsx}"],
extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")),
plugins: {
@@ -63,6 +65,24 @@ export default defineConfig([
allowElseIf: true,
},
],
// Keep the pre-eslint-plugin-react-hooks v6 lint policy until rules are adopted incrementally
"react-hooks/config": "off",
"react-hooks/error-boundaries": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/gating": "off",
"react-hooks/globals": "off",
"react-hooks/immutability": "off",
"react-hooks/incompatible-library": "off",
"react-hooks/preserve-manual-memoization": "off",
"react-hooks/purity": "off",
"react-hooks/refs": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/set-state-in-effect": "off",
"react-hooks/set-state-in-render": "off",
"react-hooks/static-components": "off",
"react-hooks/unsupported-syntax": "off",
"react-hooks/use-memo": "off",
},
},
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "2.0.0",
"version": "2.1.2",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -15,7 +15,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.10",
"@kubernetes/client-node": "^1.4.0",
"@kubernetes/client-node": "^2.0.0",
"classnames": "^2.5.1",
"compare-versions": "^6.1.1",
"dockerode": "^5.0.0",
@@ -23,7 +23,7 @@
"gamedig": "^5.3.3",
"i18next": "^26.3.6",
"ical.js": "^2.2.1",
"js-yaml": "^4.3.1",
"js-yaml": "^5.3.0",
"json-rpc-2.0": "^1.7.1",
"luxon": "^3.7.2",
"memory-cache": "^0.2.0",
@@ -39,7 +39,7 @@
"react-i18next": "^17.0.11",
"react-icons": "^5.6.0",
"recharts": "^3.1.2",
"swr": "^2.4.2",
"swr": "^2.5.1",
"systeminformation": "^5.33.1",
"tough-cookie": "^6.0.2",
"urbackup-server-api": "^0.92.2",
@@ -53,7 +53,7 @@
"@eslint/js": "^9.39.4",
"@tailwindcss/forms": "^0.5.11",
"@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",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^9.25.1",
@@ -63,7 +63,7 @@
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
"prettier": "^3.8.4",
+504 -294
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Gebruikers",
"loginsLast24H": "Aantekenings (24h)",
"failedLoginsLast24H": "Mislukte Aantekenings (24h)"
"failedLoginsLast24H": "Mislukte Aantekenings (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "GEH",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "المستخدمون",
"loginsLast24H": "تسجيلات الدخول (٢٤س)",
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)"
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "الذاكرة",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Потребители",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Usuaris",
"loginsLast24H": "Inicis de sessió (24h)",
"failedLoginsLast24H": "Errors d'inici de sessió (24h)"
"failedLoginsLast24H": "Errors d'inici de sessió (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+27 -26
View File
@@ -21,7 +21,7 @@
"seconds": "s"
},
"auth": {
"signout": "Sign out"
"signout": "Odhlásit se"
},
"widget": {
"missing_type": "Chybí typ widgetu: {{type}}",
@@ -75,13 +75,13 @@
"no_data": "Nejsou k dispozici žádná data úložiště"
},
"duplicati": {
"jobs": "Jobs",
"stored": "Stored",
"lastBackup": "Last Run",
"nextRun": "Next Run",
"running": "Running",
"warnings": "Warnings",
"errors": "Errors"
"jobs": "Úlohy",
"stored": "Uloženo",
"lastBackup": "Naposledy spuštěno",
"nextRun": "Další spuštění",
"running": "Běží",
"warnings": "Upozornění",
"errors": "Chyby"
},
"docker": {
"rx": "RX",
@@ -133,7 +133,7 @@
"series": "Seriály",
"episodes": "Epizody",
"songs": "Skladby",
"albums": "Albums"
"albums": "Alba"
},
"esphome": {
"offline": "Offline",
@@ -471,7 +471,8 @@
"authentik": {
"users": "Uživatelé",
"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": {
"mem": "Využití paměti",
@@ -951,15 +952,15 @@
"storage": "Úložiště"
},
"maintainerr": {
"itemsHandled": "Items Handled",
"episodesHandled": "Episodes Handled",
"moviesHandled": "Movies Handled",
"showsHandled": "Shows Handled",
"seasonsHandled": "Seasons Handled",
"reclaimable": "Reclaimable",
"movieReclaimable": "Movie Reclaimable",
"showReclaimable": "Show Reclaimable",
"totalCapacity": "Total Capacity"
"itemsHandled": "Zpracované položky",
"episodesHandled": "Zpracované epizody",
"moviesHandled": "Zpracované filmy",
"showsHandled": "Zpracované seriály",
"seasonsHandled": "Zpracované série",
"reclaimable": "Uvolnitelné",
"movieReclaimable": "Uvolnitelné filmy",
"showReclaimable": "Uvolnitelné seriály",
"totalCapacity": "Celková kapacita"
},
"netdata": {
"warnings": "Upozornění",
@@ -1241,14 +1242,14 @@
"steps": "Kroky"
},
"syncthing": {
"connected": "Connected",
"synced": "Synced",
"errors": "Errors",
"storage": "Storage"
"connected": "Připojeno",
"synced": "Synchronizováno",
"errors": "Chyby",
"storage": "Úložiště"
},
"sportarr": {
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
"wanted": "Požadované",
"queued": "Ve frontě",
"leagues": "Ligy"
}
}
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Login (24 timer)",
"failedLoginsLast24H": "Mislykkede logins (24 timer)"
"failedLoginsLast24H": "Mislykkede logins (24 timer)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Benutzer",
"loginsLast24H": "Anmeldungen (24 h)",
"failedLoginsLast24H": "Fehlversuche (24 h)"
"failedLoginsLast24H": "Fehlversuche (24 h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "RAM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Συνδέσεις (24h)",
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)"
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+3 -2
View File
@@ -133,7 +133,7 @@
"series": "Series",
"episodes": "Episodios",
"songs": "Canciones",
"albums": "Albums"
"albums": "Álbumes"
},
"esphome": {
"offline": "Fuera de línea",
@@ -471,7 +471,8 @@
"authentik": {
"users": "Usuarios",
"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": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Kirjautumisia (24h)",
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)"
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+3 -2
View File
@@ -21,7 +21,7 @@
"seconds": "s"
},
"auth": {
"signout": "Sign out"
"signout": "Se déconnecter"
},
"widget": {
"missing_type": "Type de widget manquant : {{type}}",
@@ -471,7 +471,8 @@
"authentik": {
"users": "Utilisateurs",
"loginsLast24H": "Connexions (24 h)",
"failedLoginsLast24H": "Connexions échouées (24 h)"
"failedLoginsLast24H": "Connexions échouées (24 h)",
"authorizationsLast24H": "Authentifications (24h)"
},
"proxmox": {
"mem": "RAM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "משתמשים",
"loginsLast24H": "כניסות (24h)",
"failedLoginsLast24H": "כניסות שנכשלו (24h)"
"failedLoginsLast24H": "כניסות שנכשלו (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "זיכרון",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Korisnici",
"loginsLast24H": "Prijave (24 h)",
"failedLoginsLast24H": "Neuspjele prijave (24 h)"
"failedLoginsLast24H": "Neuspjele prijave (24 h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Felhasználók",
"loginsLast24H": "Bejelentkezések (24 óra)",
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)"
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Login (24j)",
"failedLoginsLast24H": "Login Gagal (24j)"
"failedLoginsLast24H": "Login Gagal (24j)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Utenti",
"loginsLast24H": "Accessi (24h)",
"failedLoginsLast24H": "Accessi Falliti (24h)"
"failedLoginsLast24H": "Accessi Falliti (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "ログイン (24時間)",
"failedLoginsLast24H": "ログイン失敗(24時間)"
"failedLoginsLast24H": "ログイン失敗(24時間)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "사용자",
"loginsLast24H": "로그인 (24시간)",
"failedLoginsLast24H": "실패한 로그인 (24시간)"
"failedLoginsLast24H": "실패한 로그인 (24시간)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "메모리",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logmasuk (24j)",
"failedLoginsLast24H": "Logmasuk Gagal (24j)"
"failedLoginsLast24H": "Logmasuk Gagal (24j)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Gebruikers",
"loginsLast24H": "Logins (24u)",
"failedLoginsLast24H": "Mislukte Logins (24u)"
"failedLoginsLast24H": "Mislukte Logins (24u)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "GEH",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Mislykket innlogginger (24t)"
"failedLoginsLast24H": "Mislykket innlogginger (24t)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+4 -3
View File
@@ -21,7 +21,7 @@
"seconds": "s"
},
"auth": {
"signout": "Sign out"
"signout": "Wyloguj się"
},
"widget": {
"missing_type": "Brakujący typ widżetu: {{type}}",
@@ -133,7 +133,7 @@
"series": "Seriale",
"episodes": "Odcinki",
"songs": "Piosenki",
"albums": "Albums"
"albums": "Albumy"
},
"esphome": {
"offline": "Offline",
@@ -471,7 +471,8 @@
"authentik": {
"users": "Użytkownicy",
"loginsLast24H": "Logowania (24h)",
"failedLoginsLast24H": "Nieudane logowania (24h)"
"failedLoginsLast24H": "Nieudane logowania (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "RAM",
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Usuários",
"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": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Autentificări (24h)",
"failedLoginsLast24H": "Conectări eșuate (24h)"
"failedLoginsLast24H": "Conectări eșuate (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+62 -61
View File
@@ -21,7 +21,7 @@
"seconds": "сек"
},
"auth": {
"signout": "Sign out"
"signout": "Выход"
},
"widget": {
"missing_type": "Отсутствует тип виджета: {{type}}",
@@ -75,13 +75,13 @@
"no_data": "Нет доступных данных о хранилище"
},
"duplicati": {
"jobs": "Jobs",
"stored": "Stored",
"lastBackup": "Last Run",
"nextRun": "Next Run",
"running": "Running",
"warnings": "Warnings",
"errors": "Errors"
"jobs": "Работы",
"stored": "Сохранено",
"lastBackup": "Последний запуск",
"nextRun": "Следующий запуск",
"running": "Запущено",
"warnings": "Предупреждения",
"errors": "Ошибки"
},
"docker": {
"rx": "RX",
@@ -133,7 +133,7 @@
"series": "Сериалы",
"episodes": "Эпизоды",
"songs": "Песни",
"albums": "Albums"
"albums": "Альбомы"
},
"esphome": {
"offline": "Не в сети",
@@ -235,17 +235,17 @@
"rutorrent": {
"active": "Активно",
"upload": "Отдача",
"download": "Скачивание"
"download": "Загрузка"
},
"transmission": {
"download": "Скачивание",
"download": "Загрузка",
"upload": "Отдача",
"leech": "Лич",
"seed": "Сид"
},
"qbittorrent": {
"download": "Скачивание",
"upload": "Загрузка",
"download": "Загрузка",
"upload": "Отдача",
"leech": "Лич",
"seed": "Сид"
},
@@ -258,8 +258,8 @@
"invalid": "Некорректный"
},
"deluge": {
"download": "Скачивание",
"upload": "Загрузка",
"download": "Загрузка",
"upload": "Отдача",
"leech": "Лич",
"seed": "Сид"
},
@@ -268,8 +268,8 @@
"cachemissbytes": "Мисс байты кэша"
},
"downloadstation": {
"download": "Скачивание",
"upload": "Загрузка",
"download": "Загрузка",
"upload": "Отдача",
"leech": "Лич",
"seed": "Сид"
},
@@ -334,8 +334,8 @@
"latency": "Задержка"
},
"speedtest": {
"upload": "Загрузка",
"download": "Скачивание",
"upload": "Отдача",
"download": "Загрузка",
"ping": "Пинг"
},
"portainer": {
@@ -471,7 +471,8 @@
"authentik": {
"users": "Пользователи",
"loginsLast24H": "Входы (24ч)",
"failedLoginsLast24H": "Неудачные входы (24ч)"
"failedLoginsLast24H": "Неудачные входы (24ч)",
"authorizationsLast24H": "Входы (24ч)"
},
"proxmox": {
"mem": "Память",
@@ -480,9 +481,9 @@
"vms": "Виртуальные машины"
},
"pulse": {
"nodes": "Nodes",
"vms": "VMs",
"lxcs": "LXCs"
"nodes": "Узлы",
"vms": "Виртуальные машины",
"lxcs": "Контейнеры LXC"
},
"glances": {
"cpu": "ЦП",
@@ -581,7 +582,7 @@
"up": "В сети",
"pending": "Ожидают",
"down": "Не в сети",
"ok": "Ok"
"ok": "OK"
},
"healthchecks": {
"new": "Новый",
@@ -649,7 +650,7 @@
"total": "Всего"
},
"pangolin": {
"orgs": "Orgs",
"orgs": "Организации",
"sites": "Сайты",
"resources": "Ресурсы",
"targets": "Цели",
@@ -687,7 +688,7 @@
"limit": "Лимит"
},
"opnsense": {
"cpu": "ЦП",
"cpu": "Нагрузка ЦП",
"memory": "Активно ОЗУ",
"wanUpload": "WAN Загрузка",
"wanDownload": "WAN скачивание"
@@ -822,7 +823,7 @@
"gross_percent_today": "Сегодня",
"gross_percent_1y": "Один год",
"gross_percent_max": "Все время",
"net_worth": "Net Worth"
"net_worth": "Общая стоимость"
},
"audiobookshelf": {
"podcasts": "Подкасты",
@@ -951,15 +952,15 @@
"storage": "Хранилище"
},
"maintainerr": {
"itemsHandled": "Items Handled",
"episodesHandled": "Episodes Handled",
"moviesHandled": "Movies Handled",
"showsHandled": "Shows Handled",
"seasonsHandled": "Seasons Handled",
"reclaimable": "Reclaimable",
"movieReclaimable": "Movie Reclaimable",
"showReclaimable": "Show Reclaimable",
"totalCapacity": "Total Capacity"
"itemsHandled": "Элементы обрабатываются",
"episodesHandled": "Серии обрабатываются",
"moviesHandled": "Фильмы обрабатываются",
"showsHandled": "Шоу обрабатываются",
"seasonsHandled": "Сезоны обрабатываются",
"reclaimable": "Восстановленные",
"movieReclaimable": "Восстановленный фильм",
"showReclaimable": "Восстановленное шоу",
"totalCapacity": "Общая ёмкость"
},
"netdata": {
"warnings": "Предупреждения",
@@ -968,7 +969,7 @@
"ntfy": {
"title": "Название",
"priority": "Приоритет",
"lastReceived": "Last Received",
"lastReceived": "Последние полученные",
"message": "Сообщение",
"tags": "Теги",
"none": "Отсутствует",
@@ -1105,7 +1106,7 @@
"apps": "Приложения",
"synced": "Синхронизированные",
"outOfSync": "Не синхронизированные",
"healthy": "Healthy",
"healthy": "Здоров",
"degraded": "Деградированные",
"progressing": "Выполняются",
"missing": "Отсутствует",
@@ -1152,18 +1153,18 @@
"other": "Другое"
},
"checkmk": {
"serviceErrors": "Service issues",
"hostErrors": "Host issues"
"serviceErrors": "Проблемы сервиса",
"hostErrors": "Проблемы узла"
},
"komodo": {
"total": "Всего",
"running": "Запущено",
"stopped": "Остановлено",
"down": "Не в сети",
"unhealthy": "Unhealthy",
"unhealthy": "Неисправен",
"unknown": "Неизвестно",
"servers": "Серверы",
"stacks": "Stacks",
"stacks": "Стеки",
"containers": "Контейнеры"
},
"filebrowser": {
@@ -1186,7 +1187,7 @@
"DISABLE_DISK": "Диск отключен",
"SWAP_DSBL": "Swap отключён",
"INVALID_EXPANSION": "Неверное Расширение",
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
"PARITY_NOT_BIGGEST": "Четность не наибольшая",
"TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков",
"NEW_DISK_TOO_SMALL": "Новый диск слишком мал",
"NO_DATA_DISKS": "Нет дисков данных",
@@ -1195,18 +1196,18 @@
"cpu": "ЦП",
"memoryUsed": "Использовано ОЗУ",
"memoryAvailable": "Доступная память",
"arrayUsed": "Array Used",
"arrayFree": "Array Free",
"arrayUsed": "Используемый массив",
"arrayFree": "Свободный массив",
"poolUsed": "{{pool}} Использовано",
"poolFree": "{{pool}} Свободно"
},
"backrest": {
"num_plans": "Plans",
"num_success_30": "Successes",
"num_plans": "Планы",
"num_success_30": "Успешные",
"num_failure_30": "Ошибки",
"num_success_latest": "Succeeding",
"num_failure_latest": "Failing",
"bytes_added_30": "Bytes Added"
"num_success_latest": "Успешно",
"num_failure_latest": "Неудачные",
"bytes_added_30": "Байты добавлены"
},
"yourspotify": {
"songs": "Треков",
@@ -1235,20 +1236,20 @@
"environment_not_found": "Среда не найдена"
},
"sparkyfitness": {
"eaten": "Eaten",
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
"eaten": "Съедено",
"burned": "Сожжено",
"remaining": "Осталось",
"steps": "Шаги"
},
"syncthing": {
"connected": "Connected",
"synced": "Synced",
"errors": "Errors",
"storage": "Storage"
"connected": "Подключено",
"synced": "Синхронизировано",
"errors": "Ошибки",
"storage": "Хранилище"
},
"sportarr": {
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
"wanted": "Требуется",
"queued": "В очереди",
"leagues": "Лиги"
}
}
+6 -5
View File
@@ -21,7 +21,7 @@
"seconds": "s"
},
"auth": {
"signout": "Sign out"
"signout": "Odhlásiť sa"
},
"widget": {
"missing_type": "Chýba typ widgetu: {{type}}",
@@ -133,7 +133,7 @@
"series": "Seriálov",
"episodes": "Epizód",
"songs": "Skladieb",
"albums": "Albums"
"albums": "Albumov"
},
"esphome": {
"offline": "Nedostupné",
@@ -471,7 +471,8 @@
"authentik": {
"users": "Používateľov",
"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": {
"mem": "RAM",
@@ -959,7 +960,7 @@
"reclaimable": "Reclaimable",
"movieReclaimable": "Movie Reclaimable",
"showReclaimable": "Show Reclaimable",
"totalCapacity": "Total Capacity"
"totalCapacity": "Celková kapacita"
},
"netdata": {
"warnings": "Upozornenia",
@@ -1248,7 +1249,7 @@
},
"sportarr": {
"wanted": "Wanted",
"queued": "Queued",
"queued": "V poradí",
"leagues": "Leagues"
}
}
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Prijave (24h)",
"failedLoginsLast24H": "Neveljavne prijave (24h)"
"failedLoginsLast24H": "Neveljavne prijave (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Корисника",
"loginsLast24H": "Пријаве (24ч)",
"failedLoginsLast24H": "Неуспешне пријаве (24ч)"
"failedLoginsLast24H": "Неуспешне пријаве (24ч)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "Меморија",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Inloggningar (24h)",
"failedLoginsLast24H": "Misslyckade inloggningar (24h)"
"failedLoginsLast24H": "Misslyckade inloggningar (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "లాగిన్లు (24గం)",
"failedLoginsLast24H": "విఫలమైన లాగిన్‌లు (24గం)"
"failedLoginsLast24H": "విఫలమైన లాగిన్‌లు (24గం)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Kullanıcılar",
"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": {
"mem": "Bellek",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Користувачі",
"loginsLast24H": "Вхід (протягом доби)",
"failedLoginsLast24H": "Невдалі входи (протягом доби)"
"failedLoginsLast24H": "Невдалі входи (протягом доби)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "Пам'ять",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "Logins (24h)",
"failedLoginsLast24H": "Failed Logins (24h)"
"failedLoginsLast24H": "Failed Logins (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "登錄( 24小时)",
"failedLoginsLast24H": "登錄失敗( 24鐘頭)"
"failedLoginsLast24H": "登錄失敗( 24鐘頭)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "登录 (24h)",
"failedLoginsLast24H": "登录失败 (24h)"
"failedLoginsLast24H": "登录失败 (24h)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "内存",
+2 -1
View File
@@ -471,7 +471,8 @@
"authentik": {
"users": "Users",
"loginsLast24H": "登入 (過去 24 小時)",
"failedLoginsLast24H": "登入失敗 (過去 24 小時)"
"failedLoginsLast24H": "登入失敗 (過去 24 小時)",
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "MEM",
+60 -6
View File
@@ -3,21 +3,27 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { getSettingsMock } = vi.hoisted(() => ({
const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({
getSettingsMock: vi.fn(),
authOptionsMock: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings: getSettingsMock,
}));
vi.mock("pages/api/auth/[...nextauth]", () => ({
get authOptions() {
return authOptionsMock();
},
}));
vi.mock("next/router", () => ({
useRouter: () => ({
query: {},
}),
}));
import { getProviders } from "next-auth/react";
import SignInPage, { getServerSideProps } from "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(() => {
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 () => {
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] });
getSettingsMock.mockReturnValueOnce({
theme: "dark",
color: "slate",
@@ -79,11 +85,10 @@ describe("pages/auth/signin", () => {
const res = await getServerSideProps({});
expect(getProviders).toHaveBeenCalled();
expect(getSettingsMock).toHaveBeenCalled();
expect(res).toEqual({
props: {
providers: { foo: { id: "foo", name: "Foo" } },
providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
settings: {
theme: "dark",
color: "slate",
@@ -96,4 +101,53 @@ describe("pages/auth/signin", () => {
expect(res.props.settings).not.toHaveProperty("providers");
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/);
});
});
-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
if (error || errorInfo) {
// eslint-disable-next-line no-console
console.error("component error: %s, info: %s", error, errorInfo);
}
}
+1 -2
View File
@@ -147,7 +147,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
let descriptionMatch;
if (searchDescriptions) {
descriptionMatch = r.description?.toLowerCase().includes(searchString);
r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch; // eslint-disable-line no-param-reassign
r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch;
}
return nameMatch || descriptionMatch;
});
@@ -244,7 +244,6 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
<span>
{parts.map((part, i) =>
part.toLowerCase() === searchString.toLowerCase() ? (
// eslint-disable-next-line react/no-array-index-key
<span key={`${searchString}_${i}`} className="bg-theme-300/10">
{part}
</span>
+3 -3
View File
@@ -13,13 +13,13 @@ export default function Error({ error }) {
const { t } = useTranslation();
if (typeof error === "string") {
error = { message: error }; // eslint-disable-line no-param-reassign
error = { message: error };
} else if (typeof error === "number") {
error = { message: `Error ${error}` }; // eslint-disable-line no-param-reassign
error = { message: `Error ${error}` };
}
if (error?.data?.error) {
error = error.data.error; // eslint-disable-line no-param-reassign
error = error.data.error;
}
return (
@@ -7,7 +7,6 @@ import Resource from "../widget/resource";
export default function Network({ options, refresh = 1500 }) {
const { t } = useTranslation();
// eslint-disable-next-line no-param-reassign
if (options.network === true) options.network = "default";
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, {
+8 -8
View File
@@ -11,7 +11,7 @@ import {
} from "@headlessui/react";
import classNames from "classnames";
import { useTranslation } from "next-i18next/pages";
import { Fragment, useEffect, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { BiLogoBing } from "react-icons/bi";
import { FiSearch } from "react-icons/fi";
import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si";
@@ -57,12 +57,12 @@ export const searchProviders = {
},
};
function getAvailableProviderIds(options) {
if (options.provider && Array.isArray(options.provider)) {
return options.provider.filter((value) => searchProviders.hasOwnProperty(value));
function getAvailableProviderIds(provider) {
if (provider && Array.isArray(provider)) {
return provider.filter((value) => searchProviders.hasOwnProperty(value));
}
if (options.provider && searchProviders[options.provider]) {
return [options.provider];
if (provider && searchProviders[provider]) {
return [provider];
}
return null;
}
@@ -82,7 +82,8 @@ export function getStoredProvider() {
export default function Search({ options }) {
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 [query, setQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
@@ -184,7 +185,6 @@ export default function Search({ options }) {
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus}
onBlur={(e) => e.preventDefault()}
onKeyDown={handleSearchKeyDown}
@@ -14,7 +14,6 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Widget({ options }) {
const { t } = useTranslation();
// eslint-disable-next-line no-param-reassign, no-multi-assign
options.service_group = options.service_name = "unifi_console";
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
@@ -39,8 +38,8 @@ export default function Widget({ options }) {
const lan = defaultSite.health.find((h) => h.subsystem === "lan");
const wlan = defaultSite.health.find((h) => h.subsystem === "wlan");
[wan, lan, wlan].forEach((s) => {
s.up = s.status === "ok"; // eslint-disable-line no-param-reassign
s.show = s.status !== "unknown"; // eslint-disable-line no-param-reassign
s.up = s.status === "ok";
s.show = s.status !== "unknown";
});
const name = wan.gw_name ?? defaultSite.desc;
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
@@ -10,7 +10,6 @@ import WidgetIcon from "./widget_icon";
export function getAllClasses(options, additionalClassNames = "") {
if (options?.style?.header === "boxedWidgets") {
if (options?.style?.cardBlur !== undefined) {
// eslint-disable-next-line no-param-reassign
additionalClassNames = [
additionalClassNames,
`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;
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.
if (pathname === "/api/mcp") {
return withPrivateCache(NextResponse.next());
+25
View File
@@ -100,6 +100,31 @@ describe("middleware", () => {
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) => {
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 { appWithTranslation } from "next-i18next/pages";
import Head from "next/head";
@@ -13,7 +12,6 @@ import { ThemeProvider } from "utils/contexts/theme";
import nextI18nextConfig from "../../next-i18next.config";
// eslint-disable-next-line no-unused-vars
const tailwindSafelist = [
// TODO: remove pending https://github.com/tailwindlabs/tailwindcss/pull/17147
"backdrop-blur",
+3 -10
View File
@@ -3,7 +3,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { isAuthEnabled } from "utils/env";
import { applyNextAuthEnv, isAuthEnabled } from "utils/env";
import createLogger from "utils/logger";
const MIN_AUTH_SECRET_LENGTH = 32;
@@ -12,20 +12,13 @@ const authEnabled = isAuthEnabled();
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
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 homepageAuthPasswordDigest = homepageAuthPassword
? createHash("sha256").update(homepageAuthPassword, "utf8").digest()
: null;
// Map HOMEPAGE_* envs to what NextAuth expects
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
}
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
process.env.NEXTAUTH_URL = homepageExternalUrl;
}
// Also done in instrumentation.js
applyNextAuthEnv();
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
+6 -5
View File
@@ -15,6 +15,12 @@ export default async function handler(req, res) {
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();
if (tokenError) {
createLogger("mcp").error(tokenError);
@@ -25,11 +31,6 @@ export default async function handler(req, res) {
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);
if (!response) {
return res.status(202).end();
+34
View File
@@ -214,4 +214,38 @@ describe("pages/api/mcp", () => {
expect(res.status).toHaveBeenCalledWith(405);
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 { getProviders, signIn } from "next-auth/react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
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">
<BiShieldQuarter className="h-6 w-6" />
</div>
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
Authentication not configured
</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">Authentication error</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">Auth is disabled or misconfigured.</p>
</div>
</main>
</>
@@ -202,7 +200,16 @@ export default function SignIn({ providers, settings }) {
}
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 settings = Object.fromEntries(
PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [
+1 -2
View File
@@ -1,4 +1,3 @@
/* eslint-disable react/no-array-index-key */
import classNames from "classnames";
import BookmarksGroup from "components/bookmarks/group";
import ErrorBoundary from "components/errorboundry";
@@ -175,7 +174,7 @@ function Index({ initialSettings, fallback }) {
</div>
<div className="p-2 text-theme-100 dark:text-theme-200">
<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 className="font-italic">Check logs for details.</pre>
</div>
-1
View File
@@ -1,5 +1,4 @@
import { render } from "@testing-library/react";
import { SettingsContext } from "utils/contexts/settings";
export function renderWithProviders(ui, { settings = {} } = {}) {
+2 -3
View File
@@ -1,8 +1,6 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
import {
cleanServiceGroups,
@@ -12,6 +10,7 @@ import {
servicesFromKubernetes,
} from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
import { loadYaml } from "utils/config/yaml";
/**
* Compares services by weight then by name.
@@ -30,7 +29,7 @@ export async function bookmarksResponse() {
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents);
const bookmarks = yaml.load(fileContents);
const bookmarks = loadYaml(fileContents);
if (!bookmarks) return [];
+1 -4
View File
@@ -30,10 +30,7 @@ vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
vi.mock("utils/config/config", () => config);
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("js-yaml", () => ({ default: yaml, ...yaml }));
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
describe("utils/config/config checkAndCopyConfig", () => {
const originalEnv = process.env;
+4 -3
View File
@@ -1,9 +1,10 @@
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
import { join } from "path";
import yaml from "js-yaml";
import cache from "memory-cache";
import { loadYaml } from "utils/config/yaml";
const cacheKey = "homepageEnvironmentVariables";
const homepageVarPrefix = "HOMEPAGE_VAR_";
const homepageFilePrefix = "HOMEPAGE_FILE_";
@@ -42,7 +43,7 @@ export default function checkAndCopyConfig(config) {
}
try {
yaml.load(readFileSync(configYaml, "utf8"));
loadYaml(readFileSync(configYaml, "utf8"));
return true;
} catch (e) {
return { ...e, config };
@@ -85,7 +86,7 @@ export function getSettings() {
const settingsYaml = join(CONF_DIR, "settings.yaml");
const rawFileContents = readFileSync(settingsYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents);
const initialSettings = yaml.load(fileContents) ?? {};
const initialSettings = loadYaml(fileContents) ?? {};
if (initialSettings.layout) {
// 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 path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
export function getDefaultDockerArgs(platform = process.platform) {
if (platform !== "win32" && platform !== "darwin") {
@@ -19,7 +18,7 @@ export default function getDockerArguments(server) {
const configFile = path.join(CONF_DIR, "docker.yaml");
const rawConfigData = readFileSync(configFile, "utf8");
const configData = substituteEnvironmentVars(rawConfigData);
const servers = yaml.load(configData);
const servers = loadYaml(configData);
if (!server) {
return getDefaultDockerArgs();
+1 -4
View File
@@ -21,10 +21,7 @@ vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
+7 -2
View File
@@ -1,17 +1,18 @@
import { readFileSync } from "fs";
import { isIPv6 } from "net";
import path from "path";
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
export function getKubernetes() {
checkAndCopyConfig("kubernetes.yaml");
const configFile = path.join(CONF_DIR, "kubernetes.yaml");
const rawConfigData = readFileSync(configFile, "utf8");
const configData = substituteEnvironmentVars(rawConfigData);
return yaml.load(configData);
return loadYaml(configData);
}
export const getKubeConfig = () => {
@@ -21,6 +22,10 @@ export const getKubeConfig = () => {
switch (config?.mode) {
case "cluster":
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;
case "default":
kc.loadFromDefault();
+23 -4
View File
@@ -32,10 +32,7 @@ vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
@@ -46,6 +43,7 @@ vi.mock("@kubernetes/client-node", () => ({
ApiextensionsV1Api: class ApiextensionsV1Api {},
KubeConfig: class KubeConfig {
loadFromCluster() {
this.clusters = [{ name: "inCluster", server: "https://host:443", skipTLSVerify: false }];
return kube.loadFromCluster();
}
loadFromDefault() {
@@ -88,6 +86,27 @@ describe("utils/config/kubernetes", () => {
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 () => {
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
const logger = { error: vi.fn() };
+2 -3
View File
@@ -1,14 +1,13 @@
import { readFileSync } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
export function getProxmoxConfig() {
checkAndCopyConfig("proxmox.yaml");
const configFile = path.join(CONF_DIR, "proxmox.yaml");
const rawConfigData = readFileSync(configFile, "utf8");
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,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
+4 -3
View File
@@ -2,12 +2,12 @@ import { promises as fs } from "fs";
import path from "path";
import Docker from "dockerode";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
import getDockerArguments from "utils/config/docker";
import { getKubeConfig } from "utils/config/kubernetes";
import * as shvl from "utils/config/shvl";
import { loadYaml } from "utils/config/yaml";
import kubernetes from "utils/kubernetes/export";
import createLogger from "utils/logger";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
@@ -56,7 +56,7 @@ export async function servicesFromConfig() {
const servicesYaml = path.join(CONF_DIR, "services.yaml");
const rawFileContents = await fs.readFile(servicesYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents);
const services = yaml.load(fileContents);
const services = loadYaml(fileContents);
return parseServicesToGroups(services);
}
@@ -66,7 +66,7 @@ export async function servicesFromDocker() {
const dockerYaml = path.join(CONF_DIR, "docker.yaml");
const rawDockerFileContents = await fs.readFile(dockerYaml, "utf8");
const dockerFileContents = substituteEnvironmentVars(rawDockerFileContents);
const servers = yaml.load(dockerFileContents);
const servers = loadYaml(dockerFileContents);
if (!servers) {
return [];
@@ -589,6 +589,7 @@ export function cleanServiceGroups(groups) {
"grafana",
"gluetun",
"vikunja",
"pulse",
].includes(type)
) {
widget.version = parseVersionForUrl(version);
+3 -4
View File
@@ -70,10 +70,7 @@ vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
vi.mock("utils/config/config", () => config);
vi.mock("dockerode", () => ({ default: Docker }));
@@ -266,6 +263,7 @@ describe("utils/config/service-helpers", () => {
widgets: [
{ type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
{ type: "beszel", version: "2", systemId: "sys" },
{ type: "pulse", version: "2" },
{ type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
{ type: "crowdsec", limit24h: "true" },
{ type: "docker", server: "docker-local", container: "c1" },
@@ -345,6 +343,7 @@ describe("utils/config/service-helpers", () => {
expect.objectContaining({ userEmail: "u@example.com", repositoryId: "r" }),
);
expect(widgets.find((w) => w.type === "beszel")).toEqual(expect.objectContaining({ version: 2, systemId: "sys" }));
expect(widgets.find((w) => w.type === "pulse")).toEqual(expect.objectContaining({ version: 2 }));
expect(widgets.find((w) => w.type === "crowdsec")).toEqual(expect.objectContaining({ limit24h: true }));
expect(widgets.find((w) => w.type === "docker")).toEqual(
expect.objectContaining({ server: "docker-local", container: "c1" }),
+2 -3
View File
@@ -1,9 +1,8 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
export async function widgetsFromConfig() {
checkAndCopyConfig("widgets.yaml");
@@ -11,7 +10,7 @@ export async function widgetsFromConfig() {
const widgetsYaml = path.join(CONF_DIR, "widgets.yaml");
const rawFileContents = await fs.readFile(widgetsYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents);
const widgets = yaml.load(fileContents);
const widgets = loadYaml(fileContents);
if (!widgets) return [];
+1 -4
View File
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
vi.mock("utils/config/config", () => config);
+14
View File
@@ -0,0 +1,14 @@
import * as yaml from "js-yaml";
const EMPTY_DOCUMENT_ERROR = "expected a document, but the input is empty";
const DEFAULT_SCHEMA = yaml.CORE_SCHEMA.withTags(yaml.timestampTag, yaml.mergeTag, yaml.legacyMapTag);
export function loadYaml(input, options) {
try {
return yaml.load(input, { schema: DEFAULT_SCHEMA, ...options });
} catch (error) {
// js-yaml v4 returned undefined for empty and comment-only documents.
if (error?.reason === EMPTY_DOCUMENT_ERROR) return undefined;
throw error;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { loadYaml } from "./yaml";
describe("utils/config/yaml", () => {
it.each(["", " \n", "# comment only\n"])("loads an empty document from %j as undefined", (input) => {
expect(loadYaml(input)).toBeUndefined();
});
it("loads a populated document", () => {
expect(loadYaml("title: Homepage\n")).toEqual({ title: "Homepage" });
});
it("preserves v4 merge key behavior", () => {
expect(loadYaml("defaults: &defaults\n href: https://example.com\nservice:\n <<: *defaults\n")).toEqual({
defaults: { href: "https://example.com" },
service: { href: "https://example.com" },
});
});
it("preserves v4 timestamp behavior without enabling YAML 1.1 booleans", () => {
expect(loadYaml("date: 2026-08-21\nenabled: yes\n")).toEqual({
date: new Date("2026-08-21T00:00:00.000Z"),
enabled: "yes",
});
});
it("preserves v4 handling of non-string keys", () => {
// an unsubstituted {{HOMEPAGE_VAR_*}} parses as a flow mapping key
expect(loadYaml("- Plex:\n widget:\n key: {{HOMEPAGE_VAR_PLEX_KEY}}\n")).toEqual([
{ Plex: { widget: { key: { "[object Object]": null } } } },
]);
expect(loadYaml("- Backups:\n - 2024-01-01:\n href: http://x\n")).toEqual([
{ Backups: [{ [String(new Date("2024-01-01T00:00:00.000Z"))]: { href: "http://x" } }] },
]);
});
it("still rejects invalid YAML", () => {
expect(() => loadYaml("value: [\n")).toThrow();
});
});
-1
View File
@@ -32,7 +32,6 @@ export function ColorProvider({ initialTheme, children }) {
useEffect(() => {
if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTheme]);
useEffect(() => {
-1
View File
@@ -33,7 +33,6 @@ export function ThemeProvider({ initialTheme, children }) {
useEffect(() => {
if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTheme]);
useEffect(() => {
+10
View File
@@ -1,3 +1,13 @@
export function isAuthEnabled() {
return process.env.HOMEPAGE_AUTH_ENABLED === "true";
}
// Frozen at module load, so map them before anything imports it
export function applyNextAuthEnv() {
if (!process.env.NEXTAUTH_SECRET && process.env.HOMEPAGE_AUTH_SECRET) {
process.env.NEXTAUTH_SECRET = process.env.HOMEPAGE_AUTH_SECRET;
}
if (!process.env.NEXTAUTH_URL && process.env.HOMEPAGE_EXTERNAL_URL) {
process.env.NEXTAUTH_URL = process.env.HOMEPAGE_EXTERNAL_URL;
}
}
+5 -4
View File
@@ -14,13 +14,14 @@ import createLogger from "utils/logger";
const logger = createLogger("resource-helpers");
const kc = getKubeConfig();
const getSchemaFromGateway = async (parentRef) => {
const getSchemaFromGateway = async (parentRef, routeNamespace) => {
const crd = kc.makeApiClient(CustomObjectsApi);
const schema = await crd
.getNamespacedCustomObject({
group: HTTPROUTE_API_GROUP,
version: HTTPROUTE_API_VERSION,
namespace: parentRef.namespace,
// parentRef namespace is optional, defaults to the route's namespace
namespace: parentRef.namespace ?? routeNamespace,
plural: "gateways",
name: parentRef.name,
})
@@ -31,7 +32,7 @@ const getSchemaFromGateway = async (parentRef) => {
return listener.protocol.toLowerCase();
})
.catch((error) => {
logger.error("Error getting gateways: %d %s %s", error.statusCode, error.body, error.response);
logger.error("Error getting gateways: %s", error.body ?? error.message);
logger.debug(error);
return "http";
@@ -48,7 +49,7 @@ async function getUrlFromHttpRoute(resource) {
if (resource.spec.rules[0].matches[0].path.type !== "RegularExpression") {
const urlHost = resource.spec.hostnames[0];
const urlPath = resource.spec.rules[0].matches[0].path.value;
const urlSchema = await getSchemaFromGateway(resource.spec.parentRefs[0]);
const urlSchema = await getSchemaFromGateway(resource.spec.parentRefs[0], resource.metadata.namespace);
url = `${urlSchema}://${urlHost}${urlPath}`;
}
}
+62 -1
View File
@@ -137,6 +137,36 @@ describe("utils/kubernetes/resource-helpers", () => {
expect(service.href).toBe("https://example.com/r");
});
it("falls back to the route namespace when the parentRef omits one", async () => {
const kc = getKubeConfig();
const crd = kc.makeApiClient();
const base = "gethomepage.dev";
const resource = {
kind: "HTTPRoute",
metadata: {
name: "route",
namespace: "ns",
annotations: {
[`${base}/enabled`]: "true",
},
},
spec: {
hostnames: ["example.com"],
parentRefs: [{ name: "gw", sectionName: "web" }],
rules: [
{
matches: [{ path: { type: "PathPrefix", value: "/r" } }],
},
],
},
};
const service = await constructedServiceFromResource(resource);
expect(crd.getNamespacedCustomObject).toHaveBeenCalledWith(expect.objectContaining({ namespace: "ns" }));
expect(service.href).toBe("https://example.com/r");
});
it("falls back to http when the gateway listener protocol cannot be resolved", async () => {
const kc = getKubeConfig();
const crd = kc.makeApiClient();
@@ -169,10 +199,41 @@ describe("utils/kubernetes/resource-helpers", () => {
const service = await constructedServiceFromResource(resource);
expect(service.href).toBe("http://example.com/r");
expect(logger.error).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith("Error getting gateways: %s", "boom");
expect(logger.debug).toHaveBeenCalled();
});
it("logs the message for gateway errors that carry no response body", async () => {
const kc = getKubeConfig();
const crd = kc.makeApiClient();
crd.getNamespacedCustomObject.mockRejectedValueOnce(new Error("Required parameter namespace was null"));
const base = "gethomepage.dev";
const resource = {
kind: "HTTPRoute",
metadata: {
name: "route",
namespace: "ns",
annotations: {
[`${base}/enabled`]: "true",
},
},
spec: {
hostnames: ["example.com"],
parentRefs: [{ namespace: "ns", name: "gw", sectionName: "web" }],
rules: [
{
matches: [{ path: { type: "PathPrefix", value: "/r" } }],
},
],
},
};
const service = await constructedServiceFromResource(resource);
expect(service.href).toBe("http://example.com/r");
expect(logger.error).toHaveBeenCalledWith("Error getting gateways: %s", "Required parameter namespace was null");
});
it("logs and recovers when environment substitution yields invalid json", async () => {
substituteEnvironmentVars.mockImplementationOnce(() => "{bad json");
+6 -13
View File
@@ -18,23 +18,16 @@ function combineMessageAndSplat() {
}
function messageFormatter(logInfo) {
if (logInfo.label) {
if (logInfo.stack) {
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.stack}`;
}
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.message}`;
}
if (logInfo.stack) {
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.stack}`;
}
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.message}`;
const label = logInfo.label ? `<${logInfo.label}> ` : "";
// e.g. fetch errors say nothing useful without the cause
const cause = logInfo.cause ? `\ncaused by: ${logInfo.cause.stack ?? logInfo.cause}` : "";
return `[${logInfo.timestamp}] ${logInfo.level}: ${label}${logInfo.stack || logInfo.message}${cause}`;
}
function getConsoleLogger() {
return new winston.transports.Console({
format: winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.errors({ stack: true, cause: true }),
combineMessageAndSplat(),
winston.format.timestamp(),
winston.format.colorize(),
@@ -51,7 +44,7 @@ function getFileLogger() {
return new winston.transports.File({
format: winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.errors({ stack: true, cause: true }),
combineMessageAndSplat(),
winston.format.timestamp(),
winston.format.printf(messageFormatter),
+10
View File
@@ -170,6 +170,16 @@ describe("utils/logger", () => {
});
expect(plainMsg).toBe("[t] info: hello");
const causeMsg = formatter({
timestamp: "t",
level: "error",
label: "x",
stack: "STACK",
message: "fetch failed",
cause: { stack: "CAUSE STACK" },
});
expect(causeMsg).toBe("[t] error: <x> STACK\ncaused by: CAUSE STACK");
const out = splat.transform(
{
message: "Hello %s",
+4 -3
View File
@@ -2,9 +2,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { createHash, timingSafeEqual } from "node:crypto";
import { join } from "path";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import { CONF_DIR } from "utils/config/config";
import { loadYaml } from "utils/config/yaml";
const PROTOCOL_VERSION = "2025-11-25";
const SERVER_INFO = {
@@ -118,7 +119,7 @@ function readConfig(file) {
}
function parseYamlConfig(file) {
const parsed = yaml.load(readConfig(file) || "");
const parsed = loadYaml(readConfig(file) || "");
return parsed ?? [];
}
@@ -128,7 +129,7 @@ function validateYaml(file, content) {
}
try {
yaml.load(content || "");
loadYaml(content || "");
return { valid: true };
} catch (error) {
return {
+7 -5
View File
@@ -16,14 +16,16 @@ export default function Component({ service }) {
widget.fields = widget.fields.slice(0, MAX_FIELDS);
}
if (widget?.env == null || widget.env === "") {
const envNotSet = widget.env == null || widget.env === "";
const { data: containers, error: containersError } = useWidgetAPI(widget, envNotSet ? "" : "containers");
const { data: images, error: imagesError } = useWidgetAPI(widget, envNotSet ? "" : "images");
const { data: updates, error: updatesError } = useWidgetAPI(widget, envNotSet ? "" : "updates");
if (envNotSet) {
return <Container service={service} error={t("arcane.environment_required")} />;
}
const { data: containers, error: containersError } = useWidgetAPI(widget, "containers");
const { data: images, error: imagesError } = useWidgetAPI(widget, "images");
const { data: updates, error: updatesError } = useWidgetAPI(widget, "updates");
const error =
containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;
if (error) {
+5 -1
View File
@@ -20,11 +20,15 @@ describe("widgets/arcane/component", () => {
});
it("shows an environment required error when env is missing", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
renderWithProviders(<Component service={{ widget: { type: "arcane" } }} />, {
settings: { hideErrors: false },
});
expect(useWidgetAPI).not.toHaveBeenCalled();
// hooks always run; the empty endpoint is what skips the request
expect(useWidgetAPI).toHaveBeenCalledTimes(3);
useWidgetAPI.mock.calls.forEach((call) => expect(call[1]).toBe(""));
expect(screen.getByText("arcane.environment_required")).toBeInTheDocument();
});
+40 -13
View File
@@ -8,34 +8,43 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const isV2 = widget.version === 2;
const { data: usersData, error: usersError } = useWidgetAPI(widget, "users");
const loginsEndpoint = widget.version === 2 ? "loginv2" : "login";
const loginsEndpoint = isV2 ? "" : "login";
const { data: loginsData, error: loginsError } = useWidgetAPI(widget, loginsEndpoint);
const failedLoginsEndpoint = widget.version === 2 ? "login_failedv2" : "login_failed";
const failedLoginsEndpoint = isV2 ? "" : "login_failed";
const { data: failedLoginsData, error: failedLoginsError } = useWidgetAPI(widget, failedLoginsEndpoint);
if (usersError || loginsError || failedLoginsError) {
const finalError = usersError ?? loginsError ?? failedLoginsError;
const eventsDataEndpoint = isV2 ? "datav2" : "";
const { data: eventsData, error: eventsDataError } = useWidgetAPI(widget, eventsDataEndpoint);
if (usersError || loginsError || failedLoginsError || eventsDataError) {
const finalError = usersError ?? loginsError ?? failedLoginsError ?? eventsDataError;
return <Container service={service} error={finalError} />;
}
if (!usersData || !loginsData || !failedLoginsData) {
const hasNoData = isV2 ? !usersData || !eventsData : !usersData || !loginsData || !failedLoginsData;
if (hasNoData) {
return (
<Container service={service}>
<Block label="authentik.users" />
<Block label="authentik.loginsLast24H" />
<Block label="authentik.failedLoginsLast24H" />
{isV2 && <Block label="authentik.authorizationsLast24H" />}
</Container>
);
}
let loginsLast24H;
let failedLoginsLast24H;
let authorizationsLast24H;
switch (widget.version) {
case 1:
// v1 is default
default:
const yesterday = new Date(Date.now()).setHours(-24);
loginsLast24H = loginsData.reduce(
(total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),
@@ -47,13 +56,28 @@ export default function Component({ service }) {
);
break;
case 2:
loginsLast24H =
loginsData.reduce?.(
(total, current) => (current?.count && current?.action === "login" ? total + current.count : total),
0,
) || 0;
failedLoginsLast24H =
failedLoginsData.reduce?.((total, current) => (current?.count ? total + current.count : total), 0) || 0;
const events = Array.isArray(eventsData) ? eventsData : [];
const result = events.reduce(
(acc, current) => {
if (!current?.count || !current?.action) {
return acc;
}
if (current.action === "login") {
acc.logins += current.count;
} else if (current.action === "login_failed") {
acc.failed += current.count;
} else if (current.action === "authorize_application") {
acc.authorizations += current.count;
}
return acc;
},
{ logins: 0, failed: 0, authorizations: 0 },
);
loginsLast24H = result.logins;
failedLoginsLast24H = result.failed;
authorizationsLast24H = result.authorizations;
break;
}
@@ -62,6 +86,9 @@ export default function Component({ service }) {
<Block label="authentik.users" value={t("common.number", { value: usersData.pagination.count })} />
<Block label="authentik.loginsLast24H" value={t("common.number", { value: loginsLast24H })} />
<Block label="authentik.failedLoginsLast24H" value={t("common.number", { value: failedLoginsLast24H })} />
{isV2 && (
<Block label="authentik.authorizationsLast24H" value={t("common.number", { value: authorizationsLast24H })} />
)}
</Container>
);
}
+37 -5
View File
@@ -30,24 +30,31 @@ describe("widgets/authentik/component", () => {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("authentik.users")).toBeInTheDocument();
expect(screen.getByText("authentik.loginsLast24H")).toBeInTheDocument();
expect(screen.getByText("authentik.failedLoginsLast24H")).toBeInTheDocument();
expect(screen.getByText("authentik.authorizationsLast24H")).toBeInTheDocument();
});
it("computes v2 login/failed counts from action data", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "users") return { data: { pagination: { count: 10 } }, error: undefined };
if (endpoint === "loginv2")
if (endpoint === "datav2")
return {
data: [
{ action: "login", count: 2 },
{ action: "login", count: 4 },
{ action: "logout", count: 9 },
{ action: "login_failed", count: 1 },
{ action: "login_failed", count: 2 },
{ action: "authorize_application", count: 10 },
{ action: "authorize_application", count: 8 },
{ action: null, count: null },
{ action: "login", count: null },
],
error: undefined,
};
if (endpoint === "login_failedv2") return { data: [{ count: 3 }, { count: null }], error: undefined };
return { data: undefined, error: undefined };
});
@@ -55,10 +62,11 @@ describe("widgets/authentik/component", () => {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "authentik.users", 10);
expectBlockValue(container, "authentik.loginsLast24H", 2);
expectBlockValue(container, "authentik.loginsLast24H", 6);
expectBlockValue(container, "authentik.failedLoginsLast24H", 3);
expectBlockValue(container, "authentik.authorizationsLast24H", 18);
});
it("computes v1 login/failed counts for entries within the last 24h window", () => {
@@ -99,4 +107,28 @@ describe("widgets/authentik/component", () => {
expectBlockValue(container, "authentik.loginsLast24H", 2);
expectBlockValue(container, "authentik.failedLoginsLast24H", 1);
});
it("falls back to v1 when no version is configured", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
const oneHourAgo = Date.now() - 60 * 60 * 1000;
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "users") return { data: { pagination: { count: 5 } }, error: undefined };
if (endpoint === "login") return { data: [{ x_cord: oneHourAgo, y_cord: 2 }], error: undefined };
if (endpoint === "login_failed") return { data: [{ x_cord: oneHourAgo, y_cord: 1 }], error: undefined };
return { data: undefined, error: undefined };
});
// service-helpers sets version to null when the key is absent
const { container } = renderWithProviders(
<Component service={{ widget: { type: "authentik", version: null } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "authentik.users", 5);
expectBlockValue(container, "authentik.loginsLast24H", 2);
expectBlockValue(container, "authentik.failedLoginsLast24H", 1);
});
});
+2 -5
View File
@@ -11,14 +11,11 @@ const widget = {
login: {
endpoint: "events/events/per_month/?action=login",
},
loginv2: {
endpoint: "events/events/volume/?action=login&&history_days=1",
},
login_failed: {
endpoint: "events/events/per_month/?action=login_failed",
},
login_failedv2: {
endpoint: "events/events/volume/?action=login_failed&&history_days=1",
datav2: {
endpoint: "events/events/volume/?actions=login&actions=login_failed&actions=authorize_application&history_days=1",
},
},
};

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