Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a648a9fdff | ||
|
|
74fa51a461 | ||
|
|
48438fc003 | ||
|
|
657704340c | ||
|
|
3cf1dc8341 | ||
|
|
2eb0baf17e | ||
|
|
ecfb9d59e0 | ||
|
|
93c95e1846 | ||
|
|
1ce69d78f0 | ||
|
|
6d1ab12788 | ||
|
|
5cd40b5c53 | ||
|
|
45dd20f4f7 | ||
|
|
6eff1da08f | ||
|
|
2f0c0a07dd | ||
|
|
944c674939 | ||
|
|
7a2d6009f7 | ||
|
|
6ae109e8f5 | ||
|
|
98d56264da | ||
|
|
6f957ee984 | ||
|
|
a7eae3ff81 | ||
|
|
b82a6a7abc | ||
|
|
72caa31b57 | ||
|
|
4ac9d21bc1 | ||
|
|
723b46926a | ||
|
|
e8a2c87c2d | ||
|
|
4f6e335f72 | ||
|
|
df967751f6 |
No files matched your search
+20
-3
@@ -61,9 +61,26 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
# Enable mock integration
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Public Workshop API origin used by Homarr's Workshop browser. The default is https://homarr.dev.
|
||||
# This is read when the server starts, including in the production Docker image.
|
||||
# WORKSHOP_API_URL=http://127.0.0.1:3003
|
||||
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
|
||||
# query strings, or fragments. Trailing slashes are normalized at startup.
|
||||
HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
# PocketBase API used by Homarr and by the standalone documentation site.
|
||||
# Defaults to HOMARR_WEBSITE_URL.
|
||||
# WORKSHOP_API_URL=https://homarr.dev
|
||||
# Public Workshop page opened from Homarr. Defaults to HOMARR_WEBSITE_URL/workshop.
|
||||
# WORKSHOP_WEB_URL=https://homarr.dev/workshop
|
||||
|
||||
# Documentation and PocketBase deployment settings.
|
||||
# WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
|
||||
# PB_ALLOWED_ORIGINS=*
|
||||
|
||||
# Local Workshop stack:
|
||||
# HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
# WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
# WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
# WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
|
||||
# Optional: GITHUB_CLIENT_ID=
|
||||
# Optional: GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
||||
# DEMO_MODE=true
|
||||
|
||||
@@ -32,4 +32,4 @@ jobs:
|
||||
run: |
|
||||
git diff --name-only --diff-filter=ACMR -z "origin/${BASE_REF}...HEAD" -- \
|
||||
'*.css' '*.js' '*.json' '*.jsonc' '*.jsx' '*.md' '*.mdx' '*.scss' '*.ts' '*.tsx' '*.yaml' '*.yml' \
|
||||
| xargs -0 -r pnpm exec oxfmt --check
|
||||
| xargs -0 -r pnpm exec oxfmt --check --
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
types: [closed]
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: [main, dev, beta, next]
|
||||
branches: [main, dev, beta, next, v2]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -34,8 +34,10 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
# Every event for a PR shares one group so a close event cancels stale image
|
||||
# publication and a newer synchronize run cannot be overwritten by an older run.
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' || github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
@@ -87,6 +89,17 @@ jobs:
|
||||
- name: Validate workspace configuration
|
||||
run: pnpm lint:ws
|
||||
|
||||
- name: Validate Custom Widget architecture
|
||||
run: pnpm check:custom-widgets
|
||||
|
||||
- name: Validate checked-in OpenAPI schema
|
||||
run: pnpm check:openapi
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm --filter nextjs build
|
||||
pnpm check:custom-widget-bundle
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
@@ -229,7 +242,7 @@ jobs:
|
||||
needs: fast-gate
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -286,7 +299,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
pull-requests: read
|
||||
env:
|
||||
PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
steps:
|
||||
@@ -302,6 +317,26 @@ jobs:
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Verify PR head before publication
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
with:
|
||||
script: |
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.setFailed(`PR #${pull.number} is ${pull.state}; refusing to publish its preview.`);
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== process.env.EXPECTED_HEAD_SHA) {
|
||||
core.setFailed(`PR #${pull.number} moved from ${process.env.EXPECTED_HEAD_SHA} to ${pull.head.sha}.`);
|
||||
}
|
||||
|
||||
- name: Publish preview manifest
|
||||
if: "!env.ACT"
|
||||
env:
|
||||
|
||||
@@ -2,22 +2,8 @@ name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/workshop/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/workshop.yml"
|
||||
push:
|
||||
branches: [dev, v2]
|
||||
paths:
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/workshop/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/workshop.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+8
-2
@@ -21,8 +21,14 @@ pnpm dev:docs
|
||||
|
||||
# Or from within this directory
|
||||
pnpm dev
|
||||
|
||||
# Point the docs at a local PocketBase instance
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
The standalone docs use `WORKSHOP_API_URL` for PocketBase and `HOMARR_WEBSITE_URL` for the public site origin. The old
|
||||
`WORKSHOP_URL` name is a deprecated one-release alias.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@@ -40,7 +46,7 @@ Uses oxlint and oxfmt (same as the rest of the monorepo):
|
||||
```bash
|
||||
pnpm lint # oxlint
|
||||
pnpm format # oxfmt (check)
|
||||
pnpm format -- --write # oxfmt (fix)
|
||||
pnpm format:fix # oxfmt (fix)
|
||||
```
|
||||
|
||||
## Typecheck
|
||||
@@ -93,7 +99,7 @@ pnpm verify:search
|
||||
| Layer | Technology |
|
||||
| -------------- | ------------------------------------------- |
|
||||
| Site generator | Docusaurus 3 (`@docusaurus/preset-classic`) |
|
||||
| Styling | Tailwind CSS 3 + Docusaurus Infima |
|
||||
| Styling | Tailwind CSS 4 + Docusaurus Infima |
|
||||
| Icons | `@tabler/icons-react` |
|
||||
| Diagrams | Mermaid (`@docusaurus/theme-mermaid`) |
|
||||
| Search | Algolia DocSearch |
|
||||
|
||||
@@ -8,62 +8,107 @@ tags:
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
## Setting up your development environment
|
||||
|
||||
This guide will help you set up your development environment to start contributing to homarr.
|
||||
## Set up your development environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
|
||||
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
|
||||
- **Git**: You can download it from the [official website](https://git-scm.com/).
|
||||
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
|
||||
- **A GitHub account**: You can create one on the [official website](https://github.com/).
|
||||
- Node.js 24.18 or newer
|
||||
- Corepack and pnpm 11.11
|
||||
- Git
|
||||
- Docker with Docker Compose
|
||||
|
||||
### Setting up the project
|
||||
The versions in the root `package.json` are the supported baseline. Let Corepack select the repository version instead
|
||||
of installing an unrelated global pnpm:
|
||||
|
||||
1. Fork the repository by clicking on the "Fork" button on the top right corner of the repository page.
|
||||
```sh
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||

|
||||
### Run the Homarr application
|
||||
|
||||
2. Clone the repository to your local machine with `git clone`. You can find the url to use by clicking on the "Code" button on the top right corner of your forks repository page.
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Set `DB_URL` to an absolute writable SQLite file path.
|
||||
3. Keep Redis and the optional local databases running in one terminal.
|
||||
4. Apply migrations and start Next.js in another terminal.
|
||||
|
||||

|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Change directory to the project folder with `cd homarr`.
|
||||
Terminal 1:
|
||||
|
||||
4. Install the dependencies with `pnpm install`.
|
||||
```sh
|
||||
pnpm docker:dev
|
||||
```
|
||||
|
||||
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
|
||||
Terminal 2:
|
||||
|
||||
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
|
||||
```sh
|
||||
pnpm db:migration:sqlite:run
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
|
||||
The infrastructure command remains in the foreground. Open `http://127.0.0.1:3000`; the first user created through
|
||||
Homarr becomes the instance administrator.
|
||||
|
||||
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
|
||||
### Run PocketBase and documentation
|
||||
|
||||
9. Open your browser and navigate to [http://localhost:3000](http://localhost:3000). (Loading the page might take up to 2 minutes the first time)
|
||||
Start PocketBase in another terminal:
|
||||
|
||||
### Useful npm scripts
|
||||
```sh
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
#### During development
|
||||
Then start Docusaurus in a third terminal:
|
||||
|
||||
- `pnpm dev`: Start the development server.
|
||||
- `pnpm cli`: Run cli commands.
|
||||
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
|
||||
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
|
||||
- `pnpm db:studio`: Open a visual studio for the database.
|
||||
- `pnpm package:new`: Create a new package in the monorepo.
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
#### Checking code quality
|
||||
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
|
||||
|
||||
- `pnpm lint`: Run the linter to check for code quality issues.
|
||||
- `pnpm format:fix`: Run the formatter to fix code style issues.
|
||||
- `pnpm test`: Run the tests. You can also use `pnpm test:ui` to get a UI for the tests.
|
||||
- `pnpm typecheck`: Run the typescript type checker.
|
||||
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
|
||||
`http://127.0.0.1:3003/workshop`.
|
||||
|
||||
#### Building the project
|
||||
Point Homarr at the local services with:
|
||||
|
||||
- `pnpm build`: Build the project for production.
|
||||
- `docker build -t homarr .`: Build a docker image for the project.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See
|
||||
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
|
||||
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
|
||||
|
||||
### URL testing
|
||||
|
||||
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
|
||||
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
|
||||
separate API origin before release:
|
||||
|
||||
```sh
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 \
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
|
||||
pnpm dev:docs
|
||||
```
|
||||
|
||||
### Useful commands
|
||||
|
||||
- `pnpm dev` — Next.js on port 3000.
|
||||
- `pnpm dev:docs` — Docusaurus only on port 3003.
|
||||
- `pnpm docker:workshop` — local PocketBase through Docker Compose.
|
||||
- `pnpm cli` — Homarr operations CLI.
|
||||
- `pnpm db:migration:sqlite:run` — create or update a SQLite database.
|
||||
- `pnpm db:studio` — inspect the database.
|
||||
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
|
||||
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
|
||||
- `pnpm typecheck` — typecheck all workspaces.
|
||||
- `pnpm test` — unit and contract suites.
|
||||
- `pnpm test:workshop` — disposable Workshop integration suite.
|
||||
- `pnpm build` — production monorepo build.
|
||||
@@ -25,13 +25,16 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
|
||||
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -105,7 +108,12 @@ External redis is currently not supported with Proxmox Community Scripts
|
||||
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||
| `WORKSHOP_API_URL` | Runtime public origin of the separate Workshop PocketBase service. Homarr uses `https://homarr.dev` by default. |
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. |
|
||||
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
|
||||
`WORKSHOP_API_URL` for one release. `WORKSHOP_API_URL` takes precedence.
|
||||
@@ -4,6 +4,9 @@ An external agent creates one widget at a time with the Homarr MCP server. The a
|
||||
|
||||
Homarr does not create widget packs or drafts for multiple widgets.
|
||||
|
||||
The MCP API key must belong to a Homarr administrator. Non-administrator keys do not list Custom Widget tools, prompts,
|
||||
or resources. The same administrator requirement applies to the matching HTTP authoring endpoints.
|
||||
|
||||
<a className="button button--primary" href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget">
|
||||
View the Custom Widget Skill on skills.sh
|
||||
</a>
|
||||
@@ -67,7 +70,10 @@ These interfaces do not return an archive URL or archive hash.
|
||||
12. Update the saved definition if the final verification finds a problem.
|
||||
13. Repeat the procedure for the next widget.
|
||||
|
||||
Each source-configuration URL applies to one definition or preview and one source. The user confirms the server URL, network scope, and required credential fields directly in Homarr. The agent receives only request status and never receives plaintext credentials. Preview-scoped values expire with that preview; definition-scoped values persist.
|
||||
Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and
|
||||
can only be opened and completed by an authenticated Homarr administrator. The administrator confirms the server URL,
|
||||
network scope, and required credential fields directly in Homarr. The agent receives only request status and never
|
||||
receives plaintext credentials. Preview-scoped values expire with that preview; definition-scoped values persist.
|
||||
|
||||
Agents can install existing widgets without regenerating them:
|
||||
|
||||
@@ -88,8 +94,11 @@ Homarr maintainers can run the same self-contained prompt that the workbench cop
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai
|
||||
```
|
||||
|
||||
The suite uses DeepSeek V4 Pro to author five complex widgets and DeepSeek V4 Flash to judge only widgets that pass Homarr's real parser and canonical schema. It saves every response, validated `widget.json`, `widget.jsx`, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
|
||||
The suite uses DeepSeek V4 Pro to author Pokédex, Portainer, football, Jellyfin, and Home Assistant widgets. Before DeepSeek V4 Flash judges a result, it must pass Homarr's real clipboard parser, canonical schema, and deterministic scenario checks for the documented API routes, authentication, bindings, actions, and essential runtime components. It saves every response, validated widget manifest, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
|
||||
|
||||
The run has a hard budget of ten generation attempts across all scenarios. Override the models or budget with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and `CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result into the bundled-widget source deliberately.
|
||||
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the models or
|
||||
per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and
|
||||
`CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result
|
||||
into the bundled-widget source deliberately.
|
||||
|
||||
The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state.
|
||||
@@ -27,11 +27,12 @@ Every option has a default. Supported controls include text, textarea, number, s
|
||||
|
||||
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
|
||||
The model must return exactly:
|
||||
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
|
||||
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
|
||||
without any placeholder replacement or second paste.
|
||||
|
||||
1. One fenced `json` block with `template: "__HOMARR_TEMPLATE__"`.
|
||||
2. One fenced `jsx` block with the readable template.
|
||||
|
||||
Select **Paste AI response**. Homarr fills the visual builders and shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading, empty, failure, and success states.
|
||||
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
|
||||
recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading,
|
||||
empty, failure, and success states.
|
||||
|
||||
Connected agents should use the skills.sh skill and retrieve the live schema and individual component metadata only as needed.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Component reference
|
||||
sidebar_position: 3
|
||||
tags: [Custom Widgets, JSX, Mantine, Components]
|
||||
---
|
||||
|
||||
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
|
||||
|
||||
# Custom JSX component reference
|
||||
|
||||
Search the components and properties exposed by Homarr's safe Custom JSX runtime. The live schema and diagnostics remain authoritative: properties marked as blocked cannot be passed from widget code, and denied components are shown only for explanation.
|
||||
|
||||
<CustomJsxComponentReference />
|
||||
@@ -38,7 +38,8 @@ Bounded regex literals are supported for string matching, splitting, search, and
|
||||
|
||||
- `SubFetch` runs a manual query with its `params`. Set `triggerContent` to a card or image to make that content the accessible click and keyboard trigger instead of showing the default load button.
|
||||
- `ActionButton` and `ToggleSwitch` run actions.
|
||||
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries.
|
||||
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries for authenticated viewers. It
|
||||
is disabled for anonymous public-board viewers because refreshing invalidates shared server cache state.
|
||||
- `Icon`/`TablerIcon` resolves installed safe icons.
|
||||
|
||||
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
|
||||
@@ -49,19 +49,52 @@ interface HomarrCustomWidgetV2 {
|
||||
|
||||
The browser renders JSX. Homarr's server executes named requests, applies board permissions and network protections, and injects credentials. The browser never receives source credentials.
|
||||
|
||||
## Permissions
|
||||
|
||||
Custom Widget management is an administrator-only surface. Only a Homarr administrator can create, import, migrate,
|
||||
preview, publish, install, configure, or write secrets, including through HTTP authoring resources or MCP. An API key
|
||||
only lists Custom Widget MCP tools, prompts, and resources when its owner is an administrator.
|
||||
|
||||
Adding or configuring a Custom Widget placement requires both administrator access and permission to modify that
|
||||
board. A non-administrator with board-modify access can move, resize, change presentation settings, or remove an
|
||||
existing placement, but cannot add one, change its definition, refresh interval, or Custom Widget options. Duplicating
|
||||
a board that contains Custom Widgets also requires an administrator.
|
||||
|
||||
Once an administrator has placed a widget, ordinary board permissions apply to rendering and requests. Authorized
|
||||
non-administrators can use it on private boards, and queries can render for anonymous viewers on public boards.
|
||||
Mutations remain authenticated and enforce each request's view, modify, or full permission.
|
||||
|
||||
## Authoring paths
|
||||
|
||||
- [Create a Custom Widget](./authoring) visually or with one copied AI prompt.
|
||||
- [Use the JSX runtime](./custom-jsx) for data, Mantine components, temporary inputs, and diagnostics.
|
||||
- [Browse the supported component reference](./component-reference).
|
||||
- [Configure requests and security](./requests-and-security).
|
||||
- [Use MCP and the skills.sh skill](./agent-authoring).
|
||||
- [Correct problems](./troubleshooting).
|
||||
|
||||
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and only disable the widget preview.
|
||||
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected.
|
||||
|
||||
Exports, imports, Workshop, MCP, and storage all validate the same schema. Old formats are rejected. A missing definition only renders **Custom widget unavailable** inside its tile.
|
||||
Exports, imports, the official Workshop client, and MCP validate the same schema. PocketBase stores community
|
||||
submissions without interpreting their content, and Homarr validates them before installation. Widgets saved in the v1
|
||||
format remain visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration
|
||||
prompt**, give the redacted prompt to an LLM, copy its single complete JSON block, then select **Paste migrated widget**
|
||||
from that widget's action menu. Homarr reads the `template` value into the JSX editor automatically. The prompt keeps safe
|
||||
request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
|
||||
hard-coded credentials. Add a credential-free description of the widget's visible layout and behavior when sending the
|
||||
prompt.
|
||||
|
||||
When adding widgets while editing a board, the Custom Widgets section always includes **Install from Workshop**. The Workshop and API source review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile waits for the board to be saved before server requests run and loads automatically after a successful save; no page reload is required.
|
||||
Homarr keeps the original v1 definition and encrypted secret as a compatibility backup even after the validated v2
|
||||
replacement is created. The v2 record uses the same stable ID and takes precedence. The archived v1 copy stays hidden
|
||||
while the replacement exists; deleting the v2 record surfaces the v1 entry as migration-required again. Automatic
|
||||
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
|
||||
logs, or exports.
|
||||
|
||||
For administrators adding widgets while editing a board, the Custom Widgets section includes **Install from
|
||||
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. The Workshop and API source
|
||||
review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile
|
||||
waits for the board to be saved before server requests run and loads automatically after a successful save; no page
|
||||
reload is required.
|
||||
|
||||
## Import and Workshop
|
||||
|
||||
@@ -69,10 +102,25 @@ Import review shows origins, authentication modes, network scopes, methods, acti
|
||||
|
||||
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
|
||||
|
||||
Workshop publishes technically valid widgets immediately; users can vote or report them, and administrators can delete submissions. Publishers should replace personal private-network hosts with a clear service placeholder before publishing.
|
||||
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
|
||||
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
|
||||
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
|
||||
performs definitive validation again at install time.
|
||||
|
||||
Connected agents can search, inspect, and install Workshop widgets with `customWidget_workshopSearch`, `customWidget_workshopGet`, and `customWidget_workshopInstall`. After installation they can set a source URL and credentials with `customWidget_sourceConfigure`, or create a secure user-facing setup URL with `customWidget_configurationRequestUser`. Secret values are never returned through MCP.
|
||||
|
||||
These tools are only exposed to API keys owned by Homarr administrators. Opening and completing a source-configuration
|
||||
URL also requires the same authenticated administrator; the URL is short-lived, single-use, and redacted.
|
||||
|
||||
## Reliability and rollback
|
||||
|
||||
Custom Widget response caches and action invalidation use shared Redis state, so multiple Homarr replicas see the same
|
||||
versioned cache generation. Deployments must point every replica at the same Redis service.
|
||||
|
||||
Before upgrading, back up the database and `SECRET_ENCRYPTION_KEY`; a v1 binary can still read preserved v1 tables
|
||||
during application rollback. Widgets created only in v2 may be unavailable to the rolled-back binary, but do not
|
||||
replace the v1 backup.
|
||||
|
||||
## Bundled examples
|
||||
|
||||
Homarr seeds five disabled examples without credentials and never overwrites an edited seed. They use the same public contract. The Tautulli example is the highest-scoring widget preserved from Homarr's AI authoring evaluation:
|
||||
|
||||
@@ -12,7 +12,9 @@ Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `pe
|
||||
|
||||
The request `path` is appended to the source `baseUrl`, including a base path such as `/api/v2`.
|
||||
|
||||
GET, POST, PUT, PATCH, and DELETE work for queries and actions. Kind—not method—controls behavior. Load queries run automatically, manual queries run through `SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`.
|
||||
GET, POST, PUT, and PATCH work for queries and actions. DELETE is restricted to actions so it can never run while a
|
||||
board loads. Kind controls the remaining behavior: load queries run automatically, manual queries run through
|
||||
`SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`.
|
||||
|
||||
Bind values directly where they are used:
|
||||
|
||||
@@ -40,3 +42,12 @@ Paths and query-string values must resolve to text, numbers, or booleans. JSON b
|
||||
Every request uses Homarr's existing server executor: same-origin source enforcement, network scope, DNS and redirect validation, SSRF protection, board permissions, rate and request limits, body and response limits, timeouts, and encrypted credential injection. Static headers cannot override credentials, cookies, routing, proxy, forwarding, or hop-by-hop headers.
|
||||
|
||||
Queries default to view permission and actions to modify. DELETE always requires full permission and confirmation. Actions are never cached, retried, or run on load.
|
||||
|
||||
Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and
|
||||
configures the widget. After placement, query and action authorization follows the request's board permission. Public
|
||||
boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache
|
||||
invalidation: `RefreshButton` is disabled for anonymous viewers, while ordinary scheduled query rendering remains
|
||||
available.
|
||||
|
||||
Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every
|
||||
Homarr replica stops serving the previous response without scanning or deleting a key prefix.
|
||||
@@ -2,9 +2,20 @@
|
||||
|
||||
## Correct an unavailable widget
|
||||
|
||||
The definition does not exist. Remove the unavailable tile from the board.
|
||||
The definition does not exist. If it was deleted, remove the unavailable tile from the board.
|
||||
|
||||
Then, install or create a Custom JSX v2 definition. Homarr does not provide recovery or migration for old formats.
|
||||
If the Custom Widgets page shows the definition with a **Migration required** badge, an administrator can open its
|
||||
action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON
|
||||
from the same menu. The LLM returns one complete JSON block; copy it once and select **Paste migrated widget**. Homarr
|
||||
places its `template` value into the JSX editor automatically. Because arbitrary v1 display text or JSX may contain
|
||||
credentials, the prompt omits that display configuration; describe the old widget's visible layout and behavior without
|
||||
including secrets. Other viewers see **Contact an administrator** instead of a migration link.
|
||||
|
||||
Homarr keeps the v1 definition and encrypted secret after migration. The validated v2 replacement uses the same ID and
|
||||
takes precedence; the archived v1 record reappears if the v2 replacement is deleted. There is no v1 permanent-delete
|
||||
action during the v2 stabilization period.
|
||||
|
||||
Otherwise, install or create a Custom JSX v2 definition.
|
||||
|
||||
## Correct an invalid saved definition
|
||||
|
||||
@@ -12,9 +23,7 @@ The Custom Widgets page isolates a definition that does not conform to the curre
|
||||
|
||||
An invalid definition shows its validation errors. You can delete it.
|
||||
|
||||
You cannot edit, enable, export, duplicate, publish, or select an invalid definition. Recreate the definition or import a valid definition.
|
||||
|
||||
Homarr does not migrate old Custom JSX formats.
|
||||
You cannot edit, enable, export, duplicate, publish, or select an invalid v2 definition. Recreate the definition or import a valid definition. Preserved v1 definitions use the separate **Migration required** workflow described above.
|
||||
|
||||
## Correct validation errors
|
||||
|
||||
@@ -30,10 +39,15 @@ An incomplete API source URL or icon URL causes a form validation error. The wor
|
||||
|
||||
The workbench marks the invalid field while you type. An invalid URL does not replace the form with the error boundary.
|
||||
|
||||
An invalid definition disables only the widget preview. The Data, Options, Actions, and Diagnostics tabs remain available.
|
||||
An invalid definition disables widget preview and Save/Create. The Data, Options, Actions, and Diagnostics tabs remain available so you can correct it.
|
||||
|
||||
The Diagnostics tab shows the current schema errors.
|
||||
|
||||
## Workshop unavailable
|
||||
|
||||
If Workshop is unavailable, check `WORKSHOP_API_URL` and PocketBase health. Widgets that are already installed continue
|
||||
to work.
|
||||
|
||||
## Correct request errors
|
||||
|
||||
Check the API source network scope. Check the base URL and credentials.
|
||||
@@ -44,6 +58,9 @@ For “invalid placeholder,” use `{option:name}` or `{param:name}`. Load queri
|
||||
|
||||
The journal shows the method, request ID, path template, status, and duration.
|
||||
|
||||
In a multi-instance deployment, all Homarr replicas must share Redis. Versioned cache invalidation is distributed
|
||||
through Redis; replicas connected to different Redis databases can serve stale query responses after an action.
|
||||
|
||||
## Correct a Mantine prop warning
|
||||
|
||||
`UNKNOWN_MANTINE_PROP` is a warning. Homarr passes the prop to Mantine.
|
||||
@@ -57,3 +74,10 @@ Use safe widget options, temporary inputs, or named requests instead.
|
||||
The widget option definitions changed and the saved values are invalid. Open the item settings.
|
||||
|
||||
Correct the marked configuration values. Homarr does not infer widget option migrations.
|
||||
|
||||
## Roll back safely
|
||||
|
||||
Before an application upgrade, back up the database and preserve the exact `SECRET_ENCRYPTION_KEY`. The v2 schema keeps
|
||||
the physical v1 definition and secret tables readable by a v1 binary. Roll back the application without running
|
||||
manual cleanup, verify legacy widgets, and restore the database backup if the migration itself must be reversed.
|
||||
PocketBase Workshop data is a separate backup and restore operation.
|
||||
@@ -92,6 +92,10 @@ MCP supports two authentication methods:
|
||||
|
||||
API key authentication is simpler and recommended for most setups. The API key inherits the permissions of the user who created it. Tools that require higher privileges will return permission errors if the key lacks access.
|
||||
|
||||
Custom Widget and Workshop authoring are different: their tools, prompts, and resources are exposed only when the API
|
||||
key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an
|
||||
API key never elevates the owner.
|
||||
|
||||
## Testing the Connection
|
||||
|
||||
Verify your MCP endpoint is working:
|
||||
@@ -168,6 +172,9 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
||||
|
||||
An agent creates one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`.
|
||||
|
||||
The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview,
|
||||
secrets, publication, Workshop installation, and the HTTP authoring resources.
|
||||
|
||||
Then, read the live schema and component resources. These resources describe the installed Mantine components.
|
||||
|
||||
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
|
||||
@@ -194,7 +201,8 @@ Discovery resources:
|
||||
- `homarr://custom-widgets/examples/{name}`
|
||||
- `homarr://custom-widgets/skill`
|
||||
|
||||
An HTTP-only harness can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`, and `/skill` endpoints instead of MCP resource URIs.
|
||||
An authenticated administrator can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`,
|
||||
and `/skill` endpoints instead of MCP resource URIs. These endpoints are not public schema downloads.
|
||||
|
||||
The Custom Widget tools are:
|
||||
|
||||
@@ -219,7 +227,13 @@ The Custom Widget tools are:
|
||||
- `customWidget_getAuthoringPrompt`
|
||||
- `customWidget_getSkill`
|
||||
|
||||
The `custom-widget-manage` permission permits authoring. A direct credential write also requires the `custom-widget-secret-write` permission.
|
||||
Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete
|
||||
authoring surface. A source-configuration URL is short-lived and single-use, requires the same logged-in administrator
|
||||
to open and complete it, and never returns plaintext credentials to the agent.
|
||||
|
||||
Adding a `customApi` item through `board_addItem`, changing its definition or options, and duplicating a board that
|
||||
contains one also require an administrator with board-modify access. Existing placements continue to render and run
|
||||
authorized requests according to normal board permissions.
|
||||
|
||||
Homarr applies its request security controls during request execution. These controls include SSRF, DNS, redirect, size, timeout, rate, and permission controls.
|
||||
|
||||
|
||||
@@ -177,6 +177,11 @@ Currently, the following permissions are available:
|
||||
- **Full integration access**: Allow members to manage, use and interact with any integration
|
||||
- **Administrator**: Members with this permission have full access to all features and settings
|
||||
|
||||
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
||||
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
||||
presentation settings, or remove an existing Custom Widget placement, and board viewers can use placed widgets as
|
||||
allowed by each request.
|
||||
|
||||
When you changed permissions, you must confirm your changes by clicking the banner at the bottom:
|
||||
|
||||

|
||||
|
||||
@@ -17,9 +17,15 @@ The [Community Workshop](pathname:///workshop) is where Homarr users share and d
|
||||
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
|
||||
you like, report abuse, and install entries directly into your own Homarr instance.
|
||||
|
||||
The central `homarr.dev` Workshop is a public community. Browsing it on the website is public. Browsing, installing,
|
||||
publishing, or importing Workshop content **inside a Homarr instance is restricted to Homarr administrators**.
|
||||
|
||||
:::warning
|
||||
|
||||
The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures.
|
||||
The Workshop contains community-made content. Moderation is not a security boundary, and PocketBase does not interpret
|
||||
or validate submission content. Always review widget capabilities, network origins, actions, and CSS before installing.
|
||||
Homarr's installation-time validator is definitive and can reject content that the public service accepted earlier.
|
||||
Back up your instance before installing untrusted content.
|
||||
|
||||
:::
|
||||
|
||||
@@ -29,12 +35,12 @@ The workshop contains community-made content. Widgets may contain unwanted or ma
|
||||
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
|
||||
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
|
||||
- Report details and reporter identities are visible only to Workshop administrators. Reported submissions are highlighted in the administration page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
|
||||
- Report details and reporter identities are visible only to Workshop moderators. Reported submissions are highlighted in the moderation page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
|
||||
- The manual **Outdated** badge warns visitors that a submission may need an update.
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop administrators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
|
||||
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop moderators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
|
||||
|
||||
## Submitting
|
||||
|
||||
@@ -49,7 +55,14 @@ Comments are publicly readable and appear in chronological conversation bubbles
|
||||
Submissions are published immediately. Reported entries are reviewed and may be removed.
|
||||
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
|
||||
|
||||
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop administrator receives an email when a report is submitted. If an administrator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
|
||||
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop moderator receives an email when a report is submitted. If a moderator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
|
||||
|
||||
:::info Separate roles
|
||||
|
||||
A Workshop moderator is a user with PocketBase's internal `isAdmin` field. This role moderates the central community
|
||||
and is unrelated to a Homarr instance administrator.
|
||||
|
||||
:::
|
||||
|
||||
## Submission guidelines
|
||||
|
||||
@@ -61,9 +74,9 @@ that primarily promotes unrelated products or services.
|
||||
|
||||
You can install workshop entries without leaving Homarr:
|
||||
|
||||
- **Custom widgets** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
|
||||
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
|
||||
board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
|
||||
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
|
||||
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
|
||||
pick a theme to load it into the editor before saving.
|
||||
|
||||
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
|
||||
@@ -74,13 +87,32 @@ Custom Widget imports detect required bearer, basic, header, or query credential
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Run `pnpm docker:workshop` for the PocketBase development service or `pnpm docker:docs` for the built documentation and Workshop site. Both use `apps/workshop/docker-compose.yml` and the persistent `pb_data` volume. Normal start and stop commands retain data; `docker compose -f apps/workshop/docker-compose.yml down --volumes` intentionally recreates the database.
|
||||
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
|
||||
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
|
||||
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
|
||||
irreversible development reset.
|
||||
|
||||
Because Workshop is still unreleased, reset an older development volume if it predates the current schema and comments report that their collection context is missing. No compatibility migrations are maintained for these pre-release databases.
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
|
||||
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
|
||||
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
|
||||
links and email use the public host. Workshop uses the OAuth provider username as the public display name instead of
|
||||
the profile's full name. Configure SMTP for comment, report, and removal notices.
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices.
|
||||
The public URL contract is:
|
||||
|
||||
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
|
||||
| Variable | Purpose | Default |
|
||||
| ------------------------ | ------------------------------------------------- | ----------------------------- |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
|
||||
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. Restart
|
||||
services after changing these values.
|
||||
|
||||
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
|
||||
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
|
||||
|
||||
## Client architecture
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ Enable the definition before you use it. The definition must also be available t
|
||||
|
||||
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
|
||||
|
||||
Queries and actions can use GET, POST, PUT, PATCH, or DELETE. The `kind` value controls runtime behavior.
|
||||
Queries and actions can use GET, POST, PUT, or PATCH. DELETE is valid only for actions so it can never run
|
||||
automatically while a board loads. The `kind` value controls the remaining runtime behavior.
|
||||
|
||||
A DELETE request requires full board permission and confirmation. An anonymous viewer cannot run an action.
|
||||
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import type * as Preset from "@docusaurus/preset-classic";
|
||||
import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
const deprecatedWorkshopUrl = process.env.WORKSHOP_URL;
|
||||
if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) {
|
||||
console.warn("WORKSHOP_URL is deprecated; use WORKSHOP_API_URL instead.");
|
||||
}
|
||||
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl,
|
||||
});
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
url: "https://homarr.dev",
|
||||
url: publicUrls.homarrWebsiteUrl,
|
||||
baseUrl: "/",
|
||||
trailingSlash: undefined,
|
||||
favicon: "img/logo.png",
|
||||
organizationName: "homarr-labs",
|
||||
projectName: "homarr",
|
||||
customFields: {
|
||||
workshopUrl: process.env.WORKSHOP_URL ?? "",
|
||||
workshopUrl: publicUrls.workshopApiUrl,
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
@@ -335,6 +345,55 @@ const config: Config = {
|
||||
function workshopRoutesPlugin() {
|
||||
return {
|
||||
name: "workshop-routes",
|
||||
injectHtmlTags() {
|
||||
return {
|
||||
headTags: [
|
||||
{
|
||||
tagName: "style",
|
||||
innerHTML: `
|
||||
#workshop-detail-route-fallback { display: none; }
|
||||
html[data-workshop-detail-loading] #__docusaurus { display: none; }
|
||||
html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
color: #4b5563;
|
||||
display: flex;
|
||||
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
background: #18191a;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
tagName: "script",
|
||||
innerHTML: `(function () {
|
||||
var match = window.location.pathname.match(/^\\/workshop\\/([^/]+)\\/?$/);
|
||||
if (match && match[1] !== "admin") {
|
||||
document.documentElement.setAttribute("data-workshop-detail-loading", "");
|
||||
}
|
||||
})();`,
|
||||
},
|
||||
],
|
||||
preBodyTags: [
|
||||
{
|
||||
tagName: "div",
|
||||
attributes: {
|
||||
id: "workshop-detail-route-fallback",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
},
|
||||
innerHTML: "Loading Workshop…",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async contentLoaded({ actions }) {
|
||||
actions.addRoute({
|
||||
path: "/workshop/:id",
|
||||
|
||||
@@ -84,7 +84,6 @@ export function BundledCustomWidgetGalleryClient() {
|
||||
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
|
||||
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
|
||||
if (!selected) return null;
|
||||
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
|
||||
|
||||
return (
|
||||
<MantineProvider forceColorScheme={colorMode}>
|
||||
@@ -103,19 +102,11 @@ export function BundledCustomWidgetGalleryClient() {
|
||||
</Text>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-manifest`}
|
||||
label="widget.json"
|
||||
label="Complete widget.json"
|
||||
language="json"
|
||||
value={JSON.stringify(manifest, null, 2)}
|
||||
value={JSON.stringify(selected.widget, null, 2)}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
/>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-template`}
|
||||
label="widget.jsx"
|
||||
language="jsx"
|
||||
value={selected.widget.template}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
height="680px"
|
||||
/>
|
||||
</Stack>
|
||||
</MantineProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "@docusaurus/Link";
|
||||
import { useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import {
|
||||
IconArrowBigDown,
|
||||
IconArrowBigUp,
|
||||
@@ -61,7 +62,7 @@ import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } fro
|
||||
import { formatRelativeTime } from "./format";
|
||||
import { ScreenshotEditor } from "./ScreenshotEditor";
|
||||
import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
|
||||
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
|
||||
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
|
||||
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
|
||||
@@ -305,28 +306,16 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
if (!submission || voting.current) return;
|
||||
voting.current = true;
|
||||
|
||||
const userId = await requireUserId("vote");
|
||||
if (!userId) {
|
||||
if (!(await requireUserId("vote"))) {
|
||||
voting.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = userVote;
|
||||
const isToggleOff = prev?.value === value;
|
||||
const [upD, downD] = voteDelta(prev?.value, value);
|
||||
|
||||
setUserVote(
|
||||
isToggleOff
|
||||
? undefined
|
||||
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote),
|
||||
);
|
||||
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
|
||||
|
||||
try {
|
||||
setUserVote((await backend.vote(submission.id, value)) ?? undefined);
|
||||
const reconciled = await voteAndReconcile(backend, submission.id, value);
|
||||
setSubmission(reconciled.submission);
|
||||
setUserVote(reconciled.votes.find((vote) => vote.submission === submission.id));
|
||||
} catch (caught) {
|
||||
setUserVote(prev);
|
||||
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s));
|
||||
setError(errorMessage(caught, "Failed to register your vote"));
|
||||
} finally {
|
||||
voting.current = false;
|
||||
@@ -347,7 +336,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
if (!submission) return;
|
||||
try {
|
||||
const updated = await backend.toggleOutdated(submission.id, !submission.outdated);
|
||||
setSubmission({ ...submission, outdated: updated.outdated });
|
||||
setSubmission(updated);
|
||||
setError(null);
|
||||
toast.success(updated.outdated ? "Submission marked as outdated" : "Submission marked as current");
|
||||
} catch (caught) {
|
||||
@@ -465,7 +454,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
setReportOpen(false);
|
||||
setReportExplanation("");
|
||||
setError(null);
|
||||
toast.success("Report submitted", { description: "A Workshop administrator can now review it." });
|
||||
toast.success("Report submitted", { description: "A Workshop moderator can now review it." });
|
||||
} catch (caught) {
|
||||
setReportError(errorMessage(caught, "Failed to submit report"));
|
||||
} finally {
|
||||
@@ -875,7 +864,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report submission</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only Workshop administrators can review report details and reporter identities.
|
||||
Only Workshop moderators can review report details and reporter identities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{reportError && (
|
||||
@@ -1029,6 +1018,10 @@ export default function MarketplaceDetailPage() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.removeAttribute("data-workshop-detail-loading");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
|
||||
<main className="marketplace bg-background text-foreground min-h-[80vh]">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ScreenshotGallery } from "./DetailSections";
|
||||
|
||||
vi.mock("@docusaurus/theme-common", () => ({ useColorMode: () => ({ colorMode: "light" }) }));
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function render(urls: string[]) {
|
||||
await act(async () => root.render(<ScreenshotGallery urls={urls} title="Gallery" />));
|
||||
}
|
||||
|
||||
describe("ScreenshotGallery", () => {
|
||||
it("resets an out-of-range selection when screenshots shrink", async () => {
|
||||
await render(["/one.png", "/two.png", "/three.png"]);
|
||||
const thirdThumbnail = host.querySelector<HTMLButtonElement>('button[aria-label="Show screenshot 3"]');
|
||||
if (!thirdThumbnail) throw new Error("Third screenshot thumbnail was not rendered");
|
||||
await act(async () => thirdThumbnail.click());
|
||||
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 3"]')?.src).toContain("/three.png");
|
||||
|
||||
await render(["/one.png"]);
|
||||
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 1"]')?.src).toContain("/one.png");
|
||||
expect(host.querySelector('img[alt="Gallery screenshot 3"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useColorMode } from "@docusaurus/theme-common";
|
||||
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@site/src/lib/utils";
|
||||
import { clampScreenshotIndex } from "./workshop-utils";
|
||||
|
||||
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
|
||||
const colorModeKeys = { dark: "dark", light: "light" } as const;
|
||||
@@ -44,11 +45,19 @@ export const DetailSkeleton = () => (
|
||||
|
||||
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const selectedIndex = clampScreenshotIndex(idx, urls.length);
|
||||
useEffect(() => setIdx((current) => clampScreenshotIndex(current, urls.length)), [urls.length]);
|
||||
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="group/gallery w-full">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img className="h-full w-full object-contain" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
|
||||
<img
|
||||
className="h-full w-full object-contain"
|
||||
src={urls[selectedIndex]}
|
||||
alt={`${title} screenshot ${selectedIndex + 1}`}
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -75,7 +84,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
|
||||
<legend className="sr-only">Screenshot previews</legend>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{urls.map((url, i) => {
|
||||
const selected = i === idx;
|
||||
const selected = i === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!type) setType("customWidget");
|
||||
setType("customWidget");
|
||||
setContentAndAutofill(text, title, description);
|
||||
if (step === 0) goTo(1);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
|
||||
reader.readAsText(jsonFile);
|
||||
return true;
|
||||
},
|
||||
[type, step, title, description, setContentAndAutofill, goTo],
|
||||
[step, title, description, setContentAndAutofill, goTo],
|
||||
);
|
||||
|
||||
const addImageFiles = useCallback(
|
||||
@@ -400,6 +400,7 @@ const StepType = ({
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
aria-label="Choose a widget JSON file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -52,7 +52,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
setSubmissions(nextSubmissions);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop moderation");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
<IconShield size={18} />
|
||||
<span className="text-sm font-medium">Restricted area</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Workshop administration</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Workshop moderation</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
Review reports and remove unsafe or outdated submissions. PocketBase rules enforce every action.
|
||||
</p>
|
||||
@@ -145,14 +145,14 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
{user && !user.isAdmin && (
|
||||
<Alert variant="destructive" className="mb-5">
|
||||
<IconShield />
|
||||
<AlertTitle>Administrator access required</AlertTitle>
|
||||
<AlertTitle>Workshop moderator access required</AlertTitle>
|
||||
<AlertDescription>This GitHub account cannot access Workshop moderation.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-5">
|
||||
<IconFlag />
|
||||
<AlertTitle>Workshop administration failed</AlertTitle>
|
||||
<AlertTitle>Workshop moderation failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
|
||||
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
|
||||
|
||||
import { voteDelta } from "./workshop-utils";
|
||||
import { voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
export type SortKey = "top" | "new" | "recent" | "discussed";
|
||||
export type TypeFilter = "all" | "yours" | SubmissionType;
|
||||
@@ -131,58 +131,32 @@ export const useWorkshop = (workshopUrl: string) => {
|
||||
if (votingIds.current.has(submissionId)) return;
|
||||
votingIds.current.add(submissionId);
|
||||
|
||||
const userId = await requireUserId("vote");
|
||||
if (!userId) {
|
||||
if (!(await requireUserId("vote"))) {
|
||||
votingIds.current.delete(submissionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = votes[submissionId];
|
||||
const isToggleOff = prev?.value === value;
|
||||
const [upD, downD] = voteDelta(prev?.value, value);
|
||||
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (isToggleOff) delete next[submissionId];
|
||||
else
|
||||
next[submissionId] = {
|
||||
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
|
||||
value,
|
||||
} as WorkshopVote;
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const saved = await backend.vote(submissionId, value);
|
||||
setVotes((current) => {
|
||||
const next = { ...current };
|
||||
if (saved) next[submissionId] = saved;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
} catch (caught) {
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (prev) next[submissionId] = prev;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
|
||||
const reconciled = await voteAndReconcile(backend, submissionId, value);
|
||||
setVotes(Object.fromEntries(reconciled.votes.map((row) => [row.submission, row])));
|
||||
setSubmissions((current) =>
|
||||
current.map((submission) =>
|
||||
submission.id === submissionId
|
||||
? {
|
||||
...submission,
|
||||
upvotes: reconciled.submission.upvotes,
|
||||
downvotes: reconciled.submission.downvotes,
|
||||
}
|
||||
: submission,
|
||||
),
|
||||
);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to register your vote"));
|
||||
} finally {
|
||||
votingIds.current.delete(submissionId);
|
||||
}
|
||||
},
|
||||
[backend, votes, requireUserId],
|
||||
[backend, requireUserId],
|
||||
);
|
||||
|
||||
const report = useCallback(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
|
||||
import { clampScreenshotIndex, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
describe("Workshop state reconciliation", () => {
|
||||
it("reloads both the submission counts and authenticated vote after voting", async () => {
|
||||
const submission = { id: "submission-1", upvotes: 8, downvotes: 2 } as WorkshopSubmission;
|
||||
const vote = { id: "vote-1", submission: "submission-1", value: 1 } as WorkshopVote;
|
||||
const backend = {
|
||||
vote: vi.fn().mockResolvedValue(vote),
|
||||
get: vi.fn().mockResolvedValue(submission),
|
||||
listVotesForCurrentUser: vi.fn().mockResolvedValue([vote]),
|
||||
};
|
||||
|
||||
await expect(voteAndReconcile(backend, submission.id, 1)).resolves.toEqual({ submission, votes: [vote] });
|
||||
expect(backend.vote).toHaveBeenCalledWith(submission.id, 1);
|
||||
expect(backend.get).toHaveBeenCalledWith(submission.id);
|
||||
expect(backend.listVotesForCurrentUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clamps a selected screenshot when the gallery shrinks", () => {
|
||||
expect(clampScreenshotIndex(3, 2)).toBe(1);
|
||||
expect(clampScreenshotIndex(2, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
|
||||
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
|
||||
@@ -9,8 +9,17 @@ export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
|
||||
if (!prev) return next === 1 ? [1, 0] : [0, 1];
|
||||
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
|
||||
return next === 1 ? [1, -1] : [-1, 1];
|
||||
interface WorkshopVoteBackend {
|
||||
vote(submissionId: string, value: 1 | -1): Promise<WorkshopVote | null>;
|
||||
get(submissionId: string): Promise<WorkshopSubmission>;
|
||||
listVotesForCurrentUser(): Promise<WorkshopVote[]>;
|
||||
}
|
||||
|
||||
export const voteAndReconcile = async (backend: WorkshopVoteBackend, submissionId: string, value: 1 | -1) => {
|
||||
await backend.vote(submissionId, value);
|
||||
const [submission, votes] = await Promise.all([backend.get(submissionId), backend.listVotesForCurrentUser()]);
|
||||
return { submission, votes };
|
||||
};
|
||||
|
||||
export const clampScreenshotIndex = (index: number, screenshotCount: number) =>
|
||||
Math.min(Math.max(0, index), Math.max(0, screenshotCount - 1));
|
||||
@@ -3,12 +3,11 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
|
||||
|
||||
export default function WorkshopAdminRoute() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
return (
|
||||
<Layout title="Workshop administration" noFooter>
|
||||
<Layout title="Workshop moderation" noFooter>
|
||||
<WorkshopAdmin workshopUrl={workshopUrl} />
|
||||
</Layout>
|
||||
);
|
||||
|
||||
+3010
-543
File diff suppressed because it is too large.
Load diff
+37
-17
@@ -1,28 +1,48 @@
|
||||
# Create T3 App
|
||||
# Homarr web application
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
This workspace contains Homarr's Next.js application. It consumes the shared API, authentication, database, board,
|
||||
widget, translation, and UI packages from this monorepo.
|
||||
|
||||
## What's next? How do I make an app with this?
|
||||
Run commands from the repository root:
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
Keep infrastructure running in the first terminal:
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Drizzle](https://orm.drizzle.team)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
```sh
|
||||
pnpm docker:dev
|
||||
```
|
||||
|
||||
## Learn More
|
||||
Apply migrations and start the application in a second terminal:
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
```sh
|
||||
pnpm db:migration:sqlite:run
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
The app listens on `http://127.0.0.1:3000`. Configure an absolute writable `DB_URL`, `AUTH_SECRET`, and
|
||||
`SECRET_ENCRYPTION_KEY` in `.env` before first startup.
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
To test Custom Widgets against a local Community Workshop, start PocketBase and the docs in separate terminals:
|
||||
|
||||
## How do I deploy this?
|
||||
```sh
|
||||
pnpm docker:workshop
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
Then set:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Server-rendered runtime configuration exposes those URLs to the browser, so changing a production container's
|
||||
environment does not require rebuilding the Next.js bundle.
|
||||
|
||||
See [`apps/docs/docs/advanced/development/getting-started.mdx`](../docs/docs/advanced/development/getting-started.mdx)
|
||||
for the full contributor setup.
|
||||
@@ -9,6 +9,7 @@ import "~/styles/prismjs.scss";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||
@@ -22,6 +23,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
@@ -48,7 +51,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between">
|
||||
<WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />
|
||||
{isAdmin ? <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} /> : <span />}
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { CustomWidgetConfigurationEntry } from "./configuration-entry";
|
||||
|
||||
@@ -8,6 +11,8 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function CustomWidgetConfigurationPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const { token } = await params;
|
||||
return <CustomWidgetConfigurationEntry token={token} />;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { SettingsProvider } from "@homarr/settings";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
@@ -54,7 +54,7 @@ export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
url: "https://homarr.dev",
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: "Homarr",
|
||||
},
|
||||
icons: {
|
||||
@@ -89,6 +89,11 @@ export default async function Layout(props: {
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => {
|
||||
@@ -141,7 +146,8 @@ export default async function Layout(props: {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<meta name="homarr-workshop-api-url" content={env.WORKSHOP_API_URL ?? WORKSHOP_API_URL} />
|
||||
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
|
||||
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
{t("discussionLink")}
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("Copy AI prompt", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
|
||||
expect(prompt.length).toBeLessThanOrEqual(12_000);
|
||||
expect(prompt).toContain("exactly one complete `json` fenced block");
|
||||
expect(prompt).toContain("one complete `jsx` fenced block");
|
||||
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
|
||||
});
|
||||
|
||||
it("keeps AI actions and section navigation readable on narrow screens", () => {
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconClipboard,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import {
|
||||
formatCustomWidgetImportIssues,
|
||||
looksLikeCustomWidgetClipboard,
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||
|
||||
@@ -28,6 +32,7 @@ interface WidgetRef {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
valid: boolean;
|
||||
migrationRequired: boolean;
|
||||
}
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
@@ -37,6 +42,70 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
|
||||
const copyMigrationPrompt = async () => {
|
||||
try {
|
||||
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
||||
await navigator.clipboard.writeText(result.prompt);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyMigrationPrompt"),
|
||||
message: t("notification.migrationPromptCopied"),
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.copyMigrationPrompt"),
|
||||
message: error instanceof Error ? error.message : t("notification.migrationPromptCopyError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const queueMigratedWidget = (text: string) => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
};
|
||||
|
||||
const pasteMigratedWidget = async () => {
|
||||
try {
|
||||
queueMigratedWidget(await navigator.clipboard.readText());
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (loadEvent) => {
|
||||
try {
|
||||
queueMigratedWidget(loadEvent.target?.result as string);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
@@ -77,23 +146,22 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.delete"),
|
||||
children: t("action.deleteConfirm", { name: widget.name }),
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
const callbacks = {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
);
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
};
|
||||
deleteMutation.mutate({ id: widget.id }, callbacks);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -125,104 +193,57 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void pasteMigratedWidget()} leftSection={<IconClipboard {...iconProps} />}>
|
||||
{t("action.pasteMigration")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => migrationFileInputRef.current?.click()}
|
||||
leftSection={<IconUpload {...iconProps} />}
|
||||
>
|
||||
{t("action.importMigration")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
{!widget.migrationRequired && (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
const queueImport = useCallback(
|
||||
(value: HomarrCustomWidgetV2) => {
|
||||
setPendingImport(value);
|
||||
openReview();
|
||||
},
|
||||
[openReview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!looksLikeCustomWidgetClipboard(text)) return;
|
||||
event.preventDefault();
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
queueImport(result.widget);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [queueImport, t]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (e) => {
|
||||
try {
|
||||
const result = parseCustomWidgetClipboardDetailed(e.target?.result as string);
|
||||
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
|
||||
queueImport(result.widget);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
message: error instanceof Error ? error.message : t("notification.importError"),
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingImport}
|
||||
onClose={() => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
ref={migrationFileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleMigrationFile}
|
||||
aria-label={t("action.importMigration")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={migrationOpened}
|
||||
widget={migratedWidget}
|
||||
legacyId={widget.id}
|
||||
onClose={() => {
|
||||
migrationControls.close();
|
||||
setMigratedWidget(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,9 @@ export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
applyCustomWidgetAiResponse,
|
||||
filterSecretsForSourceAuthentication,
|
||||
getChangedSecrets,
|
||||
getCustomWidgetPreviewOptionIssues,
|
||||
loadPreviewQueries,
|
||||
@@ -22,6 +24,40 @@ const definition = customWidgetDefinitionSchema.parse({
|
||||
});
|
||||
|
||||
describe("Custom Widget workbench preview options", () => {
|
||||
it("loads one complete AI response into every editor field, including JSX", () => {
|
||||
const setValues = vi.fn();
|
||||
const result = applyCustomWidgetAiResponse(
|
||||
{
|
||||
values: {
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
options: "{}",
|
||||
template: "",
|
||||
secrets: [],
|
||||
},
|
||||
setValues,
|
||||
} as never,
|
||||
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
|
||||
);
|
||||
|
||||
if (!result.success) throw new Error("Expected the complete AI response to be accepted");
|
||||
expect(setValues).toHaveBeenCalledOnce();
|
||||
const values = setValues.mock.calls[0]?.[0];
|
||||
expect(values).toMatchObject({
|
||||
name: "Preview options",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
template: "<Text>Preview</Text>",
|
||||
secrets: [],
|
||||
});
|
||||
expect(JSON.parse(values?.sources as string)).toEqual(result.widget.sources);
|
||||
expect(JSON.parse(values?.requests as string)).toEqual(result.widget.requests);
|
||||
expect(JSON.parse(values?.options as string)).toEqual(result.widget.options);
|
||||
});
|
||||
|
||||
it("shows the default source first regardless of manifest key order", () => {
|
||||
expect(
|
||||
parseSources(
|
||||
@@ -62,4 +98,18 @@ describe("Custom Widget workbench preview options", () => {
|
||||
}),
|
||||
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
|
||||
});
|
||||
|
||||
it("drops credentials that do not apply after an authentication transition", () => {
|
||||
expect(
|
||||
filterSecretsForSourceAuthentication(
|
||||
[
|
||||
{ sourceId: "default", kind: "username", value: "admin", hasValue: true },
|
||||
{ sourceId: "default", kind: "password", value: "secret", hasValue: true },
|
||||
{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true },
|
||||
],
|
||||
"default",
|
||||
"bearer",
|
||||
),
|
||||
).toEqual([{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { fetchApi } from "@homarr/api/client";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetRequiredSecretKinds,
|
||||
parseCustomWidgetAiResponse,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
@@ -33,6 +35,15 @@ export function getChangedSecrets(values: CustomWidgetFormValues) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterSecretsForSourceAuthentication(
|
||||
secrets: CustomWidgetFormValues["secrets"],
|
||||
sourceId: string,
|
||||
authType: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0],
|
||||
) {
|
||||
const requiredKinds = new Set<string>(getCustomWidgetRequiredSecretKinds(authType));
|
||||
return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind));
|
||||
}
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
@@ -46,6 +57,12 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCustomWidgetAiResponse(form: CustomWidgetWorkbenchForm, response: string) {
|
||||
const result = parseCustomWidgetAiResponse(response);
|
||||
if (result.success) applyDefinition(form, result.widget);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSources(value: string): Array<CustomWidgetSource & { id: string }> {
|
||||
const parsed = parseJson(value);
|
||||
return isRecord(parsed)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
@@ -39,6 +39,7 @@ import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handle
|
||||
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
|
||||
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface CustomWidgetFormProps {
|
||||
@@ -71,8 +72,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
const initialDefinition = buildDefinition(formInitialValues);
|
||||
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
|
||||
});
|
||||
const dirtyRef = useRef(false);
|
||||
dirtyRef.current = form.isDirty();
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
|
||||
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
|
||||
const parsedOptions = useMemo(
|
||||
@@ -117,13 +117,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
optionsSnapshot,
|
||||
setOptionsSnapshot,
|
||||
});
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (dirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", beforeUnload);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
|
||||
}, [parsedOptions]);
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
|
||||
|
||||
describe("legacy custom widget migration UI", () => {
|
||||
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
|
||||
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
|
||||
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
|
||||
|
||||
expect(list).toContain("widget.migrationRequired");
|
||||
expect(list).toContain('t("page.list.migrationInstructions")');
|
||||
expect(actions).toContain("legacyMigrationPrompt.fetch");
|
||||
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
|
||||
expect(actions).toContain("navigator.clipboard.readText()");
|
||||
expect(actions).toContain('t("action.pasteMigration")');
|
||||
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
|
||||
expect(actions).toContain("legacyId={widget.id}");
|
||||
});
|
||||
|
||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
||||
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||
|
||||
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi, IconPencil } from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -70,7 +70,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={!widget.valid ? "1px solid var(--mantine-color-red-6)" : undefined}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
@@ -89,18 +95,34 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
{!widget.valid && (
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.valid ? (
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
@@ -166,7 +188,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
</>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{ id: widget.id, name: widget.name, enabled: widget.enabled, valid: widget.valid }}
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
|
||||
|
||||
const requests = JSON.stringify({
|
||||
status: { source: "default", path: "/status" },
|
||||
restart: { source: "secondary", path: "/restart", kind: "action" },
|
||||
logs: { source: "secondary", path: "/logs" },
|
||||
});
|
||||
|
||||
describe("custom widget source dependencies", () => {
|
||||
it("lists every request that would be removed with a source", () => {
|
||||
expect(getDependentRequestIds(requests, "secondary")).toEqual(["restart", "logs"]);
|
||||
});
|
||||
|
||||
it("removes only requests that use the selected source", () => {
|
||||
expect(removeDependentRequests(requests, "secondary")).toEqual({
|
||||
status: { source: "default", path: "/status" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
export function getDependentRequestIds(requestsJson: string, sourceId: string) {
|
||||
const requests = parseRequestRecord(requestsJson);
|
||||
return Object.entries(requests).flatMap(([requestId, request]) =>
|
||||
isRecord(request) && request.source === sourceId ? [requestId] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function removeDependentRequests(requestsJson: string, sourceId: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parseRequestRecord(requestsJson)).filter(
|
||||
([, request]) => !isRecord(request) || request.source !== sourceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseRequestRecord(requestsJson: string): Record<string, unknown> {
|
||||
try {
|
||||
const requests = JSON.parse(requestsJson) as unknown;
|
||||
return isRecord(requests) ? requests : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from "@mantine/core";
|
||||
import { IconKey, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
type SecretKind = "apiKey" | "username" | "password";
|
||||
|
||||
const secretFields: Record<string, Array<{ kind: SecretKind }>> = {
|
||||
bearer: [{ kind: "apiKey" }],
|
||||
basic: [{ kind: "username" }, { kind: "password" }],
|
||||
apiKeyHeader: [{ kind: "apiKey" }],
|
||||
apiKeyQuery: [{ kind: "apiKey" }],
|
||||
};
|
||||
|
||||
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
|
||||
invalid: "baseUrlError.invalid",
|
||||
protocol: "baseUrlError.protocol",
|
||||
credentials: "baseUrlError.credentials",
|
||||
queryOrFragment: "baseUrlError.queryOrFragment",
|
||||
};
|
||||
|
||||
interface CustomWidgetSourceFieldProps {
|
||||
source: CustomWidgetSource & { id: string };
|
||||
index: number;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
clearSecretPending: boolean;
|
||||
onUpdate(index: number, changes: Partial<CustomWidgetSource> & { id?: string }): void;
|
||||
onSetAuthentication(index: number, type: string): void;
|
||||
onSetSecret(sourceId: string, kind: string, value: string): void;
|
||||
onClearSecret(sourceId: string, kind: SecretKind): Promise<void>;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetSourceField({
|
||||
source,
|
||||
index,
|
||||
form,
|
||||
definitionId,
|
||||
clearSecretPending,
|
||||
onUpdate,
|
||||
onSetAuthentication,
|
||||
onSetSecret,
|
||||
onClearSecret,
|
||||
onRemove,
|
||||
}: CustomWidgetSourceFieldProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
|
||||
return (
|
||||
<Fieldset legend={index === 0 ? t("primary") : source.name}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("id")}
|
||||
value={source.id}
|
||||
disabled={source.id === "default" || Boolean(definitionId)}
|
||||
error={form.errors.sources}
|
||||
onCommit={(value) => onUpdate(index, { id: value })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("name")}
|
||||
value={source.name}
|
||||
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={source.baseUrl}
|
||||
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
|
||||
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={source.networkScope}
|
||||
onChange={(value) =>
|
||||
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("authentication")}
|
||||
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
|
||||
value={authType}
|
||||
onChange={(value) => onSetAuthentication(index, value ?? "none")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
|
||||
/>
|
||||
)}
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
|
||||
<TextInput
|
||||
label={t("queryParameter")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
|
||||
/>
|
||||
)}
|
||||
{(secretFields[authType] ?? []).map(({ kind }) => {
|
||||
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
|
||||
const Input = kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Group key={kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
|
||||
/>
|
||||
{definitionId && secret?.hasValue && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretPending}
|
||||
onClick={() => void onClearSecret(source.id, kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
+58
-137
@@ -1,31 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
const secretFields: Record<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
|
||||
bearer: [{ kind: "apiKey" }],
|
||||
basic: [{ kind: "username" }, { kind: "password" }],
|
||||
apiKeyHeader: [{ kind: "apiKey" }],
|
||||
apiKeyQuery: [{ kind: "apiKey" }],
|
||||
};
|
||||
|
||||
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
|
||||
invalid: "baseUrlError.invalid",
|
||||
protocol: "baseUrlError.protocol",
|
||||
credentials: "baseUrlError.credentials",
|
||||
queryOrFragment: "baseUrlError.queryOrFragment",
|
||||
};
|
||||
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
|
||||
import { CustomWidgetSourceField } from "./_custom-widget-source-field";
|
||||
|
||||
export function CustomWidgetSourcesEditor({
|
||||
form,
|
||||
@@ -35,6 +22,8 @@ export function CustomWidgetSourcesEditor({
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
||||
const sources = parseSources(form.values.sources);
|
||||
@@ -83,6 +72,14 @@ export function CustomWidgetSourcesEditor({
|
||||
const auth =
|
||||
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
|
||||
update(index, { auth: auth as CustomWidgetSource["auth"] });
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
filterSecretsForSourceAuthentication(
|
||||
form.values.secrets,
|
||||
sources[index]?.id ?? "",
|
||||
type as Parameters<typeof filterSecretsForSourceAuthentication>[2],
|
||||
),
|
||||
);
|
||||
};
|
||||
const setSecret = (sourceId: string, kind: string, value: string) => {
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
@@ -111,7 +108,7 @@ export function CustomWidgetSourcesEditor({
|
||||
),
|
||||
);
|
||||
};
|
||||
const removeSource = (index: number) => {
|
||||
const removeSourceAndDependents = (index: number) => {
|
||||
const removedId = sources[index]?.id;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
@@ -122,25 +119,38 @@ export function CustomWidgetSourcesEditor({
|
||||
),
|
||||
);
|
||||
if (!removedId) return;
|
||||
form.setFieldValue(
|
||||
"requests",
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
isRecord(parseJson(form.values.requests))
|
||||
? (parseJson(form.values.requests) as Record<string, unknown>)
|
||||
: {},
|
||||
).filter(([, request]) => !isRecord(request) || request.source !== removedId),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
form.setFieldValue("requests", JSON.stringify(removeDependentRequests(form.values.requests, removedId), null, 2));
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
|
||||
);
|
||||
};
|
||||
const removeSource = (index: number) => {
|
||||
const removedId = sources[index]?.id;
|
||||
if (!removedId) return;
|
||||
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
|
||||
if (dependentRequestIds.length === 0) {
|
||||
removeSourceAndDependents(index);
|
||||
return;
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("remove"),
|
||||
children: (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{w("requests.title")} ({dependentRequestIds.length})
|
||||
</Text>
|
||||
<List size="sm">
|
||||
{dependentRequestIds.map((requestId) => (
|
||||
<List.Item key={requestId}>{requestId}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
),
|
||||
confirmProps: { children: t("remove") },
|
||||
onConfirm: () => removeSourceAndDependents(index),
|
||||
});
|
||||
};
|
||||
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
|
||||
if (!definitionId) return;
|
||||
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
|
||||
@@ -153,109 +163,20 @@ export function CustomWidgetSourcesEditor({
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{sources.map((source, index) => {
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
return (
|
||||
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("id")}
|
||||
value={source.id}
|
||||
disabled={source.id === "default" || Boolean(definitionId)}
|
||||
error={form.errors.sources}
|
||||
onCommit={(value) => update(index, { id: value })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("name")}
|
||||
value={source.name}
|
||||
onChange={(event) => update(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={source.baseUrl}
|
||||
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
|
||||
onChange={(event) => update(index, { baseUrl: event.currentTarget.value })}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={source.networkScope}
|
||||
onChange={(value) =>
|
||||
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("authentication")}
|
||||
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
|
||||
value={typeof source.auth === "string" ? source.auth : source.auth.type}
|
||||
onChange={(value) => setAuth(index, value ?? "none")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) =>
|
||||
update(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
|
||||
<TextInput
|
||||
label={t("queryParameter")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) =>
|
||||
update(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(secretFields[typeof source.auth === "string" ? source.auth : source.auth.type] ?? []).map((field) => {
|
||||
const secret = form.values.secrets.find(
|
||||
(entry) => entry.sourceId === source.id && entry.kind === field.kind,
|
||||
);
|
||||
const Input = field.kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Group key={field.kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${field.kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
|
||||
/>
|
||||
{definitionId && secret?.hasValue && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretMutation.isPending}
|
||||
onClick={() => void clearSecret(source.id, field.kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => removeSource(index)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<CustomWidgetSourceField
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
form={form}
|
||||
definitionId={definitionId}
|
||||
clearSecretPending={clearSecretMutation.isPending}
|
||||
onUpdate={update}
|
||||
onSetAuthentication={setAuth}
|
||||
onSetSecret={setSecret}
|
||||
onClearSecret={clearSecret}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Accordion variant="contained">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
formatCustomWidgetImportIssues,
|
||||
looksLikeCustomWidgetClipboard,
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
const queueImport = useCallback(
|
||||
(value: HomarrCustomWidgetV2) => {
|
||||
setPendingImport(value);
|
||||
openReview();
|
||||
},
|
||||
[openReview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!looksLikeCustomWidgetClipboard(text)) return;
|
||||
event.preventDefault();
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
queueImport(result.widget);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [queueImport, t]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (loadEvent) => {
|
||||
try {
|
||||
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
|
||||
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
|
||||
queueImport(result.widget);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.import"),
|
||||
message: error instanceof Error ? error.message : t("notification.importError"),
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingImport}
|
||||
onClose={() => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup());
|
||||
document.body.replaceChildren();
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
});
|
||||
|
||||
describe("custom widget unsaved changes guard", () => {
|
||||
it("blocks client-side link navigation and requests confirmation for a dirty form", () => {
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
const confirmNavigation = vi.fn();
|
||||
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
|
||||
const link = document.createElement("a");
|
||||
link.href = "/en/manage/custom-widgets";
|
||||
link.textContent = "Custom widgets";
|
||||
document.body.append(link);
|
||||
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
|
||||
link.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
|
||||
});
|
||||
|
||||
it("allows section links and navigation after the form is clean", () => {
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
let dirty = true;
|
||||
const confirmNavigation = vi.fn();
|
||||
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => dirty, confirmNavigation }));
|
||||
const sectionLink = document.createElement("a");
|
||||
sectionLink.href = "#sources";
|
||||
document.body.append(sectionLink);
|
||||
|
||||
expect(sectionLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(
|
||||
true,
|
||||
);
|
||||
dirty = false;
|
||||
const listLink = document.createElement("a");
|
||||
listLink.href = "/en/manage/custom-widgets";
|
||||
document.body.append(listLink);
|
||||
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
|
||||
expect(confirmNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
interface UnsavedChangesGuardOptions {
|
||||
isDirty(): boolean;
|
||||
confirmNavigation(href: string): void;
|
||||
}
|
||||
|
||||
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
|
||||
const eventTarget = event.target instanceof Element ? event.target : null;
|
||||
const anchor = eventTarget?.closest<HTMLAnchorElement>("a[href]");
|
||||
if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return;
|
||||
|
||||
const destination = new URL(anchor.href, window.location.href);
|
||||
const current = new URL(window.location.href);
|
||||
if (destination.origin !== current.origin) return;
|
||||
if (
|
||||
destination.pathname === current.pathname &&
|
||||
destination.search === current.search &&
|
||||
destination.hash !== current.hash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`);
|
||||
};
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!isDirty()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}
|
||||
+3
-8
@@ -6,17 +6,13 @@ import { useRouter } from "next/navigation";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
formatCustomWidgetImportIssues,
|
||||
parseCustomWidgetAiResponse,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import {
|
||||
applyDefinition,
|
||||
applyCustomWidgetAiResponse,
|
||||
buildDefinition,
|
||||
getChangedSecrets,
|
||||
getDefinitionDefaults,
|
||||
@@ -167,11 +163,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
|
||||
const pasteAiResponse = async () => {
|
||||
try {
|
||||
const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText());
|
||||
const result = applyCustomWidgetAiResponse(input.form, await navigator.clipboard.readText());
|
||||
if (!result.success) {
|
||||
throw new Error(formatCustomWidgetImportIssues(result.issues));
|
||||
}
|
||||
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
|
||||
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
showSuccessNotification({
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const dirtyRef = useRef(isDirty);
|
||||
const confirmNavigationRef = useRef<(href: string) => void>(() => undefined);
|
||||
dirtyRef.current = isDirty;
|
||||
confirmNavigationRef.current = (href) => {
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
confirmProps: { children: t("common.action.discard") },
|
||||
onConfirm: () => router.push(href),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
registerUnsavedChangesGuard({
|
||||
isDirty: () => dirtyRef.current,
|
||||
confirmNavigation: (href) => confirmNavigationRef.current(href),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,9 @@ interface EditCustomWidgetPageProps {
|
||||
|
||||
export default async function EditCustomWidgetPage(props: EditCustomWidgetPageProps) {
|
||||
const session = await auth();
|
||||
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
const params = await props.params;
|
||||
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
|
||||
@@ -11,10 +11,9 @@ import { FormErrorBoundary } from "../_form-error-boundary";
|
||||
|
||||
export default async function NewCustomWidgetPage() {
|
||||
const session = await auth();
|
||||
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,16 +11,15 @@ import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
|
||||
export default async function CustomWidgetsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect(session ? "/" : "/auth/login");
|
||||
}
|
||||
|
||||
const definitions = await api.customWidget.list();
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("custom-widget-manage")) redirect("/");
|
||||
if (!session?.user.permissions.includes("admin")) redirect("/");
|
||||
const { id } = await params;
|
||||
const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null);
|
||||
if (!preview) notFound();
|
||||
|
||||
@@ -79,13 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconApi,
|
||||
href: "/manage/custom-widgets",
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session?.user.permissions.includes("custom-widget-manage"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconBuildingStore,
|
||||
href: "/manage/workshop",
|
||||
label: t("items.workshop"),
|
||||
hidden: !session?.user.permissions.includes("custom-widget-manage"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function WorkshopPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("custom-widget-manage")) redirect(session ? "/" : "/auth/login");
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const t = await getScopedI18n("workshop");
|
||||
const { workshopWebUrl } = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
return (
|
||||
<ManagePageLayout title={t("title")} size="xl">
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
size="xl"
|
||||
primaryAction={
|
||||
<Button
|
||||
component="a"
|
||||
href={workshopWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<WorkshopInstaller />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { getCustomWidgetComponentCatalog, getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { adminRoute } from "../admin";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ resource: string }>;
|
||||
}
|
||||
|
||||
const resources = new Map<string, () => unknown>([
|
||||
["schema", getCustomWidgetJsonSchema],
|
||||
["components", getCustomWidgetComponentCatalog],
|
||||
["skill", getCustomWidgetSkill],
|
||||
[
|
||||
"authoring-prompt",
|
||||
() => ({
|
||||
version: 2,
|
||||
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
resources: [
|
||||
"homarr://custom-widgets/schema",
|
||||
"homarr://custom-widgets/components",
|
||||
"homarr://custom-widgets/skill",
|
||||
],
|
||||
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const getAuthoringResource = async (_request: NextRequest, context: RouteContext): Promise<Response> => {
|
||||
const { resource } = await context.params;
|
||||
const loadResource = resources.get(resource);
|
||||
if (!loadResource) {
|
||||
return NextResponse.json({ error: "Custom Widget authoring resource not found." }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(loadResource());
|
||||
};
|
||||
|
||||
export const GET = adminRoute(getAuthoringResource);
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/next", () => ({ auth: mocks.auth }));
|
||||
|
||||
import { adminRoute } from "./admin";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } });
|
||||
});
|
||||
|
||||
describe("Custom Widget HTTP resource access", () => {
|
||||
test("allows authenticated administrators", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const response = await adminRoute(handler)();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["anonymous users", null],
|
||||
["authenticated non-admins", { user: { permissions: ["board-modify-all"] } }],
|
||||
])("denies %s", async (_label, session) => {
|
||||
mocks.auth.mockResolvedValue(session);
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const response = await adminRoute(handler)();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
type RouteHandler<Arguments extends unknown[]> = (...args: Arguments) => Response | Promise<Response>;
|
||||
|
||||
export const adminRoute =
|
||||
<Arguments extends unknown[]>(handler: RouteHandler<Arguments>) =>
|
||||
async (...args: Arguments): Promise<Response> => {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
return NextResponse.json({ error: "Administrator access is required." }, { status: 403 });
|
||||
}
|
||||
return handler(...args);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({
|
||||
version: 2,
|
||||
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
resources: [
|
||||
"homarr://custom-widgets/schema",
|
||||
"homarr://custom-widgets/components",
|
||||
"homarr://custom-widgets/skill",
|
||||
],
|
||||
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
|
||||
});
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetComponentCatalog());
|
||||
}
|
||||
@@ -4,23 +4,23 @@ import { NextResponse } from "next/server";
|
||||
import {
|
||||
claimCustomWidgetConfigurationRequest,
|
||||
completeCustomWidgetConfigurationRequest,
|
||||
configureCustomWidgetSourceFromRequest,
|
||||
configurePreviewSessionSource,
|
||||
getCustomWidgetConfigurationRequest,
|
||||
parseStoredCustomWidgetDefinition,
|
||||
releaseCustomWidgetConfigurationRequest,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "@homarr/api/custom-widget-configuration";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
|
||||
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
|
||||
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
|
||||
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { adminRoute } from "../../admin";
|
||||
import { readConfigurationRequestBody } from "../body";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
const getConfigurationRequest = async (_request: NextRequest, context: RouteContext): Promise<Response> => {
|
||||
const { token } = await context.params;
|
||||
const request = await getCustomWidgetConfigurationRequest(token);
|
||||
if (!request)
|
||||
@@ -36,23 +36,26 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
const completeConfigurationRequest = async (request: NextRequest, context: RouteContext): Promise<Response> => {
|
||||
const { token } = await context.params;
|
||||
const pending = await getCustomWidgetConfigurationRequest(token);
|
||||
if (!pending || pending.status !== "pending") {
|
||||
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
|
||||
}
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
baseUrl?: unknown;
|
||||
networkScope?: unknown;
|
||||
secrets?: Record<string, unknown>;
|
||||
} | null;
|
||||
const parsedBody = await readConfigurationRequestBody(request);
|
||||
if (parsedBody.status === "too-large") {
|
||||
return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 });
|
||||
}
|
||||
if (parsedBody.status !== "ok") {
|
||||
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
|
||||
}
|
||||
const body = parsedBody.data;
|
||||
const sourceResult = customWidgetSourceSchema.safeParse({
|
||||
...pending.source,
|
||||
baseUrl: body?.baseUrl,
|
||||
networkScope: body?.networkScope,
|
||||
baseUrl: body.baseUrl,
|
||||
networkScope: body.networkScope,
|
||||
});
|
||||
if (!sourceResult.success) {
|
||||
return NextResponse.json(
|
||||
@@ -61,7 +64,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
);
|
||||
}
|
||||
const secrets = pending.kinds.flatMap((kind) => {
|
||||
const value = body?.secrets?.[kind];
|
||||
const value = body.secrets[kind];
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 8192
|
||||
? [{ sourceId: pending.sourceId, kind, value }]
|
||||
: [];
|
||||
@@ -89,19 +92,24 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
);
|
||||
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
|
||||
} else {
|
||||
const definition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, claimed.target.id),
|
||||
const result = await configureCustomWidgetSourceFromRequest(db, {
|
||||
definitionId: claimed.target.id,
|
||||
sourceId: claimed.sourceId,
|
||||
baseUrl: sourceResult.data.baseUrl,
|
||||
networkScope: sourceResult.data.networkScope,
|
||||
secrets,
|
||||
expectedSource: claimed.source,
|
||||
});
|
||||
if (!definition) return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
|
||||
const parsed = parseStoredCustomWidgetDefinition(definition);
|
||||
const currentSource = parsed.sources[claimed.sourceId];
|
||||
if (!currentSource) {
|
||||
if (result.status === "definition-not-found") {
|
||||
return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
|
||||
}
|
||||
if (result.status === "source-not-found") {
|
||||
return NextResponse.json(
|
||||
{ error: "This API source changed after the setup link was created. Request a new setup link." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!hasSameCustomWidgetSourceAuthentication(currentSource, claimed.source)) {
|
||||
if (result.status === "binding-changed") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "This API source authentication changed after the setup link was created. Request a new setup link.",
|
||||
@@ -109,69 +117,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const configuredSource = customWidgetSourceSchema.parse({
|
||||
...currentSource,
|
||||
baseUrl: sourceResult.data.baseUrl,
|
||||
networkScope: sourceResult.data.networkScope,
|
||||
});
|
||||
const definitionChanges = {
|
||||
...serializeCustomWidgetDefinition({
|
||||
...parsed,
|
||||
sources: { ...parsed.sources, [claimed.sourceId]: configuredSource },
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const secretRows = secrets.map((secret) => ({
|
||||
definitionId: claimed.target.id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, claimed.target.id));
|
||||
for (const secret of secretRows) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, claimed.target.id),
|
||||
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, claimed.target.id))
|
||||
.run();
|
||||
for (const secret of secretRows) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, claimed.target.id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await completeCustomWidgetConfigurationRequest(token);
|
||||
@@ -179,4 +124,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
} finally {
|
||||
await releaseCustomWidgetConfigurationRequest(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = adminRoute(getConfigurationRequest);
|
||||
export const POST = adminRoute(completeConfigurationRequest);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body";
|
||||
|
||||
describe("custom widget configuration request body", () => {
|
||||
it("rejects a streaming payload once it exceeds the byte budget", async () => {
|
||||
const request = new Request("http://localhost/configure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) },
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" });
|
||||
});
|
||||
|
||||
it("strictly parses a bounded configuration object", async () => {
|
||||
const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } };
|
||||
await expect(
|
||||
readConfigurationRequestBody(
|
||||
new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }),
|
||||
),
|
||||
).resolves.toEqual({ status: "ok", data: body });
|
||||
|
||||
await expect(
|
||||
readConfigurationRequestBody(
|
||||
new Request("http://localhost/configure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, unexpected: true }),
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ status: "invalid" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
|
||||
const configurationRequestBodySchema = z.strictObject({
|
||||
baseUrl: z.string(),
|
||||
networkScope: z.enum(["public", "private", "loopback"]),
|
||||
secrets: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export async function readConfigurationRequestBody(
|
||||
request: Request,
|
||||
): Promise<
|
||||
{ status: "ok"; data: z.infer<typeof configurationRequestBodySchema> } | { status: "invalid" | "too-large" }
|
||||
> {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
|
||||
return { status: "too-large" };
|
||||
}
|
||||
if (!request.body) return { status: "invalid" };
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let byteLength = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
byteLength += value.byteLength;
|
||||
if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
return { status: "too-large" };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
||||
const result = configurationRequestBodySchema.safeParse(candidate);
|
||||
return result.success ? { status: "ok", data: result.data } : { status: "invalid" };
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetJsonSchema());
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetSkill());
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
ipAddress: vi.fn(() => "127.0.0.1"),
|
||||
toolProcedure: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
buildPrompt: vi.fn((request?: string, documentationUrl?: string) =>
|
||||
[`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"),
|
||||
),
|
||||
getComponent: vi.fn((name: string) => (name === "Text Input" ? { name, safety: "allowed" } : null)),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
||||
vi.mock("@homarr/api/mcp", () => ({
|
||||
createTRPCContext: vi.fn(() => ({})),
|
||||
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
||||
}));
|
||||
vi.mock("@homarr/auth/api-key", () => ({
|
||||
API_KEY_HEADER_NAME: "ApiKey",
|
||||
getSessionFromApiKeyAsync: mocks.authenticate,
|
||||
}));
|
||||
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
|
||||
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: mocks.ipAddress }));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt }));
|
||||
vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
|
||||
getCustomWidgetComponent: mocks.getComponent,
|
||||
getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }),
|
||||
getCustomWidgetSkillContent: () => "# Custom Widget Skill",
|
||||
}));
|
||||
vi.mock("@homarr/custom-widgets/core", () => ({
|
||||
customJsxExamples: [{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" }],
|
||||
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
|
||||
}));
|
||||
vi.mock("@homarr/db", () => ({ db: {} }));
|
||||
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
|
||||
vi.mock("../_extract-tools", () => ({
|
||||
extractMcpTools: () => [
|
||||
{
|
||||
name: "board_getAllBoards",
|
||||
description: "List boards",
|
||||
pathInRouter: ["board", "getAllBoards"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_list",
|
||||
description: "List Custom Widgets",
|
||||
pathInRouter: ["customWidget", "list"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_workshopSearch",
|
||||
description: "Search Workshop",
|
||||
pathInRouter: ["customWidget", "workshopSearch"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: number | null;
|
||||
result?: Record<string, unknown>;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.authenticate.mockResolvedValue({ user: { id: "user-1", permissions: ["admin"] } });
|
||||
mocks.buildPrompt.mockClear();
|
||||
mocks.getComponent.mockClear();
|
||||
mocks.ipAddress.mockClear();
|
||||
mocks.ipAddress.mockReturnValue("127.0.0.1");
|
||||
mocks.toolProcedure.mockReset();
|
||||
mocks.toolProcedure.mockResolvedValue([]);
|
||||
mocks.loggerWarn.mockClear();
|
||||
});
|
||||
|
||||
async function callMcp(method: string, params: Record<string, unknown> = {}, id = 1) {
|
||||
const request = new Request("http://localhost/api/mcp/mcp", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/event-stream",
|
||||
ApiKey: "key.secret",
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": "198.51.100.10",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
||||
});
|
||||
const response = await POST(request as never);
|
||||
const text = await response.text();
|
||||
const data = response.headers.get("content-type")?.includes("text/event-stream")
|
||||
? text
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice("data: ".length)
|
||||
: text;
|
||||
if (!data) throw new Error(`MCP response did not contain JSON-RPC data: ${text}`);
|
||||
return { response, body: JSON.parse(data) as JsonRpcResponse };
|
||||
}
|
||||
|
||||
describe("authenticated MCP prompt protocol", () => {
|
||||
test("initializes and advertises prompt and resource capabilities", async () => {
|
||||
const { response, body } = await callMcp("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "route-test", version: "1.0.0" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.error).toBeUndefined();
|
||||
expect(body.result).toMatchObject({
|
||||
protocolVersion: expect.any(String),
|
||||
capabilities: { prompts: {}, resources: {} },
|
||||
serverInfo: { name: "homarr", version: "test-version" },
|
||||
});
|
||||
expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test");
|
||||
expect(mocks.ipAddress).toHaveBeenCalledWith(expect.any(Headers));
|
||||
});
|
||||
|
||||
test("lists and interpolates the custom-widget authoring prompt", async () => {
|
||||
const listed = await callMcp("prompts/list");
|
||||
expect(listed.body.result).toEqual({
|
||||
prompts: [
|
||||
{
|
||||
name: "homarr-custom-widget-author",
|
||||
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
|
||||
arguments: [
|
||||
{ name: "request", description: "The widget the user wants.", required: false },
|
||||
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rendered = await callMcp("prompts/get", {
|
||||
name: "homarr-custom-widget-author",
|
||||
arguments: { request: "Build a status card", documentationUrl: "https://docs.example.test/api" },
|
||||
});
|
||||
expect(rendered.body.result).toMatchObject({
|
||||
description: "Current Homarr Custom Widget authoring workflow.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Request: Build a status card\nDocumentation: https://docs.example.test/api",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.buildPrompt).toHaveBeenCalledWith("Build a status card", "https://docs.example.test/api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticated MCP resource protocol", () => {
|
||||
test("lists concrete resources and URI templates with reviewed MIME types", async () => {
|
||||
const listed = await callMcp("resources/list");
|
||||
expect(listed.body.result).toEqual({
|
||||
resources: [
|
||||
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
|
||||
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
|
||||
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
|
||||
{
|
||||
uri: "homarr://custom-widgets/examples/status-card",
|
||||
name: "Custom Widget example: Status card",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const templates = await callMcp("resources/templates/list");
|
||||
expect(templates.body.result).toEqual({
|
||||
resourceTemplates: [
|
||||
expect.objectContaining({
|
||||
uriTemplate: "homarr://custom-widgets/components/{name}",
|
||||
mimeType: "application/json",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
uriTemplate: "homarr://custom-widgets/examples/{name}",
|
||||
mimeType: "application/json",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["homarr://custom-widgets/schema", "application/schema+json", { type: "object", title: "Custom Widget" }],
|
||||
["homarr://custom-widgets/components", "application/json", { components: [{ name: "Text Input" }] }],
|
||||
["homarr://custom-widgets/skill", "text/markdown", "# Custom Widget Skill"],
|
||||
[
|
||||
"homarr://custom-widgets/examples/status-card",
|
||||
"application/json",
|
||||
{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" },
|
||||
],
|
||||
["homarr://custom-widgets/components/Text%20Input", "application/json", { name: "Text Input", safety: "allowed" }],
|
||||
])("dispatches %s with its MIME type", async (uri, mimeType, expected) => {
|
||||
const { body } = await callMcp("resources/read", { uri });
|
||||
const contents = body.result?.contents as Array<{ uri: string; mimeType: string; text: string }> | undefined;
|
||||
const content = contents?.[0];
|
||||
expect(content).toMatchObject({ uri, mimeType });
|
||||
expect(mimeType === "text/markdown" ? content?.text : JSON.parse(content?.text ?? "null")).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-admin MCP discovery", () => {
|
||||
test("does not list or expose Custom Widget tools, prompts or resources", async () => {
|
||||
mocks.authenticate.mockResolvedValue({ user: { id: "user-1", permissions: ["board-view-all"] } });
|
||||
|
||||
const tools = await callMcp("tools/list");
|
||||
expect(((tools.body.result?.tools ?? []) as Array<{ name: string }>).map(({ name }) => name)).toEqual([
|
||||
"board_getAllBoards",
|
||||
]);
|
||||
|
||||
const prompts = await callMcp("prompts/list");
|
||||
expect(prompts.body.result).toEqual({ prompts: [] });
|
||||
|
||||
const resources = await callMcp("resources/list");
|
||||
expect(resources.body.result).toEqual({ resources: [] });
|
||||
|
||||
const templates = await callMcp("resources/templates/list");
|
||||
expect(templates.body.result).toEqual({ resourceTemplates: [] });
|
||||
|
||||
const prompt = await callMcp("prompts/get", { name: "homarr-custom-widget-author" });
|
||||
expect(prompt.body.result).toBeUndefined();
|
||||
expect(prompt.body.error).toBeDefined();
|
||||
|
||||
const resource = await callMcp("resources/read", { uri: "homarr://custom-widgets/schema" });
|
||||
expect(resource.body.result).toBeUndefined();
|
||||
expect(resource.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP protocol errors", () => {
|
||||
test.each([
|
||||
["prompts/get", { name: "unknown" }],
|
||||
["resources/read", { uri: "homarr://custom-widgets/unknown" }],
|
||||
])("returns JSON-RPC errors for unknown %s targets", async (method, params) => {
|
||||
const { body } = await callMcp(method, params);
|
||||
expect(body.result).toBeUndefined();
|
||||
expect(body.error).toEqual(expect.objectContaining({ code: expect.any(Number), message: expect.any(String) }));
|
||||
});
|
||||
|
||||
test("returns a protocol error for a malformed resource request", async () => {
|
||||
const { body } = await callMcp("resources/read", {});
|
||||
expect(body.result).toBeUndefined();
|
||||
expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) }));
|
||||
});
|
||||
|
||||
test("does not log sensitive tool error messages", async () => {
|
||||
const failure = Object.assign(new Error("request to https://user:secret@example.test/private failed"), {
|
||||
code: "BAD_GATEWAY",
|
||||
});
|
||||
mocks.toolProcedure.mockRejectedValue(failure);
|
||||
|
||||
const { body } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
|
||||
|
||||
expect(JSON.stringify(body)).not.toContain("user:secret");
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith("MCP tool execution failed", {
|
||||
tool: "board_getAllBoards",
|
||||
errorName: "Error",
|
||||
errorCode: "BAD_GATEWAY",
|
||||
});
|
||||
const metadata = mocks.loggerWarn.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
expect(metadata).not.toHaveProperty("error");
|
||||
expect(metadata).not.toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,12 @@ function clearAuthFailures(ip: string | null) {
|
||||
authFailures.delete(ip ?? "unknown");
|
||||
}
|
||||
|
||||
const callerStorage = new AsyncLocalStorage<ReturnType<typeof mcpRouter.createCaller>>();
|
||||
interface McpCallerContext {
|
||||
caller: ReturnType<typeof mcpRouter.createCaller>;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const callerStorage = new AsyncLocalStorage<McpCallerContext>();
|
||||
|
||||
const emptyObjectSchema = {
|
||||
type: "object" as const,
|
||||
@@ -113,6 +118,20 @@ function getTools() {
|
||||
return toolsCache;
|
||||
}
|
||||
|
||||
function getVisibleTools() {
|
||||
const tools = getTools();
|
||||
if (!isCustomWidgetMcpEnabled()) {
|
||||
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
function requireCustomWidgetMcpAdmin() {
|
||||
if (!isCustomWidgetMcpEnabled()) throw new Error("Resource not found");
|
||||
}
|
||||
|
||||
const isCustomWidgetMcpEnabled = () => Boolean(callerStorage.getStore()?.isAdmin);
|
||||
|
||||
const SAFE_ERROR_MESSAGES: Record<string, string> = {
|
||||
UNAUTHORIZED: "You do not have permission to perform this action",
|
||||
FORBIDDEN: "Access denied",
|
||||
@@ -184,19 +203,22 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
||||
prompts: [
|
||||
{
|
||||
name: "homarr-custom-widget-author",
|
||||
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
|
||||
arguments: [
|
||||
{ name: "request", description: "The widget the user wants.", required: false },
|
||||
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
prompts: isCustomWidgetMcpEnabled()
|
||||
? [
|
||||
{
|
||||
name: "homarr-custom-widget-author",
|
||||
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
|
||||
arguments: [
|
||||
{ name: "request", description: "The widget the user wants.", required: false },
|
||||
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
||||
requireCustomWidgetMcpAdmin();
|
||||
if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found");
|
||||
return {
|
||||
description: "Current Homarr Custom Widget authoring workflow.",
|
||||
@@ -216,41 +238,56 @@ const mcpHandler = createMcpHandler(
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
||||
resources: [
|
||||
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
|
||||
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
|
||||
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
|
||||
...customJsxExamples.map((example) => ({
|
||||
uri: `homarr://custom-widgets/examples/${example.id}`,
|
||||
name: `Custom Widget example: ${example.title}`,
|
||||
mimeType: "application/json",
|
||||
})),
|
||||
],
|
||||
resources: isCustomWidgetMcpEnabled()
|
||||
? [
|
||||
{
|
||||
uri: "homarr://custom-widgets/schema",
|
||||
name: "Custom Widget schema",
|
||||
mimeType: "application/schema+json",
|
||||
},
|
||||
{
|
||||
uri: "homarr://custom-widgets/components",
|
||||
name: "Custom Widget components",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
|
||||
...customJsxExamples.map((example) => ({
|
||||
uri: `homarr://custom-widgets/examples/${example.id}`,
|
||||
name: `Custom Widget example: ${example.title}`,
|
||||
mimeType: "application/json",
|
||||
})),
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
|
||||
resourceTemplates: [
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/components/{name}",
|
||||
name: "Custom Widget component",
|
||||
description: "Metadata for one installed Custom Widget component.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/examples/{name}",
|
||||
name: "Custom Widget example",
|
||||
description: "One canonical Custom Widget example.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
],
|
||||
resourceTemplates: isCustomWidgetMcpEnabled()
|
||||
? [
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/components/{name}",
|
||||
name: "Custom Widget component",
|
||||
description: "Metadata for one installed Custom Widget component.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/examples/{name}",
|
||||
name: "Custom Widget example",
|
||||
description: "One canonical Custom Widget example.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
|
||||
requireCustomWidgetMcpAdmin();
|
||||
const { uri } = request.params;
|
||||
let text: string;
|
||||
let mimeType = "application/json";
|
||||
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
|
||||
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
|
||||
if (uri === "homarr://custom-widgets/schema") {
|
||||
text = JSON.stringify(getCustomWidgetJsonSchema());
|
||||
mimeType = "application/schema+json";
|
||||
} else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
|
||||
else if (uri === "homarr://custom-widgets/skill") {
|
||||
text = getCustomWidgetSkillContent();
|
||||
mimeType = "text/markdown";
|
||||
@@ -269,12 +306,12 @@ const mcpHandler = createMcpHandler(
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: getTools(),
|
||||
tools: getVisibleTools(),
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tools = getTools();
|
||||
const tools = getVisibleTools();
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
|
||||
if (!tool) {
|
||||
@@ -284,8 +321,8 @@ const mcpHandler = createMcpHandler(
|
||||
};
|
||||
}
|
||||
|
||||
const caller = callerStorage.getStore();
|
||||
if (!caller) {
|
||||
const callerContext = callerStorage.getStore();
|
||||
if (!callerContext) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Authentication context not available" }],
|
||||
isError: true,
|
||||
@@ -293,7 +330,7 @@ const mcpHandler = createMcpHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], caller);
|
||||
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], callerContext.caller);
|
||||
if (typeof procedure !== "function") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }],
|
||||
@@ -309,7 +346,9 @@ const mcpHandler = createMcpHandler(
|
||||
const message = sanitizeErrorMessage(error);
|
||||
logger.warn("MCP tool execution failed", {
|
||||
tool: name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
errorCode:
|
||||
error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
|
||||
@@ -396,7 +435,9 @@ const handler = async (req: NextRequest) => {
|
||||
const ctx = createTRPCContext({ session, headers: req.headers });
|
||||
const caller = mcpRouter.createCaller(ctx);
|
||||
|
||||
return callerStorage.run(caller, () => mcpHandler(req as unknown as Request));
|
||||
return callerStorage.run({ caller, isAdmin: session.user.permissions.includes("admin") }, () =>
|
||||
mcpHandler(req as unknown as Request),
|
||||
);
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST, handler as DELETE };
|
||||
@@ -30,6 +30,8 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
@@ -135,9 +137,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
{canDuplicate && (
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
@@ -18,12 +19,17 @@ import { WorkshopInstallButton } from "~/components/workshop/workshop-install-bu
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
{ enabled: isAdmin },
|
||||
);
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
@@ -160,61 +166,65 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
/>
|
||||
))}
|
||||
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
|
||||
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
|
||||
@@ -49,6 +49,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -76,9 +77,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
|
||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||
const toggleOptions = useMemo(() => {
|
||||
if (!canConfigureWidget) return [];
|
||||
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||
}, [currentDefinition, settings]);
|
||||
}, [canConfigureWidget, currentDefinition, settings]);
|
||||
|
||||
const widgetContextActions = useMemo(() => {
|
||||
const def = currentDefinition as Record<string, unknown>;
|
||||
@@ -227,13 +229,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
{canConfigureWidget && (
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
interface CustomWidgetImportDialogProps {
|
||||
opened: boolean;
|
||||
widget: HomarrCustomWidgetV2 | null;
|
||||
legacyId?: string;
|
||||
stackId?: string;
|
||||
zIndex?: number;
|
||||
onClose(): void;
|
||||
@@ -33,6 +34,7 @@ interface CustomWidgetImportDialogProps {
|
||||
export function CustomWidgetImportDialog({
|
||||
opened,
|
||||
widget,
|
||||
legacyId,
|
||||
stackId,
|
||||
zIndex,
|
||||
onClose,
|
||||
@@ -59,19 +61,27 @@ export function CustomWidgetImportDialog({
|
||||
};
|
||||
}, [values, widget]);
|
||||
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
|
||||
const mutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: (result) => {
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") });
|
||||
},
|
||||
});
|
||||
const onSuccess = (result: { id: string }) => {
|
||||
showSuccessNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: legacyId ? t("notification.migrated") : t("notification.imported"),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
};
|
||||
const onError = (error: { message?: string }) => {
|
||||
showErrorNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
|
||||
});
|
||||
};
|
||||
const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
|
||||
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
|
||||
const pending = importMutation.isPending || migrateMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
|
||||
@@ -84,7 +94,8 @@ export function CustomWidgetImportDialog({
|
||||
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
),
|
||||
);
|
||||
mutation.mutate({ widget: configuredWidget, secrets });
|
||||
if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
|
||||
else importMutation.mutate({ widget: configuredWidget, secrets });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -93,13 +104,13 @@ export function CustomWidgetImportDialog({
|
||||
stackId={stackId}
|
||||
zIndex={zIndex}
|
||||
review={review}
|
||||
pending={mutation.isPending}
|
||||
pending={pending}
|
||||
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
|
||||
onClose={onClose}
|
||||
onConfirm={importWidget}
|
||||
messages={{
|
||||
title: t("importReview.title"),
|
||||
description: t("importReview.description"),
|
||||
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
|
||||
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
@@ -109,7 +120,7 @@ export function CustomWidgetImportDialog({
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
cancel: t("importReview.cancel"),
|
||||
confirm: t("importReview.confirm"),
|
||||
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
IconBuildingStore,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
@@ -217,7 +218,20 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
|
||||
<Skeleton h={280} />
|
||||
</SimpleGrid>
|
||||
) : list.isError ? (
|
||||
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">{t("unavailable")}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={list.isFetching}
|
||||
onClick={() => void list.refetch()}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : list.data?.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{list.data.items.map((item) => (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
publishWorkshopDefinition,
|
||||
serializeWorkshopDefinition,
|
||||
workshopDefinitionChanged,
|
||||
} from "./workshop-publish-definition";
|
||||
|
||||
const definition = {
|
||||
name: "Status",
|
||||
sources: {
|
||||
public: { name: "Public API", networkScope: "public" },
|
||||
private: { name: "Home server", networkScope: "private" },
|
||||
local: { networkScope: "loopback" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("Workshop publish definition inspection", () => {
|
||||
it("reports every source URL that requires review", () => {
|
||||
expect(getPrivateWorkshopSourceNames(definition)).toEqual(["Home server", "local"]);
|
||||
});
|
||||
|
||||
it("detects an export that changed after inspection", () => {
|
||||
expect(workshopDefinitionChanged(definition, definition)).toBe(false);
|
||||
expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks publishing when the definition changes after inspection", async () => {
|
||||
const publish = vi.fn(async () => undefined);
|
||||
await expect(
|
||||
publishWorkshopDefinition({
|
||||
inspectedDefinition: definition,
|
||||
refetchDefinition: async () => ({ ...definition, name: "Updated" }),
|
||||
publish,
|
||||
}),
|
||||
).resolves.toBe("changed");
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes the same refreshed export that passed the change check", async () => {
|
||||
const publish = vi.fn(async () => undefined);
|
||||
await expect(
|
||||
publishWorkshopDefinition({
|
||||
inspectedDefinition: definition,
|
||||
refetchDefinition: async () => definition,
|
||||
publish,
|
||||
}),
|
||||
).resolves.toBe("published");
|
||||
expect(publish).toHaveBeenCalledWith(serializeWorkshopDefinition(definition));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
interface PublishableWorkshopDefinition {
|
||||
sources: Record<string, { name?: string; networkScope: string }>;
|
||||
}
|
||||
|
||||
export const serializeWorkshopDefinition = (definition: unknown): string => {
|
||||
const serialized = JSON.stringify(definition);
|
||||
if (serialized === undefined) throw new Error("Workshop definition is not serializable");
|
||||
return serialized;
|
||||
};
|
||||
|
||||
export const workshopDefinitionChanged = (previous: unknown, current: unknown) =>
|
||||
serializeWorkshopDefinition(previous) !== serializeWorkshopDefinition(current);
|
||||
|
||||
export async function publishWorkshopDefinition({
|
||||
inspectedDefinition,
|
||||
refetchDefinition,
|
||||
publish,
|
||||
}: {
|
||||
inspectedDefinition: unknown;
|
||||
refetchDefinition: () => Promise<unknown | undefined>;
|
||||
publish: (content: string) => Promise<unknown>;
|
||||
}) {
|
||||
const refreshedDefinition = await refetchDefinition();
|
||||
if (refreshedDefinition === undefined) return "unavailable" as const;
|
||||
if (workshopDefinitionChanged(inspectedDefinition, refreshedDefinition)) return "changed" as const;
|
||||
await publish(serializeWorkshopDefinition(refreshedDefinition));
|
||||
return "published" as const;
|
||||
}
|
||||
|
||||
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
|
||||
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
|
||||
source.networkScope === "public" ? [] : [source.name ?? sourceId],
|
||||
);
|
||||
@@ -10,6 +10,11 @@ import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
publishWorkshopDefinition,
|
||||
serializeWorkshopDefinition,
|
||||
} from "./workshop-publish-definition";
|
||||
|
||||
export function WorkshopPublishModal({
|
||||
opened,
|
||||
@@ -22,7 +27,6 @@ export function WorkshopPublishModal({
|
||||
}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const utils = clientApi.useUtils();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -31,10 +35,9 @@ export function WorkshopPublishModal({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [published, setPublished] = useState(false);
|
||||
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
|
||||
const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened });
|
||||
const privateSources = Object.entries(definition.data?.sources ?? {}).filter(
|
||||
([, source]) => source.networkScope !== "public",
|
||||
);
|
||||
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
|
||||
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
|
||||
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
@@ -50,15 +53,33 @@ export function WorkshopPublishModal({
|
||||
setPublished(false);
|
||||
setSourceUrlsReviewed(false);
|
||||
}, [opened, widget.name]);
|
||||
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
|
||||
|
||||
const publish = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const definition = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
await createSubmission.mutateAsync({
|
||||
input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
|
||||
screenshots,
|
||||
if (!definition.data) throw new Error(t("publish.error"));
|
||||
const inspectedDefinition = definition.data;
|
||||
const result = await publishWorkshopDefinition({
|
||||
inspectedDefinition,
|
||||
refetchDefinition: async () => (await definition.refetch()).data,
|
||||
publish: (content) =>
|
||||
createSubmission.mutateAsync({
|
||||
input: {
|
||||
type: "customWidget",
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
},
|
||||
screenshots,
|
||||
}),
|
||||
});
|
||||
if (result === "unavailable") throw new Error(t("publish.error"));
|
||||
if (result === "changed") {
|
||||
setSourceUrlsReviewed(false);
|
||||
setError(t("publish.definitionChanged"));
|
||||
return;
|
||||
}
|
||||
setPublished(true);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("publish.error"));
|
||||
@@ -113,10 +134,10 @@ export function WorkshopPublishModal({
|
||||
value={screenshots}
|
||||
onChange={setScreenshots}
|
||||
/>
|
||||
{privateSources.length > 0 && (
|
||||
{privateSourceNames.length > 0 && (
|
||||
<Alert color="yellow">
|
||||
{t("publish.privateSourceWarning", {
|
||||
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
|
||||
sources: privateSourceNames.join(", "),
|
||||
})}
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
@@ -126,10 +147,17 @@ export function WorkshopPublishModal({
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Button
|
||||
loading={createSubmission.isPending}
|
||||
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
|
||||
loading={createSubmission.isPending || definition.isFetching}
|
||||
disabled={
|
||||
!user ||
|
||||
!definition.data ||
|
||||
definition.isError ||
|
||||
title.trim().length < 3 ||
|
||||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
|
||||
}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("publish.action")}
|
||||
|
||||
+17
-1
@@ -1,13 +1,29 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
import { normalizeHttpUrl } from "@homarr/workshop/schema";
|
||||
|
||||
const publicHttpUrl = (variableName: string) =>
|
||||
z.string().transform((value, context) => {
|
||||
try {
|
||||
return normalizeHttpUrl(value, variableName);
|
||||
} catch (error) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: error instanceof Error ? error.message : `${variableName} is invalid`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
WORKSHOP_API_URL: z.string().url().optional(),
|
||||
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
|
||||
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
|
||||
WORKSHOP_WEB_URL: publicHttpUrl("WORKSHOP_WEB_URL").optional(),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
# Local PocketBase + Docusaurus development
|
||||
PB_EXPOSE_PORT=8090
|
||||
DOCS_EXPOSE_PORT=3003
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
|
||||
# Optional local GitHub OAuth application.
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39
|
||||
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39.9@sha256:c0667fe6c5197ff226ff642120ab1ad870c3f2f27300940a61e5a7fa082b6816
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
@@ -16,13 +16,17 @@ COPY tooling/typescript ./tooling/typescript
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set store-dir /pnpm/store && pnpm install --filter @homarr/docs... --frozen-lockfile
|
||||
|
||||
FROM dependencies AS website
|
||||
ARG HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
ARG WORKSHOP_API_URL=https://homarr.dev
|
||||
ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL
|
||||
ENV WORKSHOP_API_URL=$WORKSHOP_API_URL
|
||||
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
|
||||
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
|
||||
ENV PORT=8090
|
||||
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
|
||||
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
|
||||
FROM pocketbase-runtime AS development
|
||||
|
||||
+139
-56
@@ -1,101 +1,184 @@
|
||||
# Homarr Workshop
|
||||
|
||||
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
|
||||
Workshop is the public, community-driven PocketBase service behind the Workshop pages in Homarr's Docusaurus site. It
|
||||
stores Custom JSX v2 widgets and Custom CSS submissions. Installation-time validation inside Homarr remains
|
||||
authoritative: public content must be reviewed before it is installed.
|
||||
|
||||
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
|
||||
collections, API rules, and its explicit serve arguments.
|
||||
The runtime base is PocketBase 0.39.9 pinned by its multi-platform OCI digest in `apps/workshop/Dockerfile`. Update the
|
||||
version and digest together, then run the Workshop integration and production-image smoke suites.
|
||||
|
||||
It intentionally has no approval queue, user bans, moderator roles, reusable widget connections, or remote control over
|
||||
installed widgets. Submissions publish immediately. Authenticated users can vote, comment, and report; users with
|
||||
`isAdmin` enabled can dismiss reports or delete any submission or comment.
|
||||
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
|
||||
access. Moderators can review private reports, dismiss reports, and remove submissions or comments.
|
||||
|
||||
## Local development
|
||||
## Run locally
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
pnpm docker:workshop
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
pnpm install --frozen-lockfile
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
Use `docker compose -f apps/workshop/docker-compose.yml logs -f workshop` for logs and
|
||||
`docker compose -f apps/workshop/docker-compose.yml stop workshop` to stop it without deleting data.
|
||||
This starts persistent PocketBase at `http://127.0.0.1:8090`. Start Docusaurus in another terminal:
|
||||
|
||||
Compose passes the selected port to PocketBase as `PORT`, so it listens on the same port inside and outside the
|
||||
container. With the image directly, set both values: `docker run -e PORT=3003 -p 3003:3003 <workshop-image>`.
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
|
||||
Open:
|
||||
|
||||
The `users.isAdmin` field defaults to `false`. Collection rules allow account creation only through OAuth without that
|
||||
field and reject all regular-user attempts to change it. Appoint administrators out of band with a PocketBase
|
||||
superuser or a deliberate SQLite update; there is no public role-management endpoint or hook.
|
||||
- PocketBase administration: `http://127.0.0.1:8090/_/`
|
||||
- Workshop: `http://127.0.0.1:3003/workshop`
|
||||
|
||||
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
|
||||
Create the first PocketBase superuser in the administration UI, or use:
|
||||
|
||||
The GitHub OAuth application needs one callback for the Workshop host:
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml exec workshop \
|
||||
pocketbase superuser create you@example.com 'replace-with-a-strong-password' --dir=/pb_data
|
||||
```
|
||||
|
||||
Stopping the Compose process preserves `homarr-workshop_pb_data`. To inspect or stop the service separately:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml logs -f workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
```
|
||||
|
||||
### Pre-release migration reset
|
||||
|
||||
The v2 branch consolidates the unreleased Workshop migrations into one initial migration. A PocketBase volume that ran
|
||||
an earlier v2 Workshop migration chain is unsupported. Back up anything you need, then use the intentional local reset
|
||||
command near the end of this document and start the stack again. Startup never deletes a volume automatically. This
|
||||
exception applies only to disposable pre-release volumes and must not be used for production data.
|
||||
|
||||
## Connect the Homarr application
|
||||
|
||||
Use the same URL contract in the repository-root `.env`, then restart Homarr:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`WORKSHOP_API_URL` controls API calls. `WORKSHOP_WEB_URL` controls links from Homarr. `HOMARR_WEBSITE_URL` is the
|
||||
documentation/site base. All three must be absolute HTTP(S) URLs. The old Docusaurus-only `WORKSHOP_URL` variable
|
||||
remains a deprecated alias for one release.
|
||||
|
||||
## Optional GitHub OAuth
|
||||
|
||||
Create a GitHub OAuth application with this callback:
|
||||
|
||||
```text
|
||||
https://<workshop-host>/api/oauth2-redirect
|
||||
```
|
||||
|
||||
Self-hosted Homarr origins do not need to be registered with GitHub. Keep `PB_ALLOWED_ORIGINS=*` (the default) so localhost and arbitrary self-hosted domains can open the central PocketBase OAuth popup. Set a restricted origin list only if it includes every Homarr origin that should use Workshop sign-in.
|
||||
Set both credentials in `.env`:
|
||||
|
||||
Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in the repository-root `.env`. Set
|
||||
`WORKSHOP_PUBLIC_ORIGIN=https://<workshop-host>` so notification emails link to the public Workshop. Configure the sender
|
||||
and SMTP transport in PocketBase. PocketBase emails the submission author about new comments, emails every current
|
||||
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
|
||||
failures are logged and never reject a comment, report, or deletion.
|
||||
```dotenv
|
||||
GITHUB_CLIENT_ID=...
|
||||
GITHUB_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
Run the disposable service integration test from the repository root:
|
||||
OAuth settings synchronize on every PocketBase startup. Restart after rotation. A production deployment must never
|
||||
supply only one credential. `PB_ALLOWED_ORIGINS=*` is appropriate for the central community because arbitrary
|
||||
self-hosted Homarr origins must open its OAuth popup; a restricted list must include every allowed browser origin.
|
||||
Workshop uses the OAuth provider username as the public display name instead of the profile's full name.
|
||||
|
||||
## Promote a Workshop moderator
|
||||
|
||||
Sign in once through GitHub so the user record exists. In the PocketBase administration UI, open the `users` collection
|
||||
and set `isAdmin` to `true`. The user must sign in again. Set it back to `false` to remove moderator access.
|
||||
|
||||
Regular API rules cannot set this field. Never describe a Workshop moderator as a Homarr administrator: they are
|
||||
separate roles in separate services.
|
||||
|
||||
## Validation and tests
|
||||
|
||||
The official browser and shared client validate content with the canonical TypeScript schema and send the matching
|
||||
schema identifier. PocketBase stores submission content without parsing, normalizing, or validating it; it only owns
|
||||
revision fields and rejects stale compare-and-swap updates. Homarr validates downloaded content again before
|
||||
installation, so direct API submissions that bypass the official client can be rejected there.
|
||||
|
||||
Run the disposable integration suite:
|
||||
|
||||
```sh
|
||||
pnpm test:workshop
|
||||
pnpm --filter @homarr/workshop typecheck
|
||||
pnpm --filter @homarr/docs typecheck
|
||||
```
|
||||
|
||||
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
|
||||
roles into the normal `homarr-workshop_pb_data` volume.
|
||||
The integration suite uses a separate Compose project and deletes only its disposable volume.
|
||||
|
||||
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
|
||||
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
|
||||
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
|
||||
manifest before Homarr enables installation.
|
||||
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
|
||||
administrator-only moderation. Each new submission receives one real author-owned upvote. Reports are private to
|
||||
administrators and are dismissed by changing their status. PocketBase cascade deletion removes related votes, comments,
|
||||
and reports when a submission is deleted. Authors and administrators can mark a submission outdated without making it
|
||||
un-installable.
|
||||
## Backup and restore
|
||||
|
||||
## Production docs preview
|
||||
|
||||
Build the documentation and serve it from PocketBase using the same production image layout as the hosted Workshop:
|
||||
PocketBase data lives in `/pb_data`. Stop writes and copy it into a new, empty host directory for each
|
||||
filesystem-level backup:
|
||||
|
||||
```sh
|
||||
pnpm docker:docs
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml cp workshop:/pb_data/. ./workshop-backup
|
||||
docker compose -f apps/workshop/docker-compose.yml start workshop
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. PocketBase data persists in the
|
||||
`homarr-workshop_pb_data` named volume.
|
||||
|
||||
Point a local Homarr checkout at it with:
|
||||
Never copy a backup over a non-empty PocketBase volume: files created after the backup could survive and make the
|
||||
restore inconsistent. For a development restore, first verify that the selected backup contains the database, then
|
||||
empty the stopped volume before copying it:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:3003 pnpm dev
|
||||
test -f ./workshop-backup/data.db
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint sh workshop \
|
||||
-c 'find /pb_data -mindepth 1 -maxdepth 1 -exec rm -rf {} +'
|
||||
docker compose -f apps/workshop/docker-compose.yml cp ./workshop-backup/. workshop:/pb_data
|
||||
docker compose -f apps/workshop/docker-compose.yml start workshop
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
curl --fail 'http://127.0.0.1:8090/api/collections/workshop_listings/records?perPage=1'
|
||||
```
|
||||
|
||||
The production Homarr image reads the same variable when the container starts:
|
||||
The cleanup command irreversibly empties the selected Compose volume, so confirm the backup path and preserve the
|
||||
current volume separately before running it. After the HTTP checks, verify sign-in, reports, and a test installation.
|
||||
Production backups must be versioned, access-controlled, restored into a clean staging volume, and validated before a
|
||||
release.
|
||||
|
||||
## Production image and operations
|
||||
|
||||
The production target serves the built documentation from `/pb_public` and PocketBase from the same image. Required
|
||||
production configuration:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
WORKSHOP_API_URL=https://homarr.dev
|
||||
WORKSHOP_WEB_URL=https://homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
Build and smoke-test the combined image locally:
|
||||
|
||||
```sh
|
||||
docker run -p 7575:7575 -e WORKSHOP_API_URL=http://127.0.0.1:3003 <homarr-image>
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs up --build --detach docs
|
||||
curl --fail http://127.0.0.1:3003/api/health
|
||||
curl --fail http://127.0.0.1:3003/
|
||||
curl --fail http://127.0.0.1:3003/workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs
|
||||
```
|
||||
|
||||
This command starts the profiled `docs` service from `apps/workshop/docker-compose.yml`. It does not start the development `workshop` service. Stop and remove the preview with:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs rm --stop --force docs
|
||||
```
|
||||
|
||||
Normal startup never deletes data. To intentionally recreate the local database, stop the stack first and then run:
|
||||
Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional,
|
||||
irreversible local reset:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml down --volumes
|
||||
```
|
||||
|
||||
PocketBase data lives in `/pb_data`. Docker Compose persists it in the `pb_data` named volume for both the development Workshop service and the production documentation/Workshop service. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
|
||||
At startup the container repairs ownership of existing data volumes, then runs PocketBase as its non-root user.
|
||||
Never run the reset command against production. Release by immutable image digest, back up application and PocketBase
|
||||
data first, rehearse restore and rollback in staging, and promote the exact tested digest. The Algolia recrawl workflow
|
||||
remains manual until the external Homarr website host can dispatch it after promotion; publishing the image to GHCR does
|
||||
not make that image live.
|
||||
@@ -10,9 +10,10 @@ services:
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${PB_EXPOSE_PORT:-8090}
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:8090}
|
||||
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
|
||||
volumes:
|
||||
@@ -29,9 +30,13 @@ services:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: production
|
||||
args:
|
||||
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
|
||||
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003}
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${DOCS_EXPOSE_PORT:-3003}
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 36px; color: #18181b; font-size: 15px; line-height: 1.65">
|
||||
<p style="margin: 0 0 18px">A Workshop administrator removed your submission:</p>
|
||||
<p style="margin: 0 0 18px">A Workshop moderator removed your submission:</p>
|
||||
<div style="margin: 0 0 24px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2">
|
||||
<strong>{{submissionTitle}}</strong>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
const GITHUB_USERNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
||||
|
||||
const rejectRequest = (message) => {
|
||||
throw new BadRequestError(message);
|
||||
};
|
||||
|
||||
const normalizedIdentityText = (...values) => {
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string") continue;
|
||||
const normalized = [...value]
|
||||
.filter((character) => {
|
||||
const code = character.codePointAt(0) || 0;
|
||||
return code > 31 && code !== 127;
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const deriveGithubIdentity = (oauthUser, recordId) => {
|
||||
const rawUser = oauthUser && typeof oauthUser.rawUser === "object" ? oauthUser.rawUser : {};
|
||||
const candidateUsername = normalizedIdentityText(oauthUser && oauthUser.username, rawUser.login);
|
||||
const githubUsername =
|
||||
GITHUB_USERNAME_PATTERN.test(candidateUsername) && !candidateUsername.includes("--") ? candidateUsername : "";
|
||||
const displayName =
|
||||
normalizedIdentityText(githubUsername, rawUser.login, oauthUser && oauthUser.name, rawUser.name).slice(0, 100) ||
|
||||
`GitHub user ${String(recordId || "").slice(0, 8)}`;
|
||||
const candidateAvatarUrl = normalizedIdentityText(
|
||||
oauthUser && oauthUser.avatarURL,
|
||||
oauthUser && oauthUser.avatarUrl,
|
||||
rawUser.avatar_url,
|
||||
);
|
||||
const avatarUrl =
|
||||
candidateAvatarUrl.length <= 2_048 && /^https:\/\/[^"'\\\s]+$/i.test(candidateAvatarUrl) ? candidateAvatarUrl : "";
|
||||
|
||||
return {
|
||||
displayName,
|
||||
avatarUrl,
|
||||
githubUsername,
|
||||
githubProfileUrl: githubUsername ? `https://github.com/${githubUsername}` : "",
|
||||
};
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const emailTemplate = (filename) => {
|
||||
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks;
|
||||
return $os.readFile($filepath.join(hooksPath, filename));
|
||||
};
|
||||
|
||||
const sendEmail = (app, recipientEmail, subject, text, html) => {
|
||||
const sender = app.settings().meta;
|
||||
app.newMailClient().send(
|
||||
new MailerMessage({
|
||||
from: { address: sender.senderAddress, name: sender.senderName },
|
||||
to: [{ address: recipientEmail }],
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
deriveGithubIdentity,
|
||||
emailTemplate,
|
||||
escapeHtml,
|
||||
rejectRequest,
|
||||
sendEmail,
|
||||
};
|
||||
@@ -1,5 +1,113 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onBootstrap((event) => {
|
||||
const clientId = String($os.getenv("GITHUB_CLIENT_ID") || "").trim();
|
||||
const clientSecret = String($os.getenv("GITHUB_CLIENT_SECRET") || "").trim();
|
||||
if (Boolean(clientId) !== Boolean(clientSecret)) {
|
||||
console.log(JSON.stringify({ event: "workshop_oauth_configuration_rejected", reason: "partial_credentials" }));
|
||||
throw new Error("GitHub OAuth requires both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET");
|
||||
}
|
||||
const configured = Boolean(clientId && clientSecret);
|
||||
event.next();
|
||||
const users = event.app.findCollectionByNameOrId("users");
|
||||
users.oauth2.enabled = configured;
|
||||
users.oauth2.mappedFields = { id: "", name: "", username: "displayName", avatarURL: "avatarUrl" };
|
||||
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
|
||||
event.app.save(users);
|
||||
console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
|
||||
});
|
||||
|
||||
onRecordAuthWithOAuth2Request((event) => {
|
||||
if (event.providerName !== "github" || !event.oauth2User) {
|
||||
event.next();
|
||||
return;
|
||||
}
|
||||
|
||||
const { deriveGithubIdentity } = require(`${__hooks}/workshop-utils.js`);
|
||||
const identity = deriveGithubIdentity(event.oauth2User, event.record ? event.record.id : event.oauth2User.id);
|
||||
if (event.record) {
|
||||
event.record.set("displayName", identity.displayName);
|
||||
event.record.set("avatar", "");
|
||||
event.record.set("avatarUrl", identity.avatarUrl);
|
||||
event.record.set("githubUsername", identity.githubUsername);
|
||||
event.record.set("githubProfileUrl", identity.githubProfileUrl);
|
||||
event.app.save(event.record);
|
||||
} else {
|
||||
event.createData.displayName = identity.displayName;
|
||||
event.createData.avatarUrl = identity.avatarUrl;
|
||||
event.createData.githubUsername = identity.githubUsername;
|
||||
event.createData.githubProfileUrl = identity.githubProfileUrl;
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "workshop_oauth_identity_synchronized",
|
||||
provider: "github",
|
||||
recordId: event.record ? event.record.id : null,
|
||||
newRecord: !event.record,
|
||||
}),
|
||||
);
|
||||
event.next();
|
||||
}, "users");
|
||||
|
||||
onRecordUpdateRequest((event) => {
|
||||
const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
|
||||
const originalAvatar = event.record.original().getString("avatar");
|
||||
const pendingAvatarFiles = event.record.getUnsavedFiles("avatar");
|
||||
if (event.record.getString("avatar") !== originalAvatar || pendingAvatarFiles.length > 0) {
|
||||
rejectRequest("Workshop avatars are managed by GitHub OAuth");
|
||||
}
|
||||
event.next();
|
||||
}, "users");
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
event.record.set("revision", 1);
|
||||
event.record.set("expectedRevision", 0);
|
||||
event.record.set("changelog", "");
|
||||
event.record.set("outdated", false);
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((event) => {
|
||||
const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = event.record.original();
|
||||
const currentRevision = original.getInt("revision");
|
||||
const expectedRevision = event.record.getInt("expectedRevision");
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
|
||||
rejectRequest("Submission changed since it was read");
|
||||
}
|
||||
event.record.set("expectedRevision", expectedRevision);
|
||||
event.record.set("revision", currentRevision + 1);
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
const auth = event.requestInfo().auth;
|
||||
const reporter = event.record.getString("reporter");
|
||||
const submission = event.record.getString("submission");
|
||||
if (auth && auth.id === reporter) {
|
||||
const dismissed = event.app.findRecordsByFilter(
|
||||
"reports",
|
||||
"reporter = {:reporter} && submission = {:submission} && status = 'dismissed'",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
{ reporter, submission },
|
||||
);
|
||||
if (dismissed.length > 0) {
|
||||
event.app.delete(dismissed[0]);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "workshop_report_reopened",
|
||||
reporterId: reporter,
|
||||
submissionId: submission,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
event.record.set("status", "open");
|
||||
event.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const votes = event.app.findCollectionByNameOrId("votes");
|
||||
@@ -14,39 +122,15 @@ onRecordAfterCreateSuccess((event) => {
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const emailTemplate = (filename) => {
|
||||
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
|
||||
return $os.readFile($filepath.join(hooksPath, filename));
|
||||
};
|
||||
|
||||
const sendEmail = (app, recipientEmail, subject, text, html) => {
|
||||
const sender = app.settings().meta;
|
||||
app.newMailClient().send(
|
||||
new MailerMessage({
|
||||
from: { address: sender.senderAddress, name: sender.senderName },
|
||||
to: [{ address: recipientEmail }],
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
|
||||
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
|
||||
const recipient = event.app.findRecordById("users", submission.get("author"));
|
||||
const commenter = event.app.findRecordById("users", event.record.get("author"));
|
||||
const recipientEmail = recipient.email();
|
||||
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
|
||||
const publicWorkshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || `${publicOrigin}/workshop`).replace(/\/$/, "");
|
||||
|
||||
if (!recipientEmail || recipient.id === commenter.id || !publicOrigin) {
|
||||
event.next();
|
||||
@@ -56,7 +140,7 @@ onRecordAfterCreateSuccess((event) => {
|
||||
const commenterName = commenter.getString("displayName") || "A community member";
|
||||
const submissionTitle = submission.getString("title");
|
||||
const rawExcerpt = event.record.getString("content").slice(0, 280);
|
||||
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
|
||||
const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
|
||||
const template = emailTemplate("comment-email.html")
|
||||
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
|
||||
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
|
||||
@@ -78,10 +162,12 @@ onRecordAfterCreateSuccess((event) => {
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
|
||||
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
|
||||
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
|
||||
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
|
||||
const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
|
||||
const publicWorkshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || `${publicOrigin}/workshop`).replace(/\/$/, "");
|
||||
const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
|
||||
|
||||
if (!publicOrigin || admins.length === 0) {
|
||||
event.next();
|
||||
@@ -92,8 +178,8 @@ onRecordAfterCreateSuccess((event) => {
|
||||
const submissionTitle = submission.getString("title");
|
||||
const category = event.record.getString("category");
|
||||
const explanation = event.record.getString("explanation");
|
||||
const adminUrl = `${publicOrigin}/workshop/admin`;
|
||||
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
|
||||
const adminUrl = `${publicWorkshopUrl}/admin`;
|
||||
const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
|
||||
const template = emailTemplate("report-email.html")
|
||||
.replaceAll("{{reporterName}}", escapeHtml(reporterName))
|
||||
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
|
||||
@@ -122,6 +208,7 @@ onRecordAfterCreateSuccess((event) => {
|
||||
}, "reports");
|
||||
|
||||
onRecordDeleteRequest((event) => {
|
||||
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
|
||||
const submissionId = event.record.id;
|
||||
const submissionTitle = event.record.getString("title");
|
||||
const authorId = event.record.get("author");
|
||||
@@ -138,12 +225,15 @@ onRecordDeleteRequest((event) => {
|
||||
if (!recipientEmail) return;
|
||||
|
||||
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
|
||||
const workshopUrl = publicOrigin ? `${publicOrigin}/workshop/` : "";
|
||||
const workshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || (publicOrigin ? `${publicOrigin}/workshop` : "")).replace(
|
||||
/\/?$/,
|
||||
"/",
|
||||
);
|
||||
const template = emailTemplate("submission-deleted-email.html").replaceAll(
|
||||
"{{submissionTitle}}",
|
||||
escapeHtml(submissionTitle),
|
||||
);
|
||||
const text = `A Workshop administrator removed your submission “${submissionTitle}”.${
|
||||
const text = `A Workshop moderator removed your submission “${submissionTitle}”.${
|
||||
workshopUrl ? `\n\nReturn to Workshop: ${workshopUrl}` : ""
|
||||
}`;
|
||||
|
||||
|
||||
@@ -1,46 +1,95 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
const cloneRule = (value) => (value === null ? null : String(value));
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
const identityFieldsUnchanged = [
|
||||
"@request.body.email:changed = false",
|
||||
"@request.body.displayName:changed = false",
|
||||
"@request.body.avatarUrl:changed = false",
|
||||
"@request.body.githubUsername:changed = false",
|
||||
"@request.body.githubProfileUrl:changed = false",
|
||||
].join(" && ");
|
||||
const immutableSubmissionFields = [
|
||||
"@request.body.author:changed = false",
|
||||
"@request.body.type:changed = false",
|
||||
"@request.body.widgetSchema:changed = false",
|
||||
].join(" && ");
|
||||
const moderatorSubmissionFieldsUnchanged = [
|
||||
"@request.body.title:changed = false",
|
||||
"@request.body.description:changed = false",
|
||||
"@request.body.content:changed = false",
|
||||
"@request.body.changelog:changed = false",
|
||||
"@request.body.screenshots:changed = false",
|
||||
].join(" && ");
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
let usersExisted = true;
|
||||
let users;
|
||||
try {
|
||||
users = app.findCollectionByNameOrId("users");
|
||||
} catch {
|
||||
usersExisted = false;
|
||||
users = new Collection({ type: "auth", name: "users" });
|
||||
}
|
||||
|
||||
const stateCollection = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_migration_state",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [{ type: "text", name: "snapshot", required: true, max: 1_000_000 }],
|
||||
});
|
||||
app.save(stateCollection);
|
||||
const state = {
|
||||
usersExisted,
|
||||
users: usersExisted
|
||||
? {
|
||||
passwordAuth: cloneJson(users.passwordAuth),
|
||||
oauth2: cloneJson(users.oauth2),
|
||||
listRule: cloneRule(users.listRule),
|
||||
viewRule: cloneRule(users.viewRule),
|
||||
createRule: cloneRule(users.createRule),
|
||||
updateRule: cloneRule(users.updateRule),
|
||||
deleteRule: cloneRule(users.deleteRule),
|
||||
}
|
||||
: null,
|
||||
addedUserFields: [],
|
||||
};
|
||||
const addUserField = (field) => {
|
||||
if (users.fields.getByName(field.name)) return;
|
||||
users.fields.add(field);
|
||||
state.addedUserFields.push(field.name);
|
||||
};
|
||||
|
||||
users.passwordAuth = { enabled: false };
|
||||
if (!users.fields.getByName("displayName"))
|
||||
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
|
||||
if (!users.fields.getByName("avatar"))
|
||||
users.fields.add(
|
||||
new FileField({
|
||||
name: "avatar",
|
||||
maxSelect: 1,
|
||||
maxSize: 2_097_152,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
}),
|
||||
);
|
||||
if (!users.fields.getByName("githubUsername"))
|
||||
users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
|
||||
if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
|
||||
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
|
||||
addUserField(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
addUserField(new URLField({ name: "avatarUrl" }));
|
||||
addUserField(
|
||||
new FileField({
|
||||
name: "avatar",
|
||||
maxSelect: 1,
|
||||
maxSize: 2_097_152,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
}),
|
||||
);
|
||||
addUserField(new TextField({ name: "githubUsername", max: 100 }));
|
||||
addUserField(new URLField({ name: "githubProfileUrl" }));
|
||||
addUserField(new BoolField({ name: "isAdmin" }));
|
||||
|
||||
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
|
||||
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
|
||||
if (githubClientId && githubClientSecret) {
|
||||
users.oauth2.enabled = true;
|
||||
users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
|
||||
}
|
||||
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = "";
|
||||
users.viewRule = "";
|
||||
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
const stateRecord = new Record(stateCollection);
|
||||
stateRecord.set("snapshot", JSON.stringify(state));
|
||||
app.save(stateRecord);
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
@@ -48,7 +97,9 @@ migrate(
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
|
||||
updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
|
||||
updateRule:
|
||||
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
|
||||
`(${adminRule} && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`,
|
||||
deleteRule: `author = @request.auth.id || ${adminRule}`,
|
||||
fields: [
|
||||
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
|
||||
@@ -57,6 +108,7 @@ migrate(
|
||||
{ type: "text", name: "widgetSchema", required: true, min: 1, max: 100 },
|
||||
{ type: "text", name: "content", required: true, max: 1000000 },
|
||||
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
|
||||
{ type: "number", name: "expectedRevision", onlyInt: true, min: 0 },
|
||||
{ type: "text", name: "changelog", max: 2000 },
|
||||
{ type: "bool", name: "outdated" },
|
||||
{
|
||||
@@ -75,6 +127,18 @@ migrate(
|
||||
submissions.addIndex("idx_submissions_author", false, "author", "");
|
||||
submissions.addIndex("idx_submissions_created", false, "created", "");
|
||||
app.save(submissions);
|
||||
app
|
||||
.db()
|
||||
.newQuery(
|
||||
`CREATE TRIGGER submissions_revision_cas
|
||||
BEFORE UPDATE ON submissions
|
||||
FOR EACH ROW
|
||||
WHEN NEW.expectedRevision != OLD.revision OR NEW.revision != OLD.revision + 1
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'submission revision compare-and-swap failed');
|
||||
END`,
|
||||
)
|
||||
.execute();
|
||||
|
||||
const votes = new Collection({
|
||||
type: "base",
|
||||
@@ -138,7 +202,12 @@ migrate(
|
||||
listRule: adminRule,
|
||||
viewRule: adminRule,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
|
||||
updateRule:
|
||||
`${adminRule} && ` +
|
||||
"@request.body.reporter:changed = false && " +
|
||||
"@request.body.submission:changed = false && " +
|
||||
"@request.body.category:changed = false && " +
|
||||
"@request.body.explanation:changed = false",
|
||||
deleteRule: adminRule,
|
||||
fields: [
|
||||
{
|
||||
@@ -194,25 +263,40 @@ migrate(
|
||||
`,
|
||||
});
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
(rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
|
||||
),
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
];
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
let stateCollection;
|
||||
let stateRecord;
|
||||
try {
|
||||
stateCollection = app.findCollectionByNameOrId("workshop_migration_state");
|
||||
stateRecord = app.findFirstRecordByFilter(stateCollection.id, "");
|
||||
} catch {
|
||||
throw new Error("Workshop migration cannot be rolled back safely because its state snapshot is missing");
|
||||
}
|
||||
const state = JSON.parse(stateRecord.getString("snapshot"));
|
||||
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
if (state.usersExisted) {
|
||||
users.passwordAuth = state.users.passwordAuth;
|
||||
users.oauth2 = state.users.oauth2;
|
||||
users.listRule = state.users.listRule;
|
||||
users.viewRule = state.users.viewRule;
|
||||
users.createRule = state.users.createRule;
|
||||
users.updateRule = state.users.updateRule;
|
||||
users.deleteRule = state.users.deleteRule;
|
||||
for (const name of state.addedUserFields) {
|
||||
const field = users.fields.getByName(name);
|
||||
if (field) users.fields.removeById(field.id);
|
||||
}
|
||||
app.save(users);
|
||||
} else {
|
||||
app.delete(users);
|
||||
}
|
||||
app.delete(stateCollection);
|
||||
},
|
||||
);
|
||||
@@ -4,6 +4,10 @@ set -eu
|
||||
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
|
||||
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
|
||||
|
||||
node apps/workshop/tests/workshop-contracts.mjs
|
||||
node apps/workshop/tests/workshop-migration.mjs
|
||||
node apps/workshop/tests/workshop-identity.mjs
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
@@ -30,3 +34,28 @@ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml e
|
||||
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
|
||||
|
||||
for client_id in workshop-test-client workshop-rotated-client; do
|
||||
GITHUB_CLIENT_ID="$client_id" GITHUB_CLIENT_SECRET="workshop-test-secret" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml up -d --force-recreate workshop
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 60 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" EXPECTED_GITHUB_CLIENT_ID="$client_id" \
|
||||
node apps/workshop/tests/oauth-sync.integration.mjs
|
||||
done
|
||||
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml stop workshop
|
||||
printf 'y\n' | PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
|
||||
migrate down 1 --dir=/pb_data --migrationsDir=/pb_migrations
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
|
||||
migrate up --dir=/pb_data --migrationsDir=/pb_migrations
|
||||
@@ -0,0 +1,34 @@
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
|
||||
const expectedClientId = process.env.EXPECTED_GITHUB_CLIENT_ID;
|
||||
if (!expectedClientId) throw new Error("EXPECTED_GITHUB_CLIENT_ID is required");
|
||||
|
||||
const request = async (path, init = {}) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
|
||||
return body;
|
||||
};
|
||||
|
||||
const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
|
||||
});
|
||||
const users = await request("/api/collections/users", {
|
||||
headers: { authorization: `Bearer ${root.token}` },
|
||||
});
|
||||
const github = users.oauth2?.providers?.find((provider) => provider.name === "github");
|
||||
if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
|
||||
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
|
||||
}
|
||||
if (
|
||||
users.oauth2?.mappedFields?.username !== "displayName" ||
|
||||
users.oauth2?.mappedFields?.name ||
|
||||
users.oauth2?.mappedFields?.avatarURL !== "avatarUrl"
|
||||
) {
|
||||
throw new Error(`GitHub OAuth field mapping was not synchronized: ${JSON.stringify(users.oauth2?.mappedFields)}`);
|
||||
}
|
||||
|
||||
console.log(`Workshop OAuth synchronization passed for ${expectedClientId}`);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (path) => readFile(resolve(path), "utf8");
|
||||
|
||||
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
|
||||
const clientExport = packageJson.exports?.["./client"];
|
||||
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
|
||||
await access(resolve("packages/workshop", clientExport));
|
||||
|
||||
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
|
||||
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
|
||||
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
|
||||
}
|
||||
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
|
||||
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
|
||||
}
|
||||
if (hook.includes("validateAndNormalizeSubmission")) {
|
||||
throw new Error("PocketBase must store Workshop submissions without interpreting their content");
|
||||
}
|
||||
for (const artifact of [
|
||||
"apps/workshop/pb_hooks/widget-validator.js",
|
||||
"apps/workshop/pb_hooks/widget-validator.bundle.cjs",
|
||||
"packages/workshop/src/pocketbase-validator.ts",
|
||||
"scripts/build-workshop-validator.mjs",
|
||||
]) {
|
||||
await access(resolve(artifact))
|
||||
.then(() => {
|
||||
throw new Error(`Workshop validator artifact must stay removed: ${artifact}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
|
||||
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
|
||||
}
|
||||
|
||||
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
|
||||
for (const required of [
|
||||
"workshop_migration_state",
|
||||
"addedUserFields",
|
||||
"cloneRule(users.listRule)",
|
||||
"CREATE TRIGGER submissions_revision_cas",
|
||||
"NEW.expectedRevision != OLD.revision",
|
||||
"users.passwordAuth = state.users.passwordAuth",
|
||||
"users.oauth2 = state.users.oauth2",
|
||||
"app.delete(users)",
|
||||
]) {
|
||||
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
|
||||
}
|
||||
|
||||
const workflow = await read(".github/workflows/workshop.yml");
|
||||
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
|
||||
|
||||
console.log("Workshop static contracts passed");
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { deriveGithubIdentity } = require("../pb_hooks/workshop-utils.js");
|
||||
|
||||
const identity = deriveGithubIdentity(
|
||||
{
|
||||
name: " Octo Cat ",
|
||||
username: "octocat",
|
||||
avatarURL: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
rawUser: {},
|
||||
},
|
||||
"record123456",
|
||||
);
|
||||
if (
|
||||
identity.displayName !== "octocat" ||
|
||||
identity.githubUsername !== "octocat" ||
|
||||
identity.githubProfileUrl !== "https://github.com/octocat" ||
|
||||
identity.avatarUrl !== "https://avatars.githubusercontent.com/u/1?v=4"
|
||||
) {
|
||||
throw new Error(`Trusted GitHub identity was not normalized: ${JSON.stringify(identity)}`);
|
||||
}
|
||||
|
||||
const rawFallback = deriveGithubIdentity(
|
||||
{
|
||||
name: "",
|
||||
username: "",
|
||||
avatarURL: "",
|
||||
rawUser: {
|
||||
name: "\nRaw User\u0000",
|
||||
login: "raw-user",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/2",
|
||||
},
|
||||
},
|
||||
"record123456",
|
||||
);
|
||||
if (
|
||||
rawFallback.displayName !== "raw-user" ||
|
||||
rawFallback.githubUsername !== "raw-user" ||
|
||||
rawFallback.githubProfileUrl !== "https://github.com/raw-user"
|
||||
) {
|
||||
throw new Error(`Raw GitHub identity fallback was not normalized: ${JSON.stringify(rawFallback)}`);
|
||||
}
|
||||
|
||||
const rejected = deriveGithubIdentity(
|
||||
{
|
||||
name: "\n",
|
||||
username: "not/a/github-user",
|
||||
avatarURL: "javascript:alert(1)",
|
||||
rawUser: {},
|
||||
},
|
||||
"record123456",
|
||||
);
|
||||
if (
|
||||
rejected.displayName !== "GitHub user record12" ||
|
||||
rejected.githubUsername !== "" ||
|
||||
rejected.githubProfileUrl !== "" ||
|
||||
rejected.avatarUrl !== ""
|
||||
) {
|
||||
throw new Error(`Invalid provider identity fields were retained: ${JSON.stringify(rejected)}`);
|
||||
}
|
||||
|
||||
console.log("Workshop OAuth identity normalization passed");
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
// oxlint-disable-next-line typescript/no-extraneous-class -- Mirrors the PocketBase Field constructor API.
|
||||
class Field {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.id = `field-${options.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
class Fields {
|
||||
constructor(fields = []) {
|
||||
this.values = fields.map((field) => (field instanceof Field ? field : new Field(field)));
|
||||
}
|
||||
getByName(name) {
|
||||
return this.values.find((field) => field.name === name);
|
||||
}
|
||||
add(field) {
|
||||
this.values.push(field);
|
||||
}
|
||||
removeById(id) {
|
||||
this.values = this.values.filter((field) => field.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
class Collection {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.id = options.id ?? options.name;
|
||||
this.fields = new Fields(options.fields);
|
||||
}
|
||||
addIndex() {}
|
||||
}
|
||||
|
||||
let recordId = 0;
|
||||
class Record {
|
||||
constructor(collection) {
|
||||
this.collection = collection;
|
||||
this.id = `record-${++recordId}`;
|
||||
this.data = new Map();
|
||||
}
|
||||
set(name, value) {
|
||||
this.data.set(name, value);
|
||||
}
|
||||
getString(name) {
|
||||
return String(this.data.get(name) ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
const original = {
|
||||
passwordAuth: { enabled: true, identityFields: ["email"] },
|
||||
oauth2: { enabled: true, providers: [{ name: "original", clientId: "before", clientSecret: "before-secret" }] },
|
||||
rules: {
|
||||
listRule: "original-list",
|
||||
viewRule: "original-view",
|
||||
createRule: "original-create",
|
||||
updateRule: "original-update",
|
||||
deleteRule: "original-delete",
|
||||
},
|
||||
};
|
||||
const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
|
||||
users.passwordAuth = structuredClone(original.passwordAuth);
|
||||
users.oauth2 = structuredClone(original.oauth2);
|
||||
Object.assign(users, original.rules);
|
||||
const collections = new Map([[users.id, users]]);
|
||||
const records = new Map();
|
||||
const rawQueries = [];
|
||||
const app = {
|
||||
db() {
|
||||
return {
|
||||
newQuery(sql) {
|
||||
return {
|
||||
execute() {
|
||||
rawQueries.push(sql);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
findCollectionByNameOrId(name) {
|
||||
const collection = collections.get(name);
|
||||
if (!collection) throw new Error(`Missing collection ${name}`);
|
||||
return collection;
|
||||
},
|
||||
findFirstRecordByFilter(collectionId) {
|
||||
const record = records.get(collectionId)?.[0];
|
||||
if (!record) throw new Error(`Missing state record ${collectionId}`);
|
||||
return record;
|
||||
},
|
||||
save(value) {
|
||||
if (value instanceof Collection) collections.set(value.id, value);
|
||||
else if (value instanceof Record)
|
||||
records.set(value.collection.id, [...(records.get(value.collection.id) ?? []), value]);
|
||||
},
|
||||
delete(collection) {
|
||||
collections.delete(collection.id);
|
||||
records.delete(collection.id);
|
||||
},
|
||||
};
|
||||
|
||||
let migration;
|
||||
const sandbox = {
|
||||
Collection,
|
||||
Record,
|
||||
TextField: Field,
|
||||
URLField: Field,
|
||||
FileField: Field,
|
||||
BoolField: Field,
|
||||
console,
|
||||
migrate: (up, down) => {
|
||||
migration = { up, down };
|
||||
},
|
||||
};
|
||||
const source = await readFile("apps/workshop/pb_migrations/1784240000_workshop_widgets.js", "utf8");
|
||||
vm.runInNewContext(source, sandbox);
|
||||
if (!migration) throw new Error("Workshop migration did not register");
|
||||
|
||||
migration.up(app);
|
||||
if (!collections.has("workshop_migration_state")) {
|
||||
throw new Error("Workshop migration did not capture state before applying changes");
|
||||
}
|
||||
if (
|
||||
!rawQueries.some(
|
||||
(query) =>
|
||||
query.includes("CREATE TRIGGER submissions_revision_cas") &&
|
||||
query.includes("NEW.expectedRevision != OLD.revision"),
|
||||
)
|
||||
) {
|
||||
throw new Error("Workshop migration did not install the atomic submission revision guard");
|
||||
}
|
||||
migration.down(app);
|
||||
|
||||
if (JSON.stringify(users.passwordAuth) !== JSON.stringify(original.passwordAuth))
|
||||
throw new Error("Password authentication was not restored");
|
||||
if (JSON.stringify(users.oauth2) !== JSON.stringify(original.oauth2))
|
||||
throw new Error("OAuth settings were not restored");
|
||||
for (const [name, value] of Object.entries(original.rules)) {
|
||||
if (users[name] !== value) throw new Error(`User rule ${name} was not restored`);
|
||||
}
|
||||
if (users.fields.values.map((field) => field.name).join(",") !== "existing")
|
||||
throw new Error("Workshop user fields were not removed on rollback");
|
||||
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
|
||||
if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
|
||||
}
|
||||
|
||||
collections.clear();
|
||||
records.clear();
|
||||
rawQueries.length = 0;
|
||||
|
||||
migration.up(app);
|
||||
if (!collections.has("users")) throw new Error("Fresh migration did not create the users collection");
|
||||
migration.down(app);
|
||||
for (const name of [
|
||||
"users",
|
||||
"workshop_migration_state",
|
||||
"workshop_listings",
|
||||
"reports",
|
||||
"comments",
|
||||
"votes",
|
||||
"submissions",
|
||||
]) {
|
||||
if (collections.has(name)) throw new Error(`Fresh rollback left collection ${name}`);
|
||||
}
|
||||
|
||||
console.log("Workshop migration up/down restoration passed");
|
||||
@@ -77,6 +77,55 @@ await expectStatus(
|
||||
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
|
||||
404,
|
||||
);
|
||||
for (const [field, value] of [
|
||||
["displayName", "Homarr Team"],
|
||||
["avatarUrl", "https://example.invalid/forged-avatar.png"],
|
||||
["githubUsername", "homarr-labs"],
|
||||
["githubProfileUrl", "https://github.com/homarr-labs"],
|
||||
]) {
|
||||
await expectStatus(
|
||||
`/api/collections/users/records/${author.id}`,
|
||||
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ [field]: value }) },
|
||||
404,
|
||||
);
|
||||
}
|
||||
const forgedAvatar = new FormData();
|
||||
forgedAvatar.set(
|
||||
"avatar",
|
||||
new Blob(
|
||||
[
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
),
|
||||
],
|
||||
{ type: "image/png" },
|
||||
),
|
||||
"forged-avatar.png",
|
||||
);
|
||||
const avatarResponse = await fetch(`${baseUrl}/api/collections/users/records/${author.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: forgedAvatar,
|
||||
});
|
||||
const avatarResponseBody = await avatarResponse.json().catch(() => null);
|
||||
if (avatarResponse.status !== 400 || !JSON.stringify(avatarResponseBody).includes("managed by GitHub OAuth")) {
|
||||
throw new Error(
|
||||
`Workshop user avatar upload bypassed the OAuth identity hook: ${avatarResponse.status} ${JSON.stringify(avatarResponseBody)}`,
|
||||
);
|
||||
}
|
||||
const unchangedAuthor = await request(`/api/collections/users/records/${author.id}`, {
|
||||
headers: authorSession.headers,
|
||||
});
|
||||
if (
|
||||
unchangedAuthor.displayName !== "Widget Author" ||
|
||||
unchangedAuthor.avatar ||
|
||||
unchangedAuthor.avatarUrl ||
|
||||
unchangedAuthor.githubUsername ||
|
||||
unchangedAuthor.githubProfileUrl
|
||||
) {
|
||||
throw new Error("Workshop users can forge OAuth provider identity fields");
|
||||
}
|
||||
|
||||
const visitorPassword = "WorkshopVisitor123!";
|
||||
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
|
||||
@@ -108,17 +157,20 @@ const submission = await request("/api/collections/submissions/records", {
|
||||
widgetSchema: widget.$schema,
|
||||
content: JSON.stringify(widget),
|
||||
author: author.id,
|
||||
revision: 1,
|
||||
changelog: "Initial publication",
|
||||
outdated: false,
|
||||
revision: 999,
|
||||
changelog: "Forged publication metadata",
|
||||
outdated: true,
|
||||
}),
|
||||
});
|
||||
if (
|
||||
submission.author !== author.id ||
|
||||
submission.title !== "Workshop runtime probe" ||
|
||||
submission.widgetSchema !== widget.$schema
|
||||
submission.widgetSchema !== widget.$schema ||
|
||||
submission.revision !== 1 ||
|
||||
submission.changelog !== "" ||
|
||||
submission.outdated !== false
|
||||
) {
|
||||
throw new Error("Submission publication failed");
|
||||
throw new Error("Submission publication metadata was not initialized server-side");
|
||||
}
|
||||
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
|
||||
if (
|
||||
@@ -132,17 +184,33 @@ if (
|
||||
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ title: "Updated runtime probe" }),
|
||||
body: JSON.stringify({ title: "Updated runtime probe", expectedRevision: 1, revision: 500 }),
|
||||
});
|
||||
if (updatedSubmission.title !== "Updated runtime probe")
|
||||
if (updatedSubmission.title !== "Updated runtime probe" || updatedSubmission.revision !== 2)
|
||||
throw new Error("Authors must be able to edit their submission");
|
||||
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
|
||||
body: JSON.stringify({ outdated: true, expectedRevision: 2, revision: 900, changelog: "Needs a newer API" }),
|
||||
});
|
||||
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
|
||||
throw new Error("Authors must be able to mark submissions outdated and increment revisions");
|
||||
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 3) {
|
||||
throw new Error("PocketBase must own submission revision increments");
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const expectedRevision = 3 + attempt;
|
||||
const concurrentUpdates = await Promise.all(
|
||||
["Concurrent first", "Concurrent second"].map((title) =>
|
||||
fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({ title: `${title} ${attempt}`, expectedRevision }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
|
||||
throw new Error("Exactly one update may consume an expected submission revision");
|
||||
}
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
@@ -240,13 +308,13 @@ const report = await request("/api/collections/reports/records", {
|
||||
reporter: visitor.id,
|
||||
category: "other",
|
||||
explanation: "Runtime moderation test",
|
||||
status: "open",
|
||||
status: "dismissed",
|
||||
}),
|
||||
});
|
||||
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
|
||||
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
|
||||
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
|
||||
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop administrators");
|
||||
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop moderators");
|
||||
await expectStatus(
|
||||
`/api/collections/reports/records/${report.id}`,
|
||||
{ method: "DELETE", headers: visitorSession.headers },
|
||||
@@ -267,15 +335,46 @@ await expectStatus(
|
||||
);
|
||||
|
||||
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
|
||||
throw new Error("Workshop administrators must be able to review reports");
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
|
||||
throw new Error("Workshop moderators must be able to review reports");
|
||||
}
|
||||
await expectStatus(
|
||||
`/api/collections/reports/records/${report.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ category: "spam", explanation: "Moderator-authored replacement" }),
|
||||
},
|
||||
404,
|
||||
);
|
||||
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ status: "dismissed" }),
|
||||
});
|
||||
if (dismissedReport.status !== "dismissed") throw new Error("Workshop administrators must be able to dismiss reports");
|
||||
if (dismissedReport.status !== "dismissed") throw new Error("Workshop moderators must be able to dismiss reports");
|
||||
const reopenedReport = await request("/api/collections/reports/records", {
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: visitor.id,
|
||||
category: "malicious",
|
||||
explanation: "The later submission revision now needs another review",
|
||||
status: "dismissed",
|
||||
}),
|
||||
});
|
||||
if (reopenedReport.status !== "open" || reopenedReport.id === report.id) {
|
||||
throw new Error("A dismissed Workshop report must be replaceable with a fresh open report");
|
||||
}
|
||||
const reopenedReports = await request("/api/collections/reports/records", { headers: authorSession.headers });
|
||||
if (
|
||||
reopenedReports.items.length !== 1 ||
|
||||
reopenedReports.items[0].id !== reopenedReport.id ||
|
||||
reopenedReports.items[0].status !== "open"
|
||||
) {
|
||||
throw new Error("Re-reporting must replace only the reporter's dismissed report");
|
||||
}
|
||||
|
||||
const visitorSubmission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
@@ -291,6 +390,27 @@ const visitorSubmission = await request("/api/collections/submissions/records",
|
||||
outdated: false,
|
||||
}),
|
||||
});
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${visitorSubmission.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({
|
||||
title: "Moderator-authored replacement",
|
||||
content: JSON.stringify({ ...widget, name: "Moderator-authored replacement" }),
|
||||
expectedRevision: 1,
|
||||
}),
|
||||
},
|
||||
404,
|
||||
);
|
||||
const moderatedStatus = await request(`/api/collections/submissions/records/${visitorSubmission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ outdated: true, expectedRevision: 1 }),
|
||||
});
|
||||
if (!moderatedStatus.outdated || moderatedStatus.revision !== 2) {
|
||||
throw new Error("Workshop moderators must be limited to CAS-protected submission status changes");
|
||||
}
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${visitorSubmission.id}`,
|
||||
{ method: "DELETE", headers: authorSession.headers },
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import type { Browser } from "@playwright/test";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { startMockApiContainerAsync } from "./shared/mock-api-container";
|
||||
import type { MockApiServer } from "./shared/mock-api-server";
|
||||
import { startMockApiServerAsync } from "./shared/mock-api-server";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
@@ -13,18 +16,22 @@ const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>
|
||||
|
||||
describe("Custom JSX v2 workbench", () => {
|
||||
test("creates, previews, edits, and immediately adds a widget to a board", async () => {
|
||||
const mockApi = await startMockApiContainerAsync({ title: "E2E Widget", status: "online", value: 42 });
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await seedAdminUserAsync(db, adminCredentials);
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
let mockApi: MockApiServer | undefined;
|
||||
let homarrContainer: StartedTestContainer | undefined;
|
||||
let browser: Browser | undefined;
|
||||
|
||||
try {
|
||||
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await seedAdminUserAsync(db, adminCredentials);
|
||||
homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
@@ -51,8 +58,8 @@ describe("Custom JSX v2 workbench", () => {
|
||||
await request.getByRole("combobox", { name: "Option" }).first().click();
|
||||
await page.getByRole("option", { name: "label" }).click();
|
||||
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
|
||||
await expect(page.locator("#requests-editor-root")).toContainText('"$option": "label"');
|
||||
await page.locator("#jsx-editor-root").fill(initialTemplate);
|
||||
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
|
||||
await page.locator("#jsx-editor").fill(initialTemplate);
|
||||
|
||||
await page.getByRole("button", { name: "Test and preview" }).last().click();
|
||||
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
|
||||
@@ -63,7 +70,7 @@ describe("Custom JSX v2 workbench", () => {
|
||||
await expect(page.getByText('Widget "E2E Custom JSX v2" created successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
|
||||
|
||||
await page.locator("#jsx-editor-root").fill(updatedTemplate);
|
||||
await page.locator("#jsx-editor").fill(updatedTemplate);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).last().click();
|
||||
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -84,9 +91,7 @@ describe("Custom JSX v2 workbench", () => {
|
||||
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await mockApi.stop();
|
||||
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
|
||||
export interface MockApiContainer {
|
||||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startMockApiContainerAsync(responseBody: unknown): Promise<MockApiContainer> {
|
||||
const body = JSON.stringify(responseBody);
|
||||
const script = [
|
||||
'const { createServer } = require("node:http");',
|
||||
`const body = ${JSON.stringify(body)};`,
|
||||
'createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(8080, "0.0.0.0");',
|
||||
].join("\n");
|
||||
const container: StartedTestContainer = await new GenericContainer("node:24-alpine")
|
||||
.withCommand(["node", "-e", script])
|
||||
.withExposedPorts(8080)
|
||||
.withWaitStrategy(Wait.forHttp("/status", 8080))
|
||||
.start();
|
||||
|
||||
return {
|
||||
url: `http://${container.getIpAddress("bridge")}:8080`,
|
||||
stop: async () => void (await container.stop()),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createServer } from "node:http";
|
||||
import { getContainerRuntimeClient, TestContainers } from "testcontainers";
|
||||
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
|
||||
|
||||
export interface MockApiServer {
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startMockApiServerAsync(responseBody: unknown): Promise<MockApiServer> {
|
||||
const body = JSON.stringify(responseBody);
|
||||
const server: Server = await new Promise((resolve) => {
|
||||
const candidate = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(body);
|
||||
});
|
||||
candidate.listen(0, "0.0.0.0", () => resolve(candidate));
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("Failed to start mock API server");
|
||||
}
|
||||
|
||||
try {
|
||||
await TestContainers.exposeHostPorts(address.port);
|
||||
const client = await getContainerRuntimeClient();
|
||||
const forwarder = (await client.container.list()).find(
|
||||
(container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
|
||||
);
|
||||
const forwarderAddress = forwarder
|
||||
? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
|
||||
: undefined;
|
||||
if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
|
||||
|
||||
return {
|
||||
url: `http://${forwarderAddress}:${address.port}`,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
server.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -8,6 +8,7 @@
|
||||
"clean:workspaces": "turbo clean",
|
||||
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
|
||||
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
|
||||
"check:openapi": "tsx ./scripts/check-openapi-schema.mts",
|
||||
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
|
||||
"ci:act:docker": "bash scripts/ci/act.sh docker",
|
||||
@@ -35,7 +36,7 @@
|
||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project dom --coverage.enabled",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project custom-widgets-node --project db-node --project dom --coverage.enabled",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e",
|
||||
"test:workshop": "sh apps/workshop/test.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
|
||||
@@ -4,3 +4,4 @@ export {
|
||||
parseStoredCustomWidgetDefinition,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "./router/custom-widget/stored-definition";
|
||||
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
|
||||
@@ -1,16 +1,34 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
import { normalizeHttpUrl } from "@homarr/workshop/schema";
|
||||
|
||||
const publicHttpUrl = (variableName: string) =>
|
||||
z.string().transform((value, context) => {
|
||||
try {
|
||||
return normalizeHttpUrl(value, variableName);
|
||||
} catch (error) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: error instanceof Error ? error.message : `${variableName} is invalid`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
|
||||
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
|
||||
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
|
||||
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
|
||||
},
|
||||
});
|
||||
@@ -61,6 +61,10 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import {
|
||||
throwIfCustomWidgetBoardDuplicationForbidden,
|
||||
throwIfCustomWidgetPlacementChangeForbidden,
|
||||
} from "./board/custom-widget-placement-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
@@ -372,6 +376,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
|
||||
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
|
||||
|
||||
const newBoardId = createId();
|
||||
|
||||
@@ -855,6 +860,11 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: input.items,
|
||||
storedItems: dbBoard.items,
|
||||
});
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
@@ -1440,6 +1450,11 @@ export const boardRouter = createTRPCRouter({
|
||||
.output(z.object({ itemId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
|
||||
storedItems: [],
|
||||
});
|
||||
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
|
||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user