Compare commits

..

9 Commits

Author SHA1 Message Date
Crowdin Bot
4a306b52d1 New Crowdin translations by GitHub Action
Some checks are pending
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run
2026-02-08 00:59:07 +00:00
Matt Popovich
f4f54cea60 Documentation: clarify jellyfin api key location, add ports (#6298)
Some checks are pending
Tests / vitest (4) (push) Waiting to run
Docker CI / Linting Checks (push) Waiting to run
Docker CI / Docker Build & Push (push) Blocked by required conditions
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-07 09:38:17 +00:00
shamoon
06595ef107 Revise PR template checklist for clarity and links
Some checks failed
Tests / vitest (2) (push) Has been cancelled
Crowdin Action / Crowdin Sync (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (push) Has been cancelled
Docker CI / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-02-06 07:53:26 -08:00
dependabot[bot]
91b9aa479a Chore(deps): Bump actions/setup-node from 4 to 6 (#6285)
Some checks failed
Docker CI / 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
Docker CI / Docker Build & Push (push) Has been cancelled
Crowdin Action / Crowdin Sync (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (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-02-05 13:48:18 -08:00
dependabot[bot]
08cde2f597 Chore(deps): Bump actions/checkout from 4 to 6 (#6284)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 13:40:57 -08:00
shamoon
0ce175cda5 Bump version to 1.10.1 2026-02-05 07:02:59 -08:00
shamoon
7f1de58e71 Fix: safely stringify replacement values 2026-02-05 07:02:07 -08:00
shamoon
f729290e96 Enhancement: better display of Arcane widget errors (#6281)
Some checks failed
Docker CI / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (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-02-05 00:33:38 -08:00
shamoon
4974cd96b6 Chore: move to Zensical docs (#6279)
Some checks failed
Docker CI / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Docs / Linting Checks (push) Has been cancelled
Docs / Test Build Docs (push) Has been cancelled
Docs / Build & Deploy Docs (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-02-04 23:05:11 -08:00
33 changed files with 507 additions and 1035 deletions

View File

@@ -35,8 +35,8 @@ What type of change does your PR introduce to Homepage?
## Checklist:
- [ ] If applicable, I have added corresponding documentation changes.
- [ ] If applicable, I have added or updated tests for new features and bug fixes.
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/more/development/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/more/development/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/more/development/#code-linting).
- [ ] If applicable, I have added or updated tests for new features and bug fixes (see [testing](https://gethomepage.dev/widgets/authoring/getting-started/#testing)).
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/widgets/authoring/getting-started/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/widgets/authoring/getting-started/#code-linting).
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] In the description above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -9,7 +9,9 @@ on:
workflow_dispatch:
permissions:
contents: write
contents: read
pages: write
id-token: write
jobs:
pre-commit:
@@ -35,44 +37,34 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
- run: sudo apt-get install pngquant
- run: pip install mkdocs-material mkdocs-redirects "mkdocs-material[imaging]"
- name: Test Docs Build
run: MKINSIDERS=false mkdocs build
run: uv run --frozen zensical build --clean
deploy:
name: Build & Deploy Docs
if: github.repository == 'gethomepage/homepage' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs:
- pre-commit
steps:
- uses: actions/configure-pages@v5
- uses: actions/checkout@v6
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v6
with:
python-version: 3.x
- run: echo "cache_id=${{github.sha}}" >> $GITHUB_ENV
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
- run: sudo apt-get install pngquant
- run: pip install git+https://${GH_TOKEN}@github.com/benphelps/mkdocs-material-insiders.git
- run: pip install mkdocs-redirects "mkdocs-material[imaging]"
- name: Docs Deploy
run: MKINSIDERS=true mkdocs gh-deploy --force
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Build Docs
run: uv run --frozen zensical build --clean
- uses: actions/upload-pages-artifact@v4
with:
path: site
- uses: actions/deploy-pages@v4
id: deployment

View File

@@ -13,13 +13,13 @@ jobs:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm

2
.gitignore vendored
View File

@@ -46,7 +46,7 @@ next-env.d.ts
# IDEs
/.idea/
# MkDocs documentation
# Zensical documentation
site*/
.cache/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

View File

@@ -70,14 +70,65 @@ For configuration options, examples and more, [please check out the homepage doc
## Security Notice 🔒
Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system). To keep your information private, if Homepage is reachable from any untrusted network, it:
Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. If Homepage is reachable from any untrusted network, it **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. The built-in host check in Homepage is a best-effort guard and should not be treated as security when exposed publicly.
1. **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers.
2. An optional built-in OIDC login flow is available (opt-in) offering a simple “authenticated or not” guard.
## With Docker
## Installation
Using docker compose:
See the [Installation](https://gethomepage.dev/installation/) section of the docs for instructions on installing Homepage via Docker, Kubernetes, Unraid, or from source.
```yaml
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
PUID: 1000 # optional, your user id
PGID: 1000 # optional, your group id
ports:
- 3000:3000
volumes:
- /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # optional, for docker integrations
restart: unless-stopped
```
or docker run:
```bash
docker run --name homepage \
-e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev \
-e PUID=1000 \
-e PGID=1000 \
-p 3000:3000 \
-v /path/to/config:/app/config \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--restart unless-stopped \
ghcr.io/gethomepage/homepage:latest
```
## From Source
First, clone the repository:
```bash
git clone https://github.com/gethomepage/homepage.git
```
Then install dependencies and build the production bundle:
```bash
pnpm install
pnpm build
```
If this is your first time starting, copy the `src/skeleton` directory to `config/` to populate initial example config files.
Finally, run the server in production mode:
```bash
pnpm start
```
# Configuration
@@ -105,16 +156,16 @@ This is a [Next.js](https://nextjs.org/) application, see their documentation fo
The homepage documentation is available at [https://gethomepage.dev/](https://gethomepage.dev/).
Homepage uses Material for MkDocs for documentation. To run the documentation locally, first install the dependencies:
Homepage uses Zensical for documentation. To run the documentation locally, first install the dependencies:
```bash
pip install -r requirements.txt
uv sync
```
Then run the development server:
```bash
mkdocs serve # or build, to build the static site
uv run zensical serve # or build, to build the static site
```
# Support & Suggestions

View File

@@ -15,6 +15,8 @@ services:
volumes:
- /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
```
### Running as non-root
@@ -36,6 +38,7 @@ services:
- /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations, see alternative methods
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
PUID: $PUID
PGID: $PGID
```
@@ -43,7 +46,7 @@ services:
### With Docker Run
```bash
docker run -p 3000:3000 -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest
docker run -p 3000:3000 -e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest
```
### Using Environment Secrets

View File

@@ -27,25 +27,14 @@ You have a few options for deploying homepage, depending on your needs. We offer
</div>
### Security & Authentication
### `HOMEPAGE_ALLOWED_HOSTS`
Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided.
As of v1.0 there is one required environment variable to access homepage via a URL other than `localhost`, <code>HOMEPAGE_ALLOWED_HOSTS</code>. The setting helps prevent certain kinds of attacks when retrieving data from the homepage API proxy.
Required environment variables for authentication:
The value is a comma-separated (no spaces) list of allowed hosts (sometimes with the port) that can host your homepage install. See the [docker](docker.md), [kubernetes](k8s.md) and [source](source.md) installation pages for more information about where / how to set the variable.
- `HOMEPAGE_AUTH_ENABLED=true`
- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies)
`localhost:3000` and `127.0.0.1:3000` are always included, but you can add a domain or IP address to this list to allow that host such as `HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev,192.168.1.2:1234`, etc.
For password-only login:
If you are seeing errors about host validation, check the homepage logs and ensure that the host exactly as output in the logs is in the `HOMEPAGE_ALLOWED_HOSTS` list.
- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided)
For OIDC login (overrides password login):
- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`)
- `HOMEPAGE_OIDC_CLIENT_ID`
- `HOMEPAGE_OIDC_CLIENT_SECRET`
- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks)
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)
All app pages and `/api` routes will require a signed-in session. Static assets remain public. Homepage still does not implement per-user dashboards or roles; authentication is a simple gate only.
This can be disabled by setting `HOMEPAGE_ALLOWED_HOSTS` to `*` but this is not recommended. Public deployments must rely on a reverse proxy (and/or VPN) that enforces authentication, TLS, and unexpected Host headers; the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections.

View File

@@ -223,6 +223,9 @@ spec:
- name: homepage
image: "ghcr.io/gethomepage/homepage:latest"
imagePullPolicy: Always
env:
- name: HOMEPAGE_ALLOWED_HOSTS
value: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
ports:
- name: http
containerPort: 3000

View File

@@ -27,7 +27,9 @@ If this is your first time starting, copy the `src/skeleton` directory to `confi
Finally, run the server:
```bash
pnpm start
HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start
```
When updating homepage versions you will need to re-build the static files i.e. repeat the process above.
See [HOMEPAGE_ALLOWED_HOSTS](index.md#homepage_allowed_hosts) for more information on this environment variable.

View File

@@ -5,7 +5,7 @@ description: Jellyfin Widget Configuration
Learn more about [Jellyfin](https://github.com/jellyfin/jellyfin).
You can create an API key from inside Jellyfin at `Settings > Advanced > Api Keys`.
You can create an API key from inside the Jellyfin Administration Dashboard under `Advanced > API Keys`.
As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "songs"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the "Now Playing" feature (enabled by default) can be disabled with the `enableNowPlaying` option.
@@ -17,7 +17,7 @@ As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "song
```yaml
widget:
type: jellyfin
url: http://jellyfin.host.or.ip
url: http://jellyfin.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
version: 2 # optional, default is 1
enableBlocks: true # optional, defaults to false

View File

@@ -12,7 +12,7 @@ Allowed fields: no configurable fields for this widget.
```yaml
widget:
type: tautulli
url: http://tautulli.host.or.ip
url: http://tautulli.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.10.0",
"version": "1.10.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -29,7 +29,6 @@
"memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2",
"next": "^15.5.11",
"next-auth": "^4.24.10",
"next-i18next": "^12.1.0",
"ping": "^0.4.4",
"pretty-bytes": "^7.1.0",

114
pnpm-lock.yaml generated
View File

@@ -53,9 +53,6 @@ importers:
next:
specifier: ^15.5.11
version: 15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-auth:
specifier: ^4.24.10
version: 4.24.13(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-i18next:
specifier: ^12.1.0
version: 12.1.0(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -806,9 +803,6 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1689,10 +1683,6 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
core-js@3.40.0:
resolution: {integrity: sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==}
@@ -2561,9 +2551,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
@@ -2736,10 +2723,6 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'}
@@ -2848,20 +2831,6 @@ packages:
net@1.0.2:
resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==}
next-auth@4.24.13:
resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==}
peerDependencies:
'@auth/core': 0.34.3
next: ^12.2.5 || ^13 || ^14 || ^15 || ^16
nodemailer: ^7.0.7
react: ^17.0.2 || ^18 || ^19
react-dom: ^17.0.2 || ^18 || ^19
peerDependenciesMeta:
'@auth/core':
optional: true
nodemailer:
optional: true
next-i18next@12.1.0:
resolution: {integrity: sha512-rhos/PVULmZPdC0jpec2MDBQMXdGZ3+Mbh/tZfrDtjgnVN3ucdq7k8BlwsJNww6FnqC8AC31n6dSYuqVzYsGsw==}
engines: {node: '>=12'}
@@ -2909,17 +2878,10 @@ packages:
oauth4webapi@3.3.0:
resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==}
oauth@0.9.15:
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -2948,19 +2910,12 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
oidc-token-hash@5.2.0:
resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==}
engines: {node: ^10.13.0 || >=12.0.0}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
openid-client@6.3.0:
resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==}
@@ -3052,14 +3007,6 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@5.2.6:
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
preact: '>=10'
preact@10.28.2:
resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3091,9 +3038,6 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
pretty-format@3.8.0:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
prism-react-renderer@2.4.1:
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
peerDependencies:
@@ -3726,10 +3670,6 @@ packages:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
@@ -3928,9 +3868,6 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
@@ -4452,8 +4389,6 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@panva/hkdf@1.2.1': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -5320,8 +5255,6 @@ snapshots:
concat-map@0.0.1: {}
cookie@0.7.2: {}
core-js@3.40.0: {}
core-util-is@1.0.3: {}
@@ -6414,8 +6347,6 @@ snapshots:
jiti@2.6.1: {}
jose@4.15.9: {}
jose@5.10.0: {}
js-tokens@10.0.0: {}
@@ -6577,10 +6508,6 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
luxon@3.6.1: {}
lz-string@1.5.0: {}
@@ -6660,21 +6587,6 @@ snapshots:
net@1.0.2: {}
next-auth@4.24.13(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.28.6
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
preact-render-to-string: 5.2.6(preact@10.28.2)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
uuid: 8.3.2
next-i18next@12.1.0(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.9
@@ -6723,12 +6635,8 @@ snapshots:
oauth4webapi@3.3.0: {}
oauth@0.9.15: {}
object-assign@4.1.1: {}
object-hash@2.2.0: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -6768,8 +6676,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
oidc-token-hash@5.2.0: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -6778,13 +6684,6 @@ snapshots:
dependencies:
fn.name: 1.1.0
openid-client@5.7.1:
dependencies:
jose: 4.15.9
lru-cache: 6.0.0
object-hash: 2.2.0
oidc-token-hash: 5.2.0
openid-client@6.3.0:
dependencies:
jose: 5.10.0
@@ -6867,13 +6766,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact-render-to-string@5.2.6(preact@10.28.2):
dependencies:
preact: 10.28.2
pretty-format: 3.8.0
preact@10.28.2: {}
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
@@ -6895,8 +6787,6 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
pretty-format@3.8.0: {}
prism-react-renderer@2.4.1(react@18.3.1):
dependencies:
'@types/prismjs': 1.26.5
@@ -7654,8 +7544,6 @@ snapshots:
uuid@10.0.0: {}
uuid@8.3.2: {}
varint@6.0.0: {}
victory-vendor@37.3.6:
@@ -7892,8 +7780,6 @@ snapshots:
y18n@5.0.8: {}
yallist@4.0.0: {}
yallist@5.0.0: {}
yargs-parser@21.1.1: {}

View File

@@ -1152,11 +1152,11 @@
"artists": "Kunstenaars"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
"containers": "Houers",
"images": "Beelde",
"image_updates": "Beeldopdaterings",
"images_unused": "Ongebruik",
"environment_required": "Omgewings-ID Vereis"
},
"dockhand": {
"running": "Lopend",

View File

@@ -108,14 +108,14 @@
"songs": "Piosenki"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"playing": "Odtwarza",
"transcoding": "Transkoduje",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
"no_active": "Brak aktywnych strumieni",
"movies": "Filmy",
"series": "Seriale",
"episodes": "Odcinki",
"songs": "Piosenki"
},
"esphome": {
"offline": "Offline",
@@ -716,8 +716,8 @@
"volumeAvailable": "Dostępne"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
"channels": "Kanały",
"streams": "Strumienie"
},
"mylar": {
"series": "Seriale",
@@ -1152,11 +1152,11 @@
"artists": "Wykonawcy"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
"containers": "Kontenery",
"images": "Obrazy",
"image_updates": "Aktualizacje obrazów",
"images_unused": "Nieużywane",
"environment_required": "Wymagane ID środowiska"
},
"dockhand": {
"running": "Działające",

View File

@@ -93,7 +93,7 @@
"http_status": "HTTP stavový kód",
"error": "Chyba",
"response": "Odpoveď",
"down": "Down",
"down": "Nedostupné",
"up": "Beží",
"not_available": "Nedostupné"
},
@@ -108,18 +108,18 @@
"songs": "Skladby"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
"playing": "Prehráva sa",
"transcoding": "Prebieha prekódovanie",
"bitrate": "Prenosová rýchlosť",
"no_active": "Žiadne aktívne vysielania",
"movies": "Filmov",
"series": "Seriálov",
"episodes": "Epizód",
"songs": "Skladieb"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
"offline": "Nedostupné",
"offline_alt": "Nedostupné",
"online": "Online",
"total": "Celkom",
"unknown": "Neznáme"
@@ -154,7 +154,7 @@
"uptime": "Dostupnosť",
"maxDown": "Max. sťahovanie",
"maxUp": "Max. nahrávanie",
"down": "Down",
"down": "Nedostupné",
"up": "Beží",
"received": "Prijaté",
"sent": "Odoslané",
@@ -180,7 +180,7 @@
"tautulli": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"bitrate": "Prenosová rýchlosť",
"no_active": "No Active Streams",
"plex_connection_error": "Skontroluj spojenie s Plex"
},
@@ -429,7 +429,7 @@
"version": "Verzia",
"status": "Stav",
"up": "Online",
"down": "Offline"
"down": "Nedostupné"
},
"miniflux": {
"read": "Prečítané",
@@ -450,7 +450,7 @@
"cpu": "CPU",
"load": "Záťaž",
"wait": "Čakajte, prosím",
"temp": "TEMP",
"temp": "TEPL",
"_temp": "Teplota",
"warn": "Upozornení",
"uptime": "BEŽÍ",
@@ -491,13 +491,13 @@
"51-day": "Mierne mrholenie",
"51-night": "Slabé mrholenie",
"53-day": "Mrholenie",
"53-night": "Drizzle",
"53-night": "Mrholenie",
"55-day": "Silné mrholenie",
"55-night": "Silné mrholenie",
"56-day": "Mierne mrazivé mrholenie",
"56-night": "Light Freezing Drizzle",
"56-night": "Jemné mrznúce mrholenie",
"57-day": "Mrazivé mrholenie",
"57-night": "Freezing Drizzle",
"57-night": "Mrznúce mrholenie",
"61-day": "Slabý dážď",
"61-night": "Slabý dážď",
"63-day": "Dážď",
@@ -542,14 +542,14 @@
"child_bridges_status": "{{ok}}/{{total}}",
"up": "Beží",
"pending": "Čakajúce",
"down": "Down",
"down": "Nedostupné",
"ok": "Ok"
},
"healthchecks": {
"new": "Nový",
"up": "Beží",
"grace": "V dodatočnej lehote",
"down": "Down",
"down": "Nedostupné",
"paused": "Pozastavené",
"status": "Stav",
"last_ping": "Poslendný ping",
@@ -675,7 +675,7 @@
"memory": "Využitie pamäte",
"wanStatus": "Stav WAN",
"up": "Beží",
"down": "Down",
"down": "Nedostupné",
"temp": "Temp",
"disk": "Využitie disku",
"wanIP": "IP adresa WAN"
@@ -776,8 +776,8 @@
"targets_total": "Cieľov spolu"
},
"gatus": {
"up": "Sites Up",
"down": "Sites Down",
"up": "Dostupné stránky",
"down": "Nedostupné stránky",
"uptime": "Dostupnosť"
},
"ghostfolio": {
@@ -799,7 +799,7 @@
},
"whatsupdocker": {
"monitoring": "Monitoring",
"updates": "Updates"
"updates": "Aktualizácie"
},
"calibreweb": {
"books": "Books",
@@ -872,7 +872,7 @@
"uptime": "Dostupnosť",
"cpuLoad": "Záťaž CPU priem. (5m)",
"up": "Beží",
"down": "Down",
"down": "Nedostupné",
"bytesTx": "Prenesených",
"bytesRx": "Prijaté"
},
@@ -881,13 +881,13 @@
"uptime": "Dostupnosť",
"lastDown": "Posledný čas nedostupnosti",
"downDuration": "Trvanie nedostupnosti",
"sitesUp": "Sites Up",
"sitesDown": "Sites Down",
"sitesUp": "Dostupné stránky",
"sitesDown": "Nedostupné stránky",
"paused": "Pozastavené",
"notyetchecked": "Neskontrolované",
"up": "Beží",
"seemsdown": "Javí sa nedostupný",
"down": "Down",
"down": "Nedostupné",
"unknown": "Neznáme"
},
"calendar": {
@@ -1023,17 +1023,17 @@
"last_seen": "Last Seen",
"status": "Stav",
"online": "Online",
"offline": "Offline"
"offline": "Nedostupné"
},
"beszel": {
"name": "Name",
"systems": "Systems",
"up": "Beží",
"down": "Down",
"down": "Nedostupné",
"paused": "Pozastavené",
"pending": "Čakajúce",
"status": "Stav",
"updated": "Updated",
"updated": "Aktualizované",
"cpu": "CPU",
"memory": "RAM",
"disk": "Disk",
@@ -1078,7 +1078,7 @@
"disconnected": "Odpojené",
"updateStatus": "Update",
"update_yes": "Dostupné",
"update_no": "Up to Date",
"update_no": "Aktuálne",
"downloads": "Downloads",
"uploads": "Uploads",
"sharedFiles": "Files"
@@ -1097,7 +1097,7 @@
"total": "Celkom",
"running": "Beží",
"stopped": "Zastavené",
"down": "Down",
"down": "Nedostupné",
"unhealthy": "Nezdravý",
"unknown": "Neznáme",
"servers": "Servery",

View File

@@ -40,7 +40,7 @@
},
"resources": {
"cpu": "İşlemci",
"mem": "MEM",
"mem": "Bellek",
"total": "Toplam",
"free": "Boş",
"used": "Kullanımda",
@@ -80,7 +80,7 @@
"unhealthy": "Sağlıksız",
"not_found": "Bulunamadı",
"exited": "Kapandı",
"partial": "Parçalı"
"partial": "Kısmi"
},
"ping": {
"error": "Hata",
@@ -93,14 +93,14 @@
"http_status": "HTTPS durumu",
"error": "Hata",
"response": "Yanıt",
"down": "Çalışmayan",
"down": "İndirme",
"up": "Çalışıyor",
"not_available": "Uygun değil"
},
"emby": {
"playing": "Oynatılıyor",
"transcoding": "Dönüştürülüyor",
"bitrate": "Bit Oranı",
"bitrate": "Bit Hızı",
"no_active": "Etkin akış yok",
"movies": "Filmler",
"series": "Diziler",
@@ -108,14 +108,14 @@
"songs": "Şarkılar"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
"playing": "Oynatılıyor",
"transcoding": "Dönüştürülüyor",
"bitrate": "Bit Hızı",
"no_active": "Aktif Yayın Yok",
"movies": "Filmler",
"series": "Diziler",
"episodes": "Bölümler",
"songs": "Şarkılar"
},
"esphome": {
"offline": "Çevrimdışı",
@@ -135,8 +135,8 @@
"flood": {
"download": "İndirme",
"upload": "Yükleme",
"leech": "Tüketici",
"seed": "Sağlayıcı"
"leech": "İndirilen",
"seed": "Gönderilen"
},
"freshrss": {
"subscriptions": "Abonelikler",
@@ -152,10 +152,10 @@
"connectionStatusDisconnected": "Bağlı değil",
"connectionStatusConnected": "Bağlı",
"uptime": "Çalışma Süresi",
"maxDown": "Max. Indirme",
"maxUp": "Max. Gönderme",
"down": "Çalışmayan",
"up": "Çalışıyor",
"maxDown": "Maks. İndirme",
"maxUp": "Maks. Gönderme",
"down": "İndirme",
"up": "Yükleme",
"received": "Alınan",
"sent": "Gönderilen",
"externalIPAddress": "Harici IP",
@@ -180,7 +180,7 @@
"tautulli": {
"playing": "Oynatılıyor",
"transcoding": "Dönüştürülüyor",
"bitrate": "Bit Oranı",
"bitrate": "Bit Hızı",
"no_active": "Etkin akış yok",
"plex_connection_error": "Plex Bağlantısı Kontrol Ediliyor"
},
@@ -209,20 +209,20 @@
},
"rutorrent": {
"active": "Etkin",
"upload": "Yükleme",
"upload": "Gönderme",
"download": "İndirme"
},
"transmission": {
"download": "İndirme",
"upload": "Yükleme",
"leech": "Tüketici",
"seed": "Sağlayıcı"
"upload": "Gönderme",
"leech": "İndirilen",
"seed": "Gönderilen"
},
"qbittorrent": {
"download": "İndirme",
"upload": "Yükleme",
"leech": "Tüketici",
"seed": "Sağlayıcı"
"upload": "Gönderme",
"leech": "İndirilen",
"seed": "Gönderilen"
},
"qnap": {
"cpuUsage": "İşlemci Kullanımı",
@@ -234,9 +234,9 @@
},
"deluge": {
"download": "İndirme",
"upload": "Yükleme",
"leech": "Leech",
"seed": "Seed"
"upload": "Gönderme",
"leech": "İndirilen",
"seed": "Gönderilen"
},
"develancacheui": {
"cachehitbytes": "Önbellek İsabetli Byte",
@@ -244,14 +244,14 @@
},
"downloadstation": {
"download": "İndirme",
"upload": "Yükleme",
"leech": "Tüketici",
"seed": "Sağlayıcı"
"upload": "Gönderme",
"leech": "İndirilen",
"seed": "Gönderilen"
},
"sonarr": {
"wanted": "İstendi",
"queued": "Kuyrukta",
"series": "Seriler",
"series": "Diziler",
"queue": "Kuyruk",
"unknown": "Bilinmeyen"
},
@@ -286,10 +286,10 @@
"pending": "Bekleyen",
"approved": "Onaylı",
"available": "Uygun",
"issues": "Open Issues"
"issues": "ık Sorunlar"
},
"overseerr": {
"pending": "Pending",
"pending": "Beklemede",
"processing": "İşleniyor",
"approved": "Onaylı",
"available": "Uygun"
@@ -307,7 +307,7 @@
"gravity": "Gravity"
},
"adguard": {
"queries": "Queries",
"queries": "Sorgular",
"blocked": "Engellenen",
"filtered": "Filtrelendi",
"latency": "Gecikme"
@@ -448,9 +448,9 @@
},
"glances": {
"cpu": "İşlemci",
"load": "Load",
"load": "Yük",
"wait": "Lütfen bekleyin",
"temp": "TEMP",
"temp": "Sıcaklık",
"_temp": "Sıcaklık",
"warn": "Uyarı",
"uptime": "ÇALIŞIYOR",
@@ -463,7 +463,7 @@
"read": "Okundu",
"write": "Yazma",
"gpu": "GPU",
"mem": "Hafıza",
"mem": "Bellek",
"swap": "Swap"
},
"quicklaunch": {
@@ -543,7 +543,7 @@
"up": "Çalışıyor",
"pending": "Bekleyen",
"down": "Çalışmayan",
"ok": "Ok"
"ok": "Tamam"
},
"healthchecks": {
"new": "Yeni",
@@ -598,7 +598,7 @@
"signalStrength": "Sağlamlık",
"signalQuality": "Kalite",
"symbolQuality": "Kalite",
"networkRate": "Bit Oranı",
"networkRate": "Bit Hızı",
"clientIP": "Alıcı"
},
"scrutiny": {
@@ -611,13 +611,13 @@
"total": "Toplam"
},
"pangolin": {
"orgs": "Orgs",
"sites": "Sites",
"resources": "Resources",
"targets": "Targets",
"traffic": "Traffic",
"in": "In",
"out": "Out"
"orgs": "Kuruluşlar",
"sites": "Siteler",
"resources": "Kaynaklar",
"targets": "Hedefler",
"traffic": "Trafik",
"in": "Gelen",
"out": "Giden"
},
"peanut": {
"battery_charge": "Pil Yüzdesi",
@@ -676,7 +676,7 @@
"wanStatus": "WAN Durumu",
"up": "Çalışıyor",
"down": "Çalışmayan",
"temp": "Temp",
"temp": "Sıcaklık",
"disk": "Disk Kullanımı",
"wanIP": "WAN IP"
},
@@ -697,7 +697,7 @@
"down": "Çalışmayan site",
"uptime": "Çalışma süresi",
"incident": "Olay",
"m": "m"
"m": "dk"
},
"atsumeru": {
"series": "Diziler",
@@ -716,8 +716,8 @@
"volumeAvailable": "Uygun"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
"channels": "Kanallar",
"streams": "Akışlar"
},
"mylar": {
"series": "Diziler",
@@ -771,12 +771,12 @@
"nodes": "Düğümler"
},
"prometheus": {
"targets_up": "Hedef Çalışıyor",
"targets_up": "Çalışan Hedef",
"targets_down": "Çalışmayan hedef",
"targets_total": "Toplam Hedef"
},
"gatus": {
"up": "Sites Up",
"up": "Çalışan Siteler",
"down": "Çalışmayan site",
"uptime": "Çalışma süresi"
},
@@ -784,7 +784,7 @@
"gross_percent_today": "Bugün",
"gross_percent_1y": "Bir yıl",
"gross_percent_max": "Tüm zaman",
"net_worth": "Net Worth"
"net_worth": "Net Değer"
},
"audiobookshelf": {
"podcasts": "Podcast",
@@ -805,13 +805,13 @@
"books": "Kitaplar",
"authors": "Yazarlar",
"categories": "Kategoriler",
"series": "Seriler"
"series": "Diziler"
},
"booklore": {
"libraries": "Libraries",
"books": "Books",
"reading": "Reading",
"finished": "Finished"
"libraries": "Kütüphaneler",
"books": "Kitaplar",
"reading": "Okunuyor",
"finished": "Bitti"
},
"jdownloader": {
"downloadCount": "Kuyruk",
@@ -820,7 +820,7 @@
"downloadSpeed": "Hız"
},
"kavita": {
"seriesCount": "Seriler",
"seriesCount": "Diziler",
"totalFiles": "Dosyalar"
},
"azuredevops": {
@@ -865,7 +865,7 @@
"total": "Toplam",
"running": "Çalışıyor",
"stopped": "Durdu",
"passed": "Passed",
"passed": "Başarılı",
"failed": "Başarısız"
},
"openwrt": {
@@ -874,7 +874,7 @@
"up": "Çalışıyor",
"down": "Çalışmayan",
"bytesTx": "İletilen",
"bytesRx": "Received"
"bytesRx": "Alınan"
},
"uptimerobot": {
"status": "Durum",
@@ -924,7 +924,7 @@
},
"gitea": {
"notifications": "Bildirimler",
"issues": "Issues",
"issues": "Sorunlar",
"pulls": "Değişiklik İstekleri",
"repositories": "Depolar"
},
@@ -1006,21 +1006,21 @@
"lubelogger": {
"vehicle": "Taşıt",
"vehicles": "Taşıtlar",
"serviceRecords": "Service Records",
"serviceRecords": "Servis Kayıtları",
"reminders": "Hatırlatıcılar",
"nextReminder": "Sonraki hatırlatıcı",
"none": "None"
"none": "Hiçbiri"
},
"vikunja": {
"projects": "Etkin projeler",
"tasks7d": "Bitişi Bu Hafta Olan Görevler",
"tasksOverdue": "Overdue Tasks",
"tasksInProgress": "Tasks In Progress"
"tasksOverdue": "Gecikmiş Görevler",
"tasksInProgress": "Devam Eden Görevler"
},
"headscale": {
"name": "Ad",
"address": "Adres",
"last_seen": "Last Seen",
"last_seen": "Son Görülme",
"status": "Durum",
"online": "Çevrimiçi",
"offline": "Çevrimdışı"
@@ -1031,21 +1031,21 @@
"up": "Çalışıyor",
"down": "Çalışmayan",
"paused": "Durduruldu",
"pending": "Pending",
"pending": "Beklemede",
"status": "Durum",
"updated": "Güncellendi",
"cpu": "İşlemci",
"memory": "Bellek",
"disk": "Disk",
"disk": "Depolama",
"network": "NET"
},
"argocd": {
"apps": "Uygulamalar",
"synced": "Synced",
"outOfSync": "Out Of Sync",
"synced": "Senkron",
"outOfSync": "Senkron Değil",
"healthy": "Sağlıklı",
"degraded": "Degraded",
"progressing": "Progressing",
"degraded": "Sorunlu",
"progressing": "Uygulanıyor",
"missing": "Eksik",
"suspended": "Askıya Alındı"
},
@@ -1053,22 +1053,22 @@
"loading": "Yükleniyor"
},
"gitlab": {
"groups": "Groups",
"issues": "Issues",
"merges": "Merge Requests",
"projects": "Projects"
"groups": "Gruplar",
"issues": "Sorunlar",
"merges": "Birleştirme İstekleri",
"projects": "Projeler"
},
"apcups": {
"status": "Durum",
"load": "Load",
"bcharge": "Battery Charge",
"load": "Yük",
"bcharge": "Pil Yüzdesi",
"timeleft": "Kalan zaman"
},
"karakeep": {
"bookmarks": "Yer imleri",
"favorites": "Gözdeler",
"archived": "Archived",
"highlights": "Highlights",
"archived": "Arşivlenen",
"highlights": "Öne Çıkanlar",
"lists": "Listeler",
"tags": "Etiketler"
},
@@ -1090,8 +1090,8 @@
"other": "Diğer"
},
"checkmk": {
"serviceErrors": "Service issues",
"hostErrors": "Host issues"
"serviceErrors": "Hizmet Sorunları",
"hostErrors": "Sunucu Sorunları"
},
"komodo": {
"total": "Toplam",
@@ -1101,8 +1101,8 @@
"unhealthy": "Sağlıksız",
"unknown": "Bilinmeyen",
"servers": "Sunucular",
"stacks": "Stacks",
"containers": "Containers"
"stacks": "Yığınlar",
"containers": "Konteynerler"
},
"filebrowser": {
"available": "Uygun",
@@ -1120,11 +1120,11 @@
"STARTED": "Başladı",
"STOPPED": "Durdu",
"NEW_ARRAY": "Yeni dizi",
"RECON_DISK": "Reconstructing Disk",
"RECON_DISK": "Disk Yeniden Oluşturuluyor",
"DISABLE_DISK": "Disk devre dışı",
"SWAP_DSBL": "Swap devre dışı",
"INVALID_EXPANSION": "Invalid Expansion",
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
"INVALID_EXPANSION": "Geçersiz Genişletme",
"PARITY_NOT_BIGGEST": "Parity En Büyük Disk Değil",
"TOO_MANY_MISSING_DISKS": "Çok fazla disk eksik",
"NEW_DISK_TOO_SMALL": "Yeni disk çok küçük",
"NO_DATA_DISKS": "Veri diski yok",
@@ -1139,37 +1139,37 @@
"poolFree": "{{pool}} boş"
},
"backrest": {
"num_plans": "Plans",
"num_success_30": "Successes",
"num_failure_30": "Failures",
"num_success_latest": "Succeeding",
"num_failure_latest": "Failing",
"bytes_added_30": "Bytes Added"
"num_plans": "Planlar",
"num_success_30": "Başarılılar",
"num_failure_30": "Başarısızlıklar",
"num_success_latest": "Başarılı",
"num_failure_latest": "Başarısız",
"bytes_added_30": "Eklenen Veri"
},
"yourspotify": {
"songs": "Songs",
"time": "Time",
"artists": "Artists"
"songs": "Şarkılar",
"time": "Zaman",
"artists": "Sanatçılar"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
"containers": "Konteynerler",
"images": "İmajlar",
"image_updates": "İmaj Güncellemeleri",
"images_unused": "Kullanılmayan İmajlar",
"environment_required": "Ortam Kimliği Gerekli"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
"running": "Çalışan",
"stopped": "Durdurulan",
"cpu": "İşlemci",
"memory": "Bellek",
"images": "İmajlar",
"volumes": "Birimler",
"events_today": "Bugünkü Olaylar",
"pending_updates": "Bekleyen Güncellemeler",
"stacks": "Yığınlar",
"paused": "Duraklatılan",
"total": "Toplam",
"environment_not_found": "Ortam Bulunamadı"
}
}

View File

@@ -1059,10 +1059,10 @@
"projects": "项目"
},
"apcups": {
"status": "Status",
"load": "Load",
"bcharge": "Battery Charge",
"timeleft": "Time Left"
"status": "状态",
"load": "负载",
"bcharge": "电池电量",
"timeleft": "剩余供电时间"
},
"karakeep": {
"bookmarks": "书签",

8
pyproject.toml Normal file
View File

@@ -0,0 +1,8 @@
[project]
name = "homepage-docs"
version = "1.0.0"
description = "Documentation for the Homepage project"
requires-python = ">=3.13"
dependencies = [
"zensical>=0.0.21",
]

View File

@@ -1,47 +0,0 @@
Babel==2.12.1
backrefs==5.9
cairocffi==1.7.1
CairoSVG==2.7.1
certifi==2023.7.22
cffi==1.17.1
cfgv==3.4.0
charset-normalizer==3.2.0
click==8.1.7
colorama==0.4.6
cssselect2==0.7.0
defusedxml==0.7.1
distlib==0.3.9
filelock==3.17.0
ghp-import==2.1.0
identify==2.6.7
idna==3.4
Jinja2==3.1.2
Markdown==3.4.4
MarkupSafe==2.1.3
mergedeep==1.3.4
mkdocs==1.6.0
mkdocs-get-deps==0.2.0
mkdocs-material==9.6.18
mkdocs-material-extensions==1.3.1
mkdocs-redirects==1.2.1
nodeenv==1.9.1
packaging==23.1
paginate==0.5.6
pathspec==0.11.2
pillow==10.4.0
platformdirs==3.10.0
pre-commit==3.5.0
pycparser==2.22
Pygments==2.16.1
pymdown-extensions==10.3
python-dateutil==2.8.2
PyYAML==6.0.1
pyyaml_env_tag==0.1
regex==2023.8.8
requests==2.31.0
six==1.16.0
tinycss2==1.4.0
urllib3==2.0.5
virtualenv==20.29.2
watchdog==3.0.0
webencodings==0.5.1

View File

@@ -1,124 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { nextAuthMock } = vi.hoisted(() => ({
nextAuthMock: vi.fn((options) => ({ options })),
}));
vi.mock("next-auth", () => ({
default: nextAuthMock,
}));
describe("pages/api/auth/[...nextauth]", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
nextAuthMock.mockClear();
process.env = { ...originalEnv };
delete process.env.NEXTAUTH_SECRET;
delete process.env.NEXTAUTH_URL;
});
it("configures no providers when auth is disabled", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
expect(nextAuthMock).toHaveBeenCalledTimes(1);
expect(mod.default.options.providers).toEqual([]);
expect(mod.default.options.pages?.signIn).toBe("/auth/signin");
});
it("maps HOMEPAGE_AUTH_SECRET and HOMEPAGE_EXTERNAL_URL to NextAuth envs", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
expect(mod.default.options.secret).toBe("secret");
});
it("throws when auth is enabled but no provider settings are present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/Password auth is enabled but required settings are missing/i,
);
});
it("builds a password provider when auth is enabled without OIDC config", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider.id).toBe("credentials");
expect(provider.name).toBe("Credentials");
expect(provider.type).toBe("credentials");
expect(typeof provider.authorize).toBe("function");
});
it("builds an OIDC provider when enabled and maps profile fields", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/";
process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id";
process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
process.env.HOMEPAGE_OIDC_NAME = "My OIDC";
process.env.HOMEPAGE_OIDC_SCOPE = "openid email";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider).toMatchObject({
id: "homepage-oidc",
name: "My OIDC",
type: "oauth",
idToken: true,
issuer: "https://issuer.example",
wellKnown: "https://issuer.example/.well-known/openid-configuration",
clientId: "client-id",
clientSecret: "client-secret",
});
expect(provider.authorization.params.scope).toBe("openid email");
expect(
provider.profile({
sub: "sub",
preferred_username: "user",
email: "user@example.com",
picture: "https://example.com/p.png",
}),
).toEqual({
id: "sub",
name: "user",
email: "user@example.com",
image: "https://example.com/p.png",
});
expect(
provider.profile({
id: "id",
name: "name",
}),
).toEqual({
id: "id",
name: "name",
email: null,
image: null,
});
});
it("throws when only partial OIDC settings are provided", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/OIDC auth is enabled but required settings are missing/i,
);
});
});

View File

@@ -1,78 +0,0 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { getSettingsMock } = vi.hoisted(() => ({
getSettingsMock: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings: getSettingsMock,
}));
vi.mock("next/router", () => ({
useRouter: () => ({
query: {},
}),
}));
import { getProviders } from "next-auth/react";
import SignInPage, { getServerSideProps } from "pages/auth/signin";
describe("pages/auth/signin", () => {
it("renders an error state when no providers are configured", async () => {
render(
<SignInPage
providers={{}}
settings={{
theme: "dark",
color: "slate",
title: "Homepage",
}}
/>,
);
expect(screen.getByText("Authentication not configured")).toBeInTheDocument();
await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(document.documentElement.classList.contains("scheme-dark")).toBe(true);
expect(document.documentElement.classList.contains("theme-slate")).toBe(true);
});
});
it("renders provider buttons when providers are available", () => {
render(
<SignInPage
providers={{
oidc: { id: "oidc", name: "OIDC" },
}}
settings={{
theme: "light",
color: "emerald",
title: "My Dashboard",
}}
/>,
);
expect(screen.getByText("Sign in")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument();
});
it("getServerSideProps returns providers and settings", async () => {
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
const res = await getServerSideProps({});
expect(getProviders).toHaveBeenCalled();
expect(getSettingsMock).toHaveBeenCalled();
expect(res).toEqual({
props: {
providers: { foo: { id: "foo", name: "Foo" } },
settings: { theme: "dark" },
},
});
});
});

View File

@@ -1,34 +1,23 @@
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET;
let warnedAllowedHosts = false;
export async function middleware(req) {
if (!warnedAllowedHosts && process.env.HOMEPAGE_ALLOWED_HOSTS) {
warnedAllowedHosts = true;
console.warn(
"HOMEPAGE_ALLOWED_HOSTS is deprecated. To secure a publicly accessible homepage, configure authentication instead.",
export function middleware(req) {
// Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
const host = req.headers.get("host");
const port = process.env.PORT || 3000;
let allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
const allowAll = process.env.HOMEPAGE_ALLOWED_HOSTS === "*";
if (process.env.HOMEPAGE_ALLOWED_HOSTS) {
allowedHosts = allowedHosts.concat(process.env.HOMEPAGE_ALLOWED_HOSTS.split(","));
}
if (!allowAll && (!host || !allowedHosts.includes(host))) {
console.error(
`Host validation failed for: ${host}. Hint: Set the HOMEPAGE_ALLOWED_HOSTS environment variable to allow requests from this host / port.`,
);
return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 });
}
if (authEnabled) {
const token = await getToken({ req, secret: authSecret });
if (!token) {
const signInUrl = new URL("/auth/signin", req.url);
signInUrl.searchParams.set("callbackUrl", "/");
return NextResponse.redirect(signInUrl);
}
}
return NextResponse.next();
}
export const config = {
// Protect all app and API routes; allow Next.js internals, public assets, auth pages, and NextAuth endpoints.
matcher: [
"/",
"/((?!_next/static|_next/image|favicon.ico|robots.txt|manifest.json|sitemap.xml|icons/|api/auth|auth/).*)",
],
matcher: "/api/:path*",
};

View File

@@ -1,89 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { NextResponse, getToken } = vi.hoisted(() => ({
const { NextResponse } = vi.hoisted(() => ({
NextResponse: {
json: vi.fn((body, init) => ({ type: "json", body, init })),
next: vi.fn(() => ({ type: "next" })),
redirect: vi.fn((url) => ({ type: "redirect", url })),
},
getToken: vi.fn(),
}));
vi.mock("next/server", () => ({ NextResponse }));
vi.mock("next-auth/jwt", () => ({ getToken }));
async function loadMiddleware() {
vi.resetModules();
const mod = await import("./middleware");
return mod.middleware;
}
import { middleware } from "./middleware";
function createReq(url = "http://localhost:3000/") {
function createReq(host) {
return {
url,
headers: {
get: () => null,
get: (key) => (key === "host" ? host : null),
},
};
}
describe("middleware", () => {
const originalEnv = process.env;
const originalConsoleWarn = console.warn;
const originalConsoleError = console.error;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
});
it("allows requests when auth is disabled", async () => {
const middleware = await loadMiddleware();
const res = await middleware(createReq());
it("allows requests for default localhost hosts", () => {
process.env.PORT = "3000";
const res = middleware(createReq("localhost:3000"));
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });
});
it("warns once when HOMEPAGE_ALLOWED_HOSTS is set, but does not block", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com";
it("blocks requests when host is not allowed", () => {
process.env.PORT = "3000";
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const middleware = await loadMiddleware();
const res1 = await middleware(createReq());
const res2 = await middleware(createReq());
const res = middleware(createReq("evil.com"));
expect(errSpy).toHaveBeenCalled();
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: "Host validation failed. See logs for more details." },
{ status: 400 },
);
expect(res.type).toBe("json");
expect(res.init.status).toBe(400);
});
it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => {
process.env.HOMEPAGE_ALLOWED_HOSTS = "*";
const res = middleware(createReq("anything.example"));
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(NextResponse.next).toHaveBeenCalled();
expect(res1).toEqual({ type: "next" });
expect(res2).toEqual({ type: "next" });
expect(res).toEqual({ type: "next" });
});
it("redirects to signin when auth is enabled and no token is present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => {
process.env.PORT = "3000";
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000";
getToken.mockResolvedValueOnce(null);
const middleware = await loadMiddleware();
const res = await middleware(createReq("http://localhost:3000/some"));
expect(getToken).toHaveBeenCalledWith({
req: expect.objectContaining({ url: "http://localhost:3000/some" }),
secret: "secret",
});
expect(NextResponse.redirect).toHaveBeenCalled();
expect(res.type).toBe("redirect");
expect(String(res.url)).toContain("/auth/signin");
});
it("allows requests when auth is enabled and a token is present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
getToken.mockResolvedValueOnce({ sub: "user" });
const middleware = await loadMiddleware();
const res = await middleware(createReq("http://localhost:3000/"));
const res = middleware(createReq("example.com:3000"));
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });

View File

@@ -1,5 +1,4 @@
/* eslint-disable react/jsx-props-no-spreading */
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import Head from "next/head";
import "styles/globals.css";
@@ -70,7 +69,6 @@ const tailwindSafelist = [
function MyApp({ Component, pageProps }) {
return (
<SessionProvider session={pageProps.session}>
<SWRConfig
value={{
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
@@ -93,7 +91,6 @@ function MyApp({ Component, pageProps }) {
</ThemeProvider>
</ColorProvider>
</SWRConfig>
</SessionProvider>
);
}

View File

@@ -1,114 +0,0 @@
import { timingSafeEqual } from "node:crypto";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
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;
// 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;
}
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
const hasOidcConfig = Boolean(issuer && clientId && clientSecret);
const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret);
if (authEnabled) {
if (hasOidcConfig) {
if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) {
throw new Error("OIDC auth is enabled but required settings are missing.");
}
} else if (hasAnyOidcConfig) {
throw new Error("OIDC auth is enabled but required settings are missing.");
} else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) {
throw new Error("Password auth is enabled but required settings are missing.");
}
}
let providers = [];
if (authEnabled) {
if (hasOidcConfig) {
providers = [
{
id: "homepage-oidc",
name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC",
type: "oauth",
idToken: true,
issuer: cleanedIssuer,
wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`,
clientId,
clientSecret,
authorization: {
params: {
scope: defaultScope,
},
},
profile(profile) {
return {
id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email,
name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email,
email: profile.email ?? null,
image: profile.picture ?? null,
};
},
},
];
} else {
providers = [
CredentialsProvider({
name: "Password",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const provided = credentials?.password ?? "";
const expected = homepageAuthPassword ?? "";
if (!expected || provided.length !== expected.length) {
return null;
}
const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!isMatch) {
return null;
}
return {
id: "homepage",
name: "Homepage",
};
},
}),
];
}
}
export default NextAuth({
providers,
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
pages: {
signIn: "/auth/signin",
},
debug: true,
logger: {
error: (...args) => console.error("[nextauth][error]", ...args),
warn: (...args) => console.warn("[nextauth][warn]", ...args),
debug: (...args) => console.debug("[nextauth][debug]", ...args),
},
events: {
signIn: async (message) => console.debug("[nextauth][event][signIn]", message),
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
error: async (message) => console.error("[nextauth][event][error]", message),
},
});

View File

@@ -1,210 +0,0 @@
import classNames from "classnames";
import { getProviders, signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { BiShieldQuarter } from "react-icons/bi";
import { getSettings } from "utils/config/config";
export default function SignIn({ providers, settings }) {
const router = useRouter();
const [password, setPassword] = useState("");
const theme = settings?.theme || "dark";
const color = settings?.color || "slate";
const title = settings?.title || "Homepage";
const callbackUrl = useMemo(() => {
const value = router.query?.callbackUrl;
return typeof value === "string" ? value : "/";
}, [router.query?.callbackUrl]);
const error = router.query?.error;
let backgroundImage = "";
let opacity = settings?.backgroundOpacity ?? 0;
let backgroundBlur = false;
let backgroundSaturate = false;
let backgroundBrightness = false;
if (settings?.background) {
const bg = settings.background;
if (typeof bg === "object") {
backgroundImage = bg.image || "";
if (bg.opacity !== undefined) {
opacity = 1 - bg.opacity / 100;
}
backgroundBlur = bg.blur !== undefined;
backgroundSaturate = bg.saturate !== undefined;
backgroundBrightness = bg.brightness !== undefined;
} else {
backgroundImage = bg;
}
}
useEffect(() => {
const html = document.documentElement;
const body = document.body;
html.classList.remove("dark", "scheme-dark", "scheme-light");
html.classList.toggle("dark", theme === "dark");
html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light");
const desiredThemeClass = `theme-${color}`;
const themeClassesToRemove = Array.from(html.classList).filter(
(cls) => cls.startsWith("theme-") && cls !== desiredThemeClass,
);
if (themeClassesToRemove.length) {
html.classList.remove(...themeClassesToRemove);
}
if (!html.classList.contains(desiredThemeClass)) {
html.classList.add(desiredThemeClass);
}
body.style.backgroundImage = "";
body.style.backgroundColor = "";
body.style.backgroundAttachment = "";
}, [color, theme]);
if (!providers || Object.keys(providers).length === 0) {
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main
className={classNames(
"relative flex min-h-screen items-center justify-center px-6 py-12",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="relative w-full max-w-xl overflow-hidden rounded-3xl border border-white/40 bg-white/80 p-10 text-center shadow-2xl shadow-black/10 dark:border-white/10 dark:bg-slate-900/70">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-theme-500/20 via-theme-500/5 to-transparent"
/>
<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>
</div>
</main>
</>
);
}
const passwordProvider = providers
? Object.values(providers).find((provider) => provider.type === "credentials")
: null;
const hasPasswordProvider = Boolean(passwordProvider);
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main className="relative flex min-h-screen items-center justify-center px-6 py-12">
<div
className={classNames(
"relative w-full max-w-4xl overflow-hidden rounded-3xl border border-white/50 bg-white/80 shadow-2xl shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="pointer-events-none absolute -left-24 -top-20 h-64 w-64 rounded-full bg-theme-500/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-theme-500/10 blur-3xl" />
<div className="grid gap-10 px-8 py-12 md:grid-cols-[1.2fr_1fr] md:px-12">
<section className="flex flex-col justify-between">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-theme-500/30 bg-theme-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-theme-600 dark:text-theme-300">
Login Required
</div>
<h1 className="mt-6 text-3xl font-semibold text-gray-900 dark:text-slate-100">{title}</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-300">Login to view your dashboard.</p>
</div>
</section>
<section className="flex flex-col justify-center gap-6">
<div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70">
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
<div className="mt-6 space-y-3">
{hasPasswordProvider && (
<form
className="space-y-3"
onSubmit={async (event) => {
event.preventDefault();
await signIn(passwordProvider?.id ?? "credentials", {
redirect: true,
callbackUrl,
password,
});
}}
>
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300">Password</label>
<input
type="password"
name="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
className="w-full rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-gray-900 shadow-sm outline-none ring-0 transition focus:border-theme-500 focus:ring-2 focus:ring-theme-500/30 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-100"
required
/>
<button
type="submit"
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Sign in &rarr;</span>
</button>
</form>
)}
{!hasPasswordProvider &&
Object.values(providers).map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => signIn(provider.id, { callbackUrl })}
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Login via {provider.name} &rarr;</span>
</button>
))}
</div>
{hasPasswordProvider && error && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800/60 dark:bg-red-950/40 dark:text-red-200">
Invalid password. Please try again.
</p>
)}
</div>
</section>
</div>
</div>
</main>
</>
);
}
export async function getServerSideProps(context) {
const providers = await getProviders();
const settings = getSettings();
return {
props: { providers, settings },
};
}

View File

@@ -6,7 +6,7 @@ export function formatApiCall(url, args) {
if (key === "url") {
value = value.replace(/\/+$/, ""); // remove trailing slashes
}
return value || "";
return value?.toString() || "";
};
return url.replace(find, replace).replace(find, replace);

View File

@@ -24,7 +24,8 @@ export default function Component({ service }) {
const { data: images, error: imagesError } = useWidgetAPI(widget, "images");
const { data: updates, error: updatesError } = useWidgetAPI(widget, "updates");
const error = containersError ?? imagesError ?? updatesError;
const error =
containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;
if (error) {
return <Container service={service} error={error} />;
}

View File

@@ -34,6 +34,16 @@ describe("widgets/arcane/component", () => {
expect(screen.getByText("arcane.environment_required")).toBeInTheDocument();
});
it("shows an error when API calls return detail errors", () => {
useWidgetAPI.mockImplementation(() => ({ data: { detail: "Specific API error" }, error: undefined }));
renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("Specific API error")).toBeInTheDocument();
});
it("renders placeholders while loading data", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));

139
uv.lock generated Normal file
View File

@@ -0,0 +1,139 @@
version = 1
revision = 1
requires-python = ">=3.13"
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "deepmerge"
version = "2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475 },
]
[[package]]
name = "homepage-docs"
version = "1.0.0"
source = { virtual = "." }
dependencies = [
{ name = "zensical" },
]
[package.metadata]
requires-dist = [{ name = "zensical", specifier = ">=0.0.21" }]
[[package]]
name = "markdown"
version = "3.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684 },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pymdown-extensions"
version = "10.20.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
name = "zensical"
version = "0.0.21"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "deepmerge" },
{ name = "markdown" },
{ name = "pygments" },
{ name = "pymdown-extensions" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/50/2655b5f72d0c72f4366be580f5e2354ff05280d047ea986fe89570e44589/zensical-0.0.21.tar.gz", hash = "sha256:c13563836fa63a3cabeffd83fe3a770ca740cfa5ae7b85df85d89837e31b3b4a", size = 3819731 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/98/90710d232cb35b633815fa7b493da542391b89283b6103a5bb4ae9fc0dd9/zensical-0.0.21-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:67404cc70c330246dfb7269bcdb60a25be0bb60a212a09c9c50229a1341b1f84", size = 12237120 },
{ url = "https://files.pythonhosted.org/packages/97/fb/4280b3781157e8f051711732192f949bf29beeafd0df3e33c1c8bf9b7a1a/zensical-0.0.21-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d4fd253ccfbf5af56434124f13bac01344e456c020148369b18d8836b6537c3c", size = 12118047 },
{ url = "https://files.pythonhosted.org/packages/74/b3/b7f85ae9cf920cf9f17bf157ae6c274919477148feb7716bf735636caa0e/zensical-0.0.21-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:440e40cdc30a29bf7466bcd6f43ed7bd1c54ea3f1a0fefca65619358b481a5bc", size = 12473440 },
{ url = "https://files.pythonhosted.org/packages/d8/ac/1dc6e98f79ed19b9f103c88a0bd271f9140565d7d26b64bc1542b3ef6d91/zensical-0.0.21-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:368e832fc8068e75dc45cab59379db4cefcd81eb116f48d058db8fb7b7aa8d14", size = 12412588 },
{ url = "https://files.pythonhosted.org/packages/bd/76/16a580f6dd32b387caa4a41615451e7dddd1917a2ff2e5b08744f41b4e11/zensical-0.0.21-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4ab962d47f9dd73510eed168469326c7a452554dfbfdb9cdf85efc7140244df", size = 12749438 },
{ url = "https://files.pythonhosted.org/packages/95/30/4baaa1c910eee61db5f49d0d45f2e550a0027218c618f3dd7f8da966a019/zensical-0.0.21-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b846d53dfce007f056ff31848f87f3f2a388228e24d4851c0cafdce0fa204c9b", size = 12514504 },
{ url = "https://files.pythonhosted.org/packages/76/77/931fccae5580b94409a0448a26106f922dcfa7822e7b93cacd2876dd63a8/zensical-0.0.21-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:daac1075552d230d52d621d2e4754ba24d5afcaa201a7a991f1a8d57e320c9de", size = 12647832 },
{ url = "https://files.pythonhosted.org/packages/5b/82/3cf75de64340829d55c87c36704f4d1d8c952bd2cdc8a7bc48cbfb8ab333/zensical-0.0.21-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:7b380f545adb6d40896f9bd698eb0e1540ed4258d35b83f55f91658d0fdae312", size = 12678537 },
{ url = "https://files.pythonhosted.org/packages/77/91/6f4938dceeaa241f78bbfaf58a94acef10ba18be3468795173e3087abeb6/zensical-0.0.21-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c2227fdab64616bea94b40b8340bafe00e2e23631cc58eeea1e7267167e6ac5", size = 12822164 },
{ url = "https://files.pythonhosted.org/packages/a2/4e/a9c9d25ef0766f767db7b4f09da68da9b3d8a28c3d68cfae01f8e3f9e297/zensical-0.0.21-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2e0f5154d236ed0f98662ee68785b67e8cd2138ea9d5e26070649e93c22eeee0", size = 12785632 },
{ url = "https://files.pythonhosted.org/packages/8d/76/8627548f6d00e676009a129be49bb22ca2aa3c938df9b642eea18c381c98/zensical-0.0.21-cp310-abi3-win32.whl", hash = "sha256:0b72e1f8a880c130cbd52335e3bc5dff666291cec40eaf09061d5d7a5a9f8068", size = 11832998 },
{ url = "https://files.pythonhosted.org/packages/c1/9a/7767cfcf70a2022b2b4d49c48f022fde3feb3d3360ac084c111e75df96cf/zensical-0.0.21-cp310-abi3-win_amd64.whl", hash = "sha256:eec08ff6fa335c11b81599032aaeda91c65d31ef03b97aa9239074bf19963a98", size = 12033476 },
]

View File

@@ -8,12 +8,6 @@ afterEach(() => {
if (typeof document !== "undefined") cleanup();
});
// Avoid NextAuth client-side fetches during unit tests.
vi.mock("next-auth/react", () => ({
SessionProvider: ({ children }) => children ?? null,
getProviders: vi.fn(async () => ({})),
}));
// implement a couple of common formatters mocked in next-i18next
vi.mock("next-i18next", () => ({
// Keep app/page components importable in unit tests.