mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
Merge branch 'develop'
This commit is contained in:
commit
149c6a24e7
283 changed files with 38340 additions and 3017 deletions
83
.github/workflows/backend-tests.yml
vendored
83
.github/workflows/backend-tests.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
# PRs: test on latest Node only. Push to develop: full matrix.
|
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
|
node: ${{ fromJSON('[24]') }}
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
|
|
@ -66,7 +66,24 @@ jobs:
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
-
|
-
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
run: pnpm test
|
env:
|
||||||
|
# --report-on-fatalerror and friends write a Node diagnostic report
|
||||||
|
# (V8 stack, libuv handles, OS info) on fatal errors that bypass JS
|
||||||
|
# handlers — the failure mode we've been chasing on Windows + Node
|
||||||
|
# 24 since PR #7663. Reports land in node-report/ and are uploaded
|
||||||
|
# as an artifact if the step fails.
|
||||||
|
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ github.workspace }}/node-report"
|
||||||
|
pnpm test
|
||||||
|
- name: Upload Node diagnostic reports on failure
|
||||||
|
if: ${{ failure() }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||||
|
path: node-report/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
@ -84,7 +101,7 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
|
node: ${{ fromJSON('[24]') }}
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
|
|
@ -136,7 +153,19 @@ jobs:
|
||||||
ep_table_of_contents
|
ep_table_of_contents
|
||||||
-
|
-
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
run: pnpm test
|
env:
|
||||||
|
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ github.workspace }}/node-report"
|
||||||
|
pnpm test
|
||||||
|
- name: Upload Node diagnostic reports on failure
|
||||||
|
if: ${{ failure() }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||||
|
path: node-report/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
@ -150,7 +179,8 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node: [22, 24, 25]
|
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||||
|
node: ${{ fromJSON('[24]') }}
|
||||||
name: Windows without plugins
|
name: Windows without plugins
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -186,8 +216,25 @@ jobs:
|
||||||
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
|
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
|
||||||
-
|
-
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
|
shell: bash
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm test
|
env:
|
||||||
|
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ github.workspace }}/node-report"
|
||||||
|
# --exit forces process.exit(failures) after the suite completes,
|
||||||
|
# closing the post-suite event-loop drain window where Windows +
|
||||||
|
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
|
||||||
|
# runs still surface real handle leaks via natural drain.
|
||||||
|
pnpm test -- --exit
|
||||||
|
- name: Upload Node diagnostic reports on failure
|
||||||
|
if: ${{ failure() }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||||
|
path: node-report/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
@ -200,7 +247,8 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node: [22, 24, 25]
|
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||||
|
node: ${{ fromJSON('[24]') }}
|
||||||
name: Windows with Plugins
|
name: Windows with Plugins
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
|
@ -265,8 +313,25 @@ jobs:
|
||||||
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
|
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
|
||||||
-
|
-
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
|
shell: bash
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm test
|
env:
|
||||||
|
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ github.workspace }}/node-report"
|
||||||
|
# --exit forces process.exit(failures) after the suite completes,
|
||||||
|
# closing the post-suite event-loop drain window where Windows +
|
||||||
|
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
|
||||||
|
# runs still surface real handle leaks via natural drain.
|
||||||
|
pnpm test -- --exit
|
||||||
|
- name: Upload Node diagnostic reports on failure
|
||||||
|
if: ${{ failure() }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||||
|
path: node-report/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
|
||||||
5
.github/workflows/build-and-deploy-docs.yml
vendored
5
.github/workflows/build-and-deploy-docs.yml
vendored
|
|
@ -56,12 +56,11 @@ jobs:
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
# Pin Node so the build does not silently fall back to whatever the
|
# Pin Node so the build does not silently fall back to whatever the
|
||||||
# runner image ships with. vite 8 requires Node ^20.19.0 || >=22.12.0;
|
# runner image ships with. The repo declares engines.node >=24.0.0.
|
||||||
# the repo declares engines.node >=22.12.0 to match.
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
|
||||||
79
.github/workflows/deb-package.yml
vendored
79
.github/workflows/deb-package.yml
vendored
|
|
@ -128,7 +128,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
# Ubuntu's default apt nodejs is 18 — too old for our
|
# Ubuntu's default apt nodejs is 18 — too old for our
|
||||||
# `Depends: nodejs (>= 22)`. Add NodeSource's apt repo
|
# `Depends: nodejs (>= 24)`. Add NodeSource's apt repo
|
||||||
# explicitly (key + sources.list) instead of `curl | sudo bash`
|
# explicitly (key + sources.list) instead of `curl | sudo bash`
|
||||||
# so we don't execute network-fetched code as root.
|
# so we don't execute network-fetched code as root.
|
||||||
NODE_MAJOR=24
|
NODE_MAJOR=24
|
||||||
|
|
@ -137,7 +137,7 @@ jobs:
|
||||||
# existing nodesource entries so the only Node candidate apt sees
|
# existing nodesource entries so the only Node candidate apt sees
|
||||||
# is our node_24.x repo. Otherwise `apt-get install -y nodejs`
|
# is our node_24.x repo. Otherwise `apt-get install -y nodejs`
|
||||||
# picks the higher-version 20.x build that's already cached and
|
# picks the higher-version 20.x build that's already cached and
|
||||||
# `dpkg -i` then fails on `Depends: nodejs (>= 22)`.
|
# `dpkg -i` then fails on `Depends: nodejs (>= 24)`.
|
||||||
sudo rm -f /etc/apt/sources.list.d/nodesource.list \
|
sudo rm -f /etc/apt/sources.list.d/nodesource.list \
|
||||||
/etc/apt/sources.list.d/nodesource.sources \
|
/etc/apt/sources.list.d/nodesource.sources \
|
||||||
/etc/apt/preferences.d/nodesource \
|
/etc/apt/preferences.d/nodesource \
|
||||||
|
|
@ -172,10 +172,14 @@ jobs:
|
||||||
sudo test -L /opt/etherpad/settings.json
|
sudo test -L /opt/etherpad/settings.json
|
||||||
sudo test -L /opt/etherpad/var
|
sudo test -L /opt/etherpad/var
|
||||||
[ "$(sudo readlink /opt/etherpad/var)" = "/var/lib/etherpad/var" ]
|
[ "$(sudo readlink /opt/etherpad/var)" = "/var/lib/etherpad/var" ]
|
||||||
sudo test -L /opt/etherpad/src/plugin_packages
|
# plugin_packages must stay in-tree -- Node.js resolves symlinks
|
||||||
[ "$(sudo readlink /opt/etherpad/src/plugin_packages)" = "/var/lib/etherpad/plugin_packages" ]
|
# to realpath before walking node_modules, so symlinking it
|
||||||
sudo test -d /var/lib/etherpad/plugin_packages
|
# outside /opt broke require("ep_etherpad-lite/...") in
|
||||||
[ "$(sudo stat -c '%U' /var/lib/etherpad/plugin_packages)" = "etherpad" ]
|
# admin-installed plugins (ether/ep_comments_page#416).
|
||||||
|
sudo test -d /opt/etherpad/src/plugin_packages
|
||||||
|
sudo test ! -L /opt/etherpad/src/plugin_packages
|
||||||
|
[ "$(sudo stat -c '%G' /opt/etherpad/src/plugin_packages)" = "etherpad" ]
|
||||||
|
[ "$(sudo stat -c '%a' /opt/etherpad/src/plugin_packages)" = "2775" ]
|
||||||
[ "$(stat -c '%G' /opt/etherpad/src/node_modules)" = "etherpad" ]
|
[ "$(stat -c '%G' /opt/etherpad/src/node_modules)" = "etherpad" ]
|
||||||
sudo test -f /var/lib/etherpad/var/installed_plugins.json
|
sudo test -f /var/lib/etherpad/var/installed_plugins.json
|
||||||
sudo grep -q '"ep_etherpad-lite"' /var/lib/etherpad/var/installed_plugins.json
|
sudo grep -q '"ep_etherpad-lite"' /var/lib/etherpad/var/installed_plugins.json
|
||||||
|
|
@ -197,8 +201,71 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sudo systemctl stop etherpad
|
sudo systemctl stop etherpad
|
||||||
|
# Regression: simulate a pre-fix install (plugin_packages as a
|
||||||
|
# symlink to /var/lib/etherpad/plugin_packages, with a marker
|
||||||
|
# plugin inside) and re-run the postinst. The new postinst must
|
||||||
|
# migrate the contents back in-tree and drop the symlink so
|
||||||
|
# admin-installed plugins keep resolving ep_etherpad-lite
|
||||||
|
# (ether/ep_comments_page#416).
|
||||||
|
sudo rm -rf /opt/etherpad/src/plugin_packages
|
||||||
|
sudo mkdir -p /var/lib/etherpad/plugin_packages/.versions/ep_migration_marker
|
||||||
|
echo '{"name":"ep_migration_marker"}' | \
|
||||||
|
sudo tee /var/lib/etherpad/plugin_packages/.versions/ep_migration_marker/package.json >/dev/null
|
||||||
|
sudo chown -R etherpad:etherpad /var/lib/etherpad/plugin_packages
|
||||||
|
sudo ln -sfn /var/lib/etherpad/plugin_packages /opt/etherpad/src/plugin_packages
|
||||||
|
sudo dpkg-reconfigure etherpad
|
||||||
|
sudo test -d /opt/etherpad/src/plugin_packages
|
||||||
|
sudo test ! -L /opt/etherpad/src/plugin_packages
|
||||||
|
sudo test -f /opt/etherpad/src/plugin_packages/.versions/ep_migration_marker/package.json
|
||||||
|
[ "$(sudo stat -c '%a' /opt/etherpad/src/plugin_packages)" = "2775" ]
|
||||||
|
|
||||||
|
# Regression: stage the ep_layout_trip_wire test fixture into
|
||||||
|
# plugin_packages and confirm etherpad loads it. The fixture's
|
||||||
|
# index.js does the require('ep_etherpad-lite/...') calls that
|
||||||
|
# broke under the old symlinked layout (#416). If the layout
|
||||||
|
# ever regresses, the marker line never reaches the journal
|
||||||
|
# and this step fails.
|
||||||
|
PLUGIN_DIR=/opt/etherpad/src/plugin_packages
|
||||||
|
FIXTURE_DIR=$GITHUB_WORKSPACE/packaging/test-fixtures/ep_layout_trip_wire
|
||||||
|
sudo install -d -o etherpad -g etherpad -m 2775 "${PLUGIN_DIR}/.versions"
|
||||||
|
sudo cp -a "${FIXTURE_DIR}" "${PLUGIN_DIR}/.versions/ep_layout_trip_wire@1.0.0"
|
||||||
|
sudo ln -sfn .versions/ep_layout_trip_wire@1.0.0 "${PLUGIN_DIR}/ep_layout_trip_wire"
|
||||||
|
sudo ln -sfn ../plugin_packages/ep_layout_trip_wire \
|
||||||
|
/opt/etherpad/src/node_modules/ep_layout_trip_wire
|
||||||
|
sudo chown -R etherpad:etherpad "${PLUGIN_DIR}/.versions/ep_layout_trip_wire@1.0.0" \
|
||||||
|
"${PLUGIN_DIR}/ep_layout_trip_wire" \
|
||||||
|
/opt/etherpad/src/node_modules/ep_layout_trip_wire
|
||||||
|
echo '{"plugins":[{"name":"ep_etherpad-lite","version":"0.0.0"},{"name":"ep_layout_trip_wire","version":"1.0.0"}]}' \
|
||||||
|
| sudo tee /var/lib/etherpad/var/installed_plugins.json >/dev/null
|
||||||
|
sudo chown etherpad:etherpad /var/lib/etherpad/var/installed_plugins.json
|
||||||
|
sudo systemctl start etherpad
|
||||||
|
ok=
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS http://127.0.0.1:9001/health; then ok=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -z "${ok}" ]; then
|
||||||
|
sudo journalctl -u etherpad --no-pager -n 300 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Marker proves the require('ep_etherpad-lite/...') calls in
|
||||||
|
# the fixture's index.js all resolved.
|
||||||
|
sudo journalctl -u etherpad --no-pager -n 500 \
|
||||||
|
| grep -F 'ep_layout_trip_wire: plugin_packages layout OK'
|
||||||
|
# And no MODULE_NOT_FOUND involving ep_etherpad-lite, anywhere.
|
||||||
|
if sudo journalctl -u etherpad --no-pager -n 500 \
|
||||||
|
| grep -E "Cannot find module '?ep_etherpad-lite"; then
|
||||||
|
echo "::error::ep_etherpad-lite require failed inside an installed plugin"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo systemctl stop etherpad
|
||||||
|
|
||||||
sudo dpkg --purge etherpad
|
sudo dpkg --purge etherpad
|
||||||
! id etherpad 2>/dev/null
|
! id etherpad 2>/dev/null
|
||||||
|
# Purge must clean up runtime-created plugin artifacts that
|
||||||
|
# dpkg didn't ship (ether/ep_comments_page#416, Qodo #3).
|
||||||
|
sudo test ! -e /opt/etherpad/src/plugin_packages
|
||||||
|
sudo test ! -e /var/lib/etherpad
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|
|
||||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -17,4 +17,4 @@ jobs:
|
||||||
- name: 'Checkout Repository'
|
- name: 'Checkout Repository'
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: 'Dependency Review'
|
- name: 'Dependency Review'
|
||||||
uses: actions/dependency-review-action@v4
|
uses: actions/dependency-review-action@v5
|
||||||
|
|
|
||||||
87
.github/workflows/docker.yml
vendored
87
.github/workflows/docker.yml
vendored
|
|
@ -59,7 +59,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: etherpad/pnpm-lock.yaml
|
cache-dependency-path: etherpad/pnpm-lock.yaml
|
||||||
-
|
-
|
||||||
|
|
@ -79,7 +79,88 @@ jobs:
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
(cd src && pnpm run test-container)
|
(cd src && pnpm run test-container)
|
||||||
|
docker rm -f test
|
||||||
git clean -dxf .
|
git clean -dxf .
|
||||||
|
-
|
||||||
|
# Regression test for #7718. Reproduces the production
|
||||||
|
# docker-compose layout reported in that issue: a named volume
|
||||||
|
# mounted on src/plugin_packages with no TTY allocated. Under
|
||||||
|
# the previous `CMD ["pnpm", "run", "prod"]`, pnpm 11's
|
||||||
|
# runDepsStatusCheck spuriously decided node_modules was out of
|
||||||
|
# sync at boot and tried to wipe + reinstall, aborting with
|
||||||
|
# ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY before the HTTP
|
||||||
|
# server ever came up. If the CMD is ever reverted to go
|
||||||
|
# through `pnpm run`, this step will time out waiting for the
|
||||||
|
# health endpoint and fail.
|
||||||
|
name: Regression — boot with named volume on plugin_packages (#7718)
|
||||||
|
working-directory: etherpad
|
||||||
|
run: |
|
||||||
|
docker volume create ep7718-plugins
|
||||||
|
docker run --rm -d -p 9001:9001 \
|
||||||
|
-v ep7718-plugins:/opt/etherpad-lite/src/plugin_packages \
|
||||||
|
--name test-7718 ${{ env.TEST_TAG }}
|
||||||
|
docker logs -f test-7718 &
|
||||||
|
ok=0
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
status=$(docker container inspect -f '{{.State.Health.Status}}' test-7718 2>/dev/null) || {
|
||||||
|
echo "container exited prematurely — likely #7718 regression"
|
||||||
|
docker logs test-7718 || true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case ${status} in
|
||||||
|
healthy) ok=1; echo "container healthy after $((i*2))s"; break;;
|
||||||
|
starting) sleep 2;;
|
||||||
|
*) echo "unexpected status: ${status}"; docker logs test-7718; break;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
docker rm -f test-7718 >/dev/null 2>&1 || true
|
||||||
|
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
|
||||||
|
[ "$ok" = "1" ] || exit 1
|
||||||
|
|
||||||
|
build-test-local-plugin:
|
||||||
|
# Regression coverage for #7687: the Docker image's
|
||||||
|
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
|
||||||
|
# invokes pnpm via the corepack shim. A previous corepack/cache bug
|
||||||
|
# made that path fail when ETHERPAD_LOCAL_PLUGINS was set. This job
|
||||||
|
# builds the development target with a stub local plugin so the
|
||||||
|
# regression cannot silently come back.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Check out
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
path: etherpad
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
-
|
||||||
|
name: Stub a local plugin
|
||||||
|
run: |
|
||||||
|
mkdir -p etherpad/local_plugins/ep_test_corepack
|
||||||
|
cat > etherpad/local_plugins/ep_test_corepack/package.json <<'EOF'
|
||||||
|
{
|
||||||
|
"name": "ep_test_corepack",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "regression-test stub for ether/etherpad#7687",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
cat > etherpad/local_plugins/ep_test_corepack/index.js <<'EOF'
|
||||||
|
exports.placeholder = true;
|
||||||
|
EOF
|
||||||
|
-
|
||||||
|
name: Build with ETHERPAD_LOCAL_PLUGINS (must succeed)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: ./etherpad
|
||||||
|
target: development
|
||||||
|
load: false
|
||||||
|
build-args: |
|
||||||
|
ETHERPAD_LOCAL_PLUGINS=ep_test_corepack
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
build-test-db-drivers:
|
build-test-db-drivers:
|
||||||
# Spinning up MySQL + Postgres + cross-driver smoke is expensive; only
|
# Spinning up MySQL + Postgres + cross-driver smoke is expensive; only
|
||||||
|
|
@ -284,13 +365,13 @@ jobs:
|
||||||
if: success() && github.ref == 'refs/heads/develop'
|
if: success() && github.ref == 'refs/heads/develop'
|
||||||
working-directory: ether-charts
|
working-directory: ether-charts
|
||||||
run: |
|
run: |
|
||||||
sed -i 's/tag: ".*"/tag: "${{ steps.build-docker.outputs.digest }}"/' values-dev.yaml
|
sed -i 's/tag: ".*"/tag: "${{ steps.build-docker.outputs.digest }}"/' charts/etherpad/values-dev.yaml
|
||||||
- name: Commit and push changes
|
- name: Commit and push changes
|
||||||
working-directory: ether-charts
|
working-directory: ether-charts
|
||||||
if: success() && github.ref == 'refs/heads/develop'
|
if: success() && github.ref == 'refs/heads/develop'
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name 'github-actions[bot]'
|
git config --global user.name 'github-actions[bot]'
|
||||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
git add values-dev.yaml
|
git add charts/etherpad/values-dev.yaml
|
||||||
git commit -m 'Update develop image tag'
|
git commit -m 'Update develop image tag'
|
||||||
git push
|
git push
|
||||||
|
|
|
||||||
4
.github/workflows/frontend-admin-tests.yml
vendored
4
.github/workflows/frontend-admin-tests.yml
vendored
|
|
@ -21,8 +21,8 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
# PRs: single Node version. Push: full matrix.
|
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
|
node: ${{ fromJSON('[24]') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
|
|
|
||||||
8
.github/workflows/frontend-tests.yml
vendored
8
.github/workflows/frontend-tests.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
-
|
-
|
||||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
|
|
@ -114,7 +114,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
@ -190,7 +190,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
@ -291,7 +291,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
|
||||||
2
.github/workflows/handleRelease.yml
vendored
2
.github/workflows/handleRelease.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
|
||||||
12
.github/workflows/installer-test.yml
vendored
12
.github/workflows/installer-test.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Pre-install pnpm (avoid sudo prompt in the installer)
|
- name: Pre-install pnpm (avoid sudo prompt in the installer)
|
||||||
run: npm install -g pnpm
|
run: npm install -g pnpm
|
||||||
|
|
@ -50,8 +50,8 @@ jobs:
|
||||||
- name: Run bin/installer.sh against this commit
|
- name: Run bin/installer.sh against this commit
|
||||||
env:
|
env:
|
||||||
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
|
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
|
||||||
ETHERPAD_REPO: ${{ github.server_url }}/${{ github.repository }}.git
|
ETHERPAD_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.clone_url || format('{0}/{1}.git', github.server_url, github.repository) }}
|
||||||
ETHERPAD_BRANCH: ${{ github.head_ref || github.ref_name }}
|
ETHERPAD_BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
|
||||||
NO_COLOR: "1"
|
NO_COLOR: "1"
|
||||||
run: sh ./bin/installer.sh
|
run: sh ./bin/installer.sh
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Pre-install pnpm
|
- name: Pre-install pnpm
|
||||||
run: npm install -g pnpm
|
run: npm install -g pnpm
|
||||||
|
|
@ -113,8 +113,8 @@ jobs:
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
|
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
|
||||||
ETHERPAD_REPO: ${{ github.server_url }}/${{ github.repository }}.git
|
ETHERPAD_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.clone_url || format('{0}/{1}.git', github.server_url, github.repository) }}
|
||||||
ETHERPAD_BRANCH: ${{ github.head_ref || github.ref_name }}
|
ETHERPAD_BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
|
||||||
NO_COLOR: "1"
|
NO_COLOR: "1"
|
||||||
run: ./bin/installer.ps1
|
run: ./bin/installer.ps1
|
||||||
|
|
||||||
|
|
|
||||||
6
.github/workflows/load-test.yml
vendored
6
.github/workflows/load-test.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
-
|
-
|
||||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
|
|
@ -77,7 +77,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
-
|
-
|
||||||
name: Install etherpad-load-test
|
name: Install etherpad-load-test
|
||||||
|
|
@ -140,7 +140,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
-
|
-
|
||||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
|
|
|
||||||
2
.github/workflows/perform-type-check.yml
vendored
2
.github/workflows/perform-type-check.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
- name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
|
||||||
29
.github/workflows/rate-limit.yml
vendored
29
.github/workflows/rate-limit.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
-
|
-
|
||||||
|
|
@ -58,11 +58,36 @@ jobs:
|
||||||
name: run docker images
|
name: run docker images
|
||||||
run: |
|
run: |
|
||||||
docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim &
|
docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim &
|
||||||
docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest
|
docker run --name nginx-docker -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest
|
||||||
docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip
|
docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip
|
||||||
-
|
-
|
||||||
name: install dependencies and create symlink for ep_etherpad-lite
|
name: install dependencies and create symlink for ep_etherpad-lite
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
-
|
||||||
|
# The etherpad container is started in the background above; without
|
||||||
|
# this wait the test step can hit nginx before etherpad is listening
|
||||||
|
# and nginx returns 502, failing the run on a cold cache. Poll the
|
||||||
|
# nginx-proxied endpoint (which is also what the test hits) until it
|
||||||
|
# stops returning 5xx.
|
||||||
|
name: Wait for etherpad behind nginx to be ready
|
||||||
|
run: |
|
||||||
|
# ~60s budget: 30 iterations × (1s curl timeout + 1s sleep).
|
||||||
|
# Cold-start of the etherpad container is well under that.
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS -o /dev/null --max-time 1 http://127.0.0.1:8081/; then
|
||||||
|
echo "etherpad is ready behind nginx"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "ERROR: etherpad behind nginx did not become ready in time"
|
||||||
|
echo "--- docker ps ---"
|
||||||
|
docker ps -a || true
|
||||||
|
echo "--- nginx-docker logs ---"
|
||||||
|
docker logs nginx-docker || true
|
||||||
|
echo "--- etherpad-docker logs ---"
|
||||||
|
docker logs etherpad-docker || true
|
||||||
|
exit 1
|
||||||
-
|
-
|
||||||
name: run rate limit test
|
name: run rate limit test
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -64,7 +64,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: etherpad/pnpm-lock.yaml
|
cache-dependency-path: etherpad/pnpm-lock.yaml
|
||||||
- name: Install dependencies ether.github.com
|
- name: Install dependencies ether.github.com
|
||||||
|
|
|
||||||
5
.github/workflows/releaseEtherpad.yml
vendored
5
.github/workflows/releaseEtherpad.yml
vendored
|
|
@ -17,9 +17,8 @@ jobs:
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
||||||
# Node >= 22.9.0. setup-node's `22` resolves to the latest
|
# Node >= 22.9.0. Node 24 satisfies that and matches the rest of CI.
|
||||||
# 22.x, which satisfies that.
|
node-version: 24
|
||||||
node-version: 22
|
|
||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
||||||
run: npm install -g npm@latest
|
run: npm install -g npm@latest
|
||||||
|
|
|
||||||
2
.github/workflows/update-plugins.yml
vendored
2
.github/workflows/update-plugins.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install bin dependencies
|
- name: Install bin dependencies
|
||||||
working-directory: ./bin
|
working-directory: ./bin
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,17 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
# PRs: single Node version. Push: full matrix.
|
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||||
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
|
node: ${{ fromJSON('[24]') }}
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out latest release
|
name: Check out latest release
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: develop #FIXME change to master when doing release
|
fetch-depth: 0
|
||||||
|
-
|
||||||
|
name: Check out latest release tag
|
||||||
|
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v5
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
|
|
@ -84,13 +87,10 @@ jobs:
|
||||||
-
|
-
|
||||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
# Because actions/checkout@v6 is called with "ref: master" and without
|
# The job starts from the latest release tag, so fetch the pull-request
|
||||||
# "fetch-depth: 0", the local clone does not have the ${GITHUB_SHA}
|
# ref explicitly before checking out ${GITHUB_SHA}. A plain "git fetch"
|
||||||
# commit. Fetch ${GITHUB_REF} to get the ${GITHUB_SHA} commit. Note that a
|
# only brings "normal" references (refs/heads/* and refs/tags/*), and for
|
||||||
# plain "git fetch" only fetches "normal" references (refs/heads/* and
|
# pull requests none of those include the synthetic merge commit.
|
||||||
# refs/tags/*), and for pull requests none of the normal references
|
|
||||||
# include ${GITHUB_SHA}, so we have to explicitly tell Git to fetch
|
|
||||||
# ${GITHUB_REF}.
|
|
||||||
-
|
-
|
||||||
name: Fetch the new Git commits
|
name: Fetch the new Git commits
|
||||||
run: git fetch --depth=1 origin "${GITHUB_REF}"
|
run: git fetch --depth=1 origin "${GITHUB_REF}"
|
||||||
|
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -38,3 +38,11 @@ stage/
|
||||||
prime/
|
prime/
|
||||||
.craft/
|
.craft/
|
||||||
*.snap
|
*.snap
|
||||||
|
|
||||||
|
# Generated by `pnpm --filter admin gen:api` from src/node/hooks/express/openapi.ts.
|
||||||
|
# Regenerated by build/test/dev scripts; not committed.
|
||||||
|
/admin/src/api/schema.d.ts
|
||||||
|
/admin/src/api/version.ts
|
||||||
|
|
||||||
|
# Local git worktrees used by /release-review and similar workflows.
|
||||||
|
/.worktrees/
|
||||||
|
|
|
||||||
19
AGENTS.MD
19
AGENTS.MD
|
|
@ -46,6 +46,25 @@ pnpm --filter ep_etherpad-lite run prod # Start production server
|
||||||
- **Comments:** Provide clear comments for complex logic only.
|
- **Comments:** Provide clear comments for complex logic only.
|
||||||
- **Backward Compatibility:** Always ensure compatibility with older versions of the database and configuration files.
|
- **Backward Compatibility:** Always ensure compatibility with older versions of the database and configuration files.
|
||||||
|
|
||||||
|
### Internationalisation (i18n) — Mandatory
|
||||||
|
- **Every user-facing string MUST go through i18n.** That means JSX text, `placeholder`, `title`, `aria-label`, `alt`, toast/alert titles, `<option>` labels, error messages, breadcrumbs, empty-state copy — anything a user can see or hear via a screen reader.
|
||||||
|
- **React (admin SPA):** use `<Trans i18nKey="…"/>` for JSX text and `t('…')` for attribute values. The `t` comes from `useTranslation()`.
|
||||||
|
- **Pad UI (legacy):** use `data-l10n-id="…"` (html10n) — never bind `window._` directly to `html10n.get` and call it (it's unbound; returns `undefined`).
|
||||||
|
- **String keys live in `src/locales/en.json`.** Other locales sync from translatewiki on its own cadence — never hand-edit non-EN locale files.
|
||||||
|
- **Reuse existing keys before inventing new ones.** Check `src/locales/en.json` and the relevant plugin's `static/locale/en.json` (e.g. `admin/public/ep_admin_pads/en.json`) for an existing match. Duplicating a key like `ep_admin_pads:ep_adminpads2_last-edited` as a fresh `admin_pads.col.last_edited` fragments the translation surface for translatewiki.
|
||||||
|
- **Naming:** dot-namespaced, kebab-or-underscore-cased: `admin_plugins.subtitle`, `admin_pads.filter.all`. Group by page/feature.
|
||||||
|
- **Pluralisation:** use i18next's `_one`/`_other` suffix forms with `t('key', {count: n})` — never `n > 1 ? 'X items' : '1 item'`.
|
||||||
|
- **Locale-aware formatters:** pass a sanitised locale to `Intl.*` / `toLocaleString`. `i18n.language` is influenced by `?lng=` (user-controlled) and a malformed tag throws `RangeError`. Use a `sanitizeLocale()` helper that normalises `_`→`-` and validates via `Intl.DateTimeFormat.supportedLocalesOf()`, falling back to `'en'`.
|
||||||
|
- **`defaultValue:` in `t()` is for safety, not a substitute** for adding the key to `en.json`. If you're tempted to inline English with `defaultValue:` and skip the key, you're shipping a future-broken translation.
|
||||||
|
- **Hard prohibition:** literal German / French / Spanish / any non-English in JSX. The denylist test at `src/tests/backend-new/specs/admin-i18n-source-lint.test.ts` enforces this for the admin SPA — extend it when adding new admin files or new known-bad words.
|
||||||
|
|
||||||
|
### Accessibility (a11y) — Mandatory
|
||||||
|
- **Icon-only buttons MUST have `aria-label` AND `title`** (both — screen readers prefer `aria-label`; hover users get `title`). Lucide icons inside a button are not text content. Both labels must be `t('…')`-localised.
|
||||||
|
- **Sort controls are focusable.** A `<select>` plus a paired direction toggle is fine; a clickable column header is fine; "click invisible part of the row to sort" is not. When restyling, never strip a direction toggle without adding back an equivalent.
|
||||||
|
- **Semantic HTML over `<div>` soup.** Use `<nav>`, `<main>`, `<button>`, `<a>` (for navigation), `<table>` for tabular data. If a thing navigates externally, it is `<a target="_blank" rel="noopener noreferrer">`, not a click-handler on a `<span>`.
|
||||||
|
- **Don't drop existing affordances when restyling.** A UI refresh that removes `<a href="https://npmjs.com/{plugin}">` links, removes a sort-direction control, or replaces semantic elements with non-focusable divs is a regression even if it looks nicer. Audit the before/after for: focus order, keyboard reachability, external links, aria-labels, alt text.
|
||||||
|
- **Tests assert rendered strings + structural affordances.** For UI changes, the Playwright spec must assert at least one rendered translated string (catches broken i18n loading) and one structural affordance you added/preserved (links, toggles, headings).
|
||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
- **Branching:** Work in feature branches. Issue PRs against the `develop` branch. Never PR directly to `master`.
|
- **Branching:** Work in feature branches. Issue PRs against the `develop` branch. Never PR directly to `master`.
|
||||||
- **Commits:** Maintain a linear history (no merge commits). Use meaningful messages in the format: `submodule: description`.
|
- **Commits:** Maintain a linear history (no merge commits). Use meaningful messages in the format: `submodule: description`.
|
||||||
|
|
|
||||||
92
CHANGELOG.md
92
CHANGELOG.md
|
|
@ -1,3 +1,92 @@
|
||||||
|
# 3.0.0
|
||||||
|
|
||||||
|
3.0 is a feature-heavy release that closes out the self-update programme (Tiers 2 and 3 land alongside Tier 1 from 2.7.3), removes the last identified upstream telemetry vector, and ships a parsed JSONC settings editor, native DOCX export, in-place pad history scrubbing, and an admin UI for GDPR author erasure. It also marks the start of the broader Etherpad app ecosystem (see *Companion apps* below).
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
- **Minimum required Node.js version is now 24.** Node.js 22 is no longer supported. Node 25 was briefly the floor mid-cycle but was rolled back to **24 LTS (Krypton, supported through ~May 2028)** because Node 25 reached end-of-life on 2026-04-10 (see #7779 / #7781). The CI matrix targets Node 24 and 26. Node 24 still ships Corepack, so existing `bin/installer.sh` / `bin/installer.ps1` flows continue to work unchanged; the global `pnpm` install fallback added for the Node 25 detour is kept for forward-compatibility.
|
||||||
|
- **`pnpm` floor raised to `pnpm@11.1.2`.** `packageManager` is now pinned to `pnpm@11.1.2` and `engines.pnpm` requires `>=11.1.2`. The Dockerfile, snap, .deb and all GitHub workflows are aligned.
|
||||||
|
- **`swagger-ui-express` removed.** `/api-docs` now serves a vendored, telemetry-free copy of [Scalar](https://github.com/scalar/scalar) (see the privacy item below). The route, the OpenAPI document, and the rendered output are unchanged for downstream consumers, but anything that introspected `swagger-ui-express` internals will need updating.
|
||||||
|
- **Debian package depends on `nodejs (>= 24)`.** The signed apt repository at `etherpad.org/apt` is rebuilt against this floor; older Node packages are no longer acceptable as a dependency (#7754).
|
||||||
|
|
||||||
|
### Companion apps
|
||||||
|
|
||||||
|
This release coincides with the launch of two ecosystem projects, both maintained under the [`ether` org](https://github.com/ether) and able to talk to any 3.x Etherpad server over its existing HTTP / WebSocket API:
|
||||||
|
|
||||||
|
- **[`ether/etherpad-desktop`](https://github.com/ether/etherpad-desktop)** — a native desktop wrapper around Etherpad for macOS, Windows and Linux. Single-window editor experience, system-tray indicator, and an optional embedded server for fully offline pads.
|
||||||
|
- **[`ether/pad`](https://github.com/ether/pad)** — a portable cross-target client: an Android and iOS app for editing pads on the go, and a `nano`-style terminal client for headless / SSH workflows. Shares the same realtime client transport as the browser editor so changes propagate live across desktop, mobile, terminal and the web UI.
|
||||||
|
|
||||||
|
Both clients hit the **stable 3.x API surface**, so server operators don't need to enable anything extra to support them — the OpenAPI clean-up landed in this release (see *Notable enhancements*) is what makes the shared client code generators viable.
|
||||||
|
|
||||||
|
### Notable enhancements
|
||||||
|
|
||||||
|
- **Self-update subsystem — Tier 2 (manual click).**
|
||||||
|
- Admins on a git install can click "Apply update" at `/admin/update`. Etherpad runs a 60s session drain (with T-60 / T-30 / T-10 broadcasts to every pad), `git fetch / checkout / pnpm install --frozen-lockfile / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. The next boot runs a 60s health check; if `/health` doesn't come up the previous SHA + lockfile are restored automatically.
|
||||||
|
- Crash-loop guard: if the new version reboots more than twice without the health check completing, RollbackHandler forces a rollback regardless of the timer.
|
||||||
|
- Terminal `rollback-failed` state surfaces a strong banner; the admin clicks Acknowledge once they've manually recovered to clear the lock and re-allow Tier 2 attempts.
|
||||||
|
- New settings under `updates.*`: `preApplyGraceMinutes`, `drainSeconds`, `rollbackHealthCheckSeconds`, `diskSpaceMinMB`, `requireSignature`, `trustedKeysPath`. Tag signature verification is opt-in (default `false`) — see `doc/admin/updates.md` for the keyring setup.
|
||||||
|
- **A process supervisor (systemd / pm2 / docker `--restart=unless-stopped`) is required to apply updates.** Without one, exit 75 leaves the instance down.
|
||||||
|
- **Self-update subsystem — Tier 3 (auto with grace window).**
|
||||||
|
- On a git install, set `updates.tier: "auto"` to have new releases applied automatically after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons. Schedules are persisted to `var/update-state.json`, so an Etherpad restart during the grace window rehydrates the timer instead of losing the schedule. A new release tag detected mid-grace re-arms the timer; if `adminEmail` is set, a one-shot `grace-start` notification fires per scheduled tag (issue #7607).
|
||||||
|
- The terminal `rollback-failed` state continues to disable auto/autonomous attempts globally until acknowledged; manual click stays available because an admin click *is* the intervention the terminal state requires.
|
||||||
|
- Tier 4 (autonomous in a maintenance window) remains designed but unimplemented and will land in a subsequent release.
|
||||||
|
- **Privacy — drop swagger-ui telemetry, document phone-homes, add opt-outs.**
|
||||||
|
- Dropped `swagger-ui-express` because upstream injects a Scarf analytics pixel that cannot be disabled at install or runtime (see [swagger-api/swagger-ui#10573](https://github.com/swagger-api/swagger-ui/issues/10573)). `/api-docs` now serves a vendored copy of [Scalar](https://github.com/scalar/scalar) (MIT) configured with `withDefaultFonts: false` and `telemetry: false` so no outbound calls are made.
|
||||||
|
- New `privacy.updateCheck` (default `true`) — set to `false` to disable the hourly `UpdateCheck.ts` request to `${updateServer}/info.json`.
|
||||||
|
- New `privacy.pluginCatalog` (default `true`) — set to `false` to disable the admin plugins page fetch of `${updateServer}/plugins.json`. CLI install-by-name still works.
|
||||||
|
- New [`PRIVACY.md`](PRIVACY.md) at repo root documenting both outbound calls, what they send, and how to turn each off.
|
||||||
|
- `bin/plugins/stalePlugins.ts` now reads `settings.updateServer` (was hardcoded to `static.etherpad.org`) and honours the new flag.
|
||||||
|
- Closes #7524.
|
||||||
|
- **Parsed JSONC settings editor in `/admin`.** The settings page now parses `settings.json` as JSONC (with comments and trailing commas preserved), validates edits in-browser, and writes the file back without clobbering comment blocks (#7709, closes #7603, takes over #7666).
|
||||||
|
- **GDPR — admin UI for author erasure.** Builds on the 2.7.3 author-erasure API: admins can now find an author by id or name in `/admin` and run a confirmed erasure flow from the UI (#7667, follow-up to #7550).
|
||||||
|
- **Pad-wide settings on by default.** `padOptions`-style settings can now be edited from the in-pad cog without flipping a flag, and the modal title no longer misleads about scope (#7679). Plugin-namespaced `ep_*` keys also flow through `applyPadSettings` so plugins can register their own pad-wide options (#7698).
|
||||||
|
- **Scrub history in-place on the pad URL.** A long-edited pad can now have its history rewritten in place (e.g. for compliance or to drop accidentally-pasted secrets), without changing the pad URL or breaking deep-links (#7710, closes #7659).
|
||||||
|
- **`bin/compactStalePads` — staleness-gated bulk compaction.** Companion to the 2.7.3 `compactAllPads` CLI: targets only pads not edited in the last `--older-than N` days, so hot pads in active timeslider use are left alone. Same `--keep` / `--dry-run` shape as `compactAllPads` (#7708, issue #7642).
|
||||||
|
- **Native DOCX export (opt-in).** A `html-to-docx`-based exporter lands as an alternative to the LibreOffice path, so installs that don't want `soffice` on the host can still produce `.docx`. `soffice` is now documented as optional for `.docx` and `.pdf` (#7568 / #7707, issue #7538).
|
||||||
|
- **Editor / UI.**
|
||||||
|
- Settings popup is now scrollable on short viewports so the lower controls stay reachable on small laptops (#7703, issue #7696).
|
||||||
|
- Admin design pass cleans up the rework introduced in 2.7.3 (#7716).
|
||||||
|
- `theme-color` meta now follows the client-side dark-mode switch instead of locking to the boot-time value (#7690, issue #7606).
|
||||||
|
- `menu_right` stays visible on readonly pads by default; operators that prefer the slimmer chrome can still opt in via `showMenuRight` (#7783).
|
||||||
|
- Social meta: new `settings.socialMeta.description` override (#7691) plus a fix for numeric / boolean override values that were silently being dropped during coercion (#7692).
|
||||||
|
- **Admin / API surface.**
|
||||||
|
- The published OpenAPI spec is cleaned up for downstream codegens — duplicate operationIds removed, response schemas filled in, `nullable` ⟶ `oneOf null` migrated for OpenAPI 3.1 (#7714). The companion apps above consume this directly.
|
||||||
|
- Admin endpoints (`/admin/*` JSON APIs) are now documented in the OpenAPI spec (#7693 / #7705) and called from a typesafe TanStack Query client in the admin SPA (#7638 / #7695).
|
||||||
|
- "Requires newer Etherpad" message in the plugin browser when an `ep.json` declares an `engines.etherpad` higher than the running version, instead of failing with a generic install error (#7763 / #7771).
|
||||||
|
- **Security hardening.**
|
||||||
|
- Reject `USER_CHANGES` inserts that arrive without an author attribute, closing a server-side trust gap where unattributed changes could be applied to a pad (#7773).
|
||||||
|
- Integrator-issued `sessionID` cookies can now be marked `HttpOnly` via the new option, matching the 2.7.3 author-token hardening (#7045 / #7755).
|
||||||
|
- **Observability — Prometheus counters.** Three new counters surface scaling-relevant events (`pad_load_total`, `socket_connect_total`, `changeset_apply_total`) so operators can drive horizontal-scaling decisions off the existing `/metrics` endpoint without a custom exporter (#7756 / #7762).
|
||||||
|
- **Accessibility (continuation of the 2.7.2 / 2.7.3 pass).**
|
||||||
|
- Skip-to-content link plus hiding line-number gutters from screen readers (#7255 / #7758).
|
||||||
|
- Named `role="toolbar"` regions and `linemetricsdiv` hidden from assistive tech (#7255 / #7777).
|
||||||
|
- Localized `aria-label` on form controls (`<select>`, `<input>`, `<textarea>`) and on export-as links (#7697 / #7713).
|
||||||
|
- Removed `role="textbox"` / `aria-multiline` from `innerdocbody` where they no longer matched the editor's real semantics (#7778 / #7782).
|
||||||
|
|
||||||
|
### Notable fixes
|
||||||
|
|
||||||
|
- **Docker — pnpm at runtime.** Bypass `pnpm` at container start so the entrypoint no longer triggers a spurious `deps-status` reinstall on every restart (#7718 / #7727). The Corepack cache is now shared so the unprivileged `etherpad` user can resolve `pnpm` (#7689).
|
||||||
|
- **Debian — `plugin_packages` stays in-tree.** The `.deb` now keeps `plugin_packages/` under the install root so plugins installed at runtime can still resolve `ep_etherpad-lite` (#7750).
|
||||||
|
- **Admin — restore search and sort.** `SearchField` and the column-sort helpers used by the authors page were lost during the admin rework; they're restored (#7746).
|
||||||
|
- **Admin — German strings hardcoded in error paths.** A handful of leftover German strings from the rework are replaced with i18n keys (#7735 / #7736).
|
||||||
|
- **Settings — `username: false` / `malformed color: false` regression.** Legacy `settings.json` files that used `false` to disable a feature no longer surface as `'false'` username or `'malformed color: false'` errors (#7688, issue #7686).
|
||||||
|
|
||||||
|
### Internal / contributor-facing
|
||||||
|
|
||||||
|
- **Database driver — `ueberdb2` 5 → 6.** Major-version bump to `ueberdb2@^6.0.3` (#7734). Drivers are pinned through the lockfile; the schema-level changes are documented in the `ueberdb2` 6.0 release notes.
|
||||||
|
- **CI / tests.**
|
||||||
|
- Windows + Node 24 backend-test flake fixed; native crashes are now captured for diagnosis (#7748).
|
||||||
|
- `updater-integration` rmdir-retry to clear the long-standing Windows `EBUSY` flake (#7728).
|
||||||
|
- `lowerCasePadIds` spec closes its socket.io clients on teardown (#7722).
|
||||||
|
- Admin tests realigned to the typesafe API client + plugin row count fixes (#7712).
|
||||||
|
- Rate-limit test waits for Etherpad readiness before running, instead of racing the boot sequence (#7726).
|
||||||
|
- README link fixes and tidy-up (#7723 / #7724 / #7725).
|
||||||
|
- Several dependency-group bumps across the dev and runtime trees: `undici` 7.25 → 8.3, `semver` 7.7.4 → 7.8, `tsx` 4.21 → 4.22, `mssql` 12.5.2 → 12.5.3, `js-cookie` 3.0.5 → 3.0.6, `@tanstack/react-query` 5.100.9 → 5.100.10, `actions/dependency-review-action` 4 → 5, plus the usual Dependabot dev-group rollups.
|
||||||
|
|
||||||
|
### Localisation
|
||||||
|
|
||||||
|
- Multiple updates from translatewiki.net.
|
||||||
|
|
||||||
# 2.7.3
|
# 2.7.3
|
||||||
|
|
||||||
### Breaking changes
|
### Breaking changes
|
||||||
|
|
@ -21,6 +110,7 @@
|
||||||
- Tier 1 ships in this release. Tiers 2 (manual click), 3 (auto with grace window) and 4 (autonomous in maintenance window) are designed and will land in subsequent releases.
|
- Tier 1 ships in this release. Tiers 2 (manual click), 3 (auto with grace window) and 4 (autonomous in maintenance window) are designed and will land in subsequent releases.
|
||||||
- See `doc/admin/updates.md` for full configuration.
|
- See `doc/admin/updates.md` for full configuration.
|
||||||
- **Pad compaction.** New `compactPad` HTTP API plus `bin/compactPad` and `bin/compactAllPads` CLIs to reclaim database space on long-lived pads with heavy edit history (issue #6194). `--keep N` retains the last N revisions; `--dry-run` previews per-pad rev counts before writing. Per-pad failures don't stop the bulk run.
|
- **Pad compaction.** New `compactPad` HTTP API plus `bin/compactPad` and `bin/compactAllPads` CLIs to reclaim database space on long-lived pads with heavy edit history (issue #6194). `--keep N` retains the last N revisions; `--dry-run` previews per-pad rev counts before writing. Per-pad failures don't stop the bulk run.
|
||||||
|
- `bin/compactStalePads` (issue #7642) targets only pads not edited in the last `--older-than N` days, so hot pads in active timeslider use are left alone. Same `--keep` / `--dry-run` shape as `bin/compactAllPads`. Targeting is deliberately a CLI concern — the `compactPad` API surface stays unchanged.
|
||||||
- **New packaging targets.**
|
- **New packaging targets.**
|
||||||
- Etherpad is now published as a **Snap** package.
|
- Etherpad is now published as a **Snap** package.
|
||||||
- **Debian (.deb)** packages are built via nfpm with a systemd unit, and a signed apt repository is published to `etherpad.org/apt`.
|
- **Debian (.deb)** packages are built via nfpm with a systemd unit, and a signed apt repository is published to `etherpad.org/apt`.
|
||||||
|
|
@ -44,7 +134,7 @@
|
||||||
|
|
||||||
- The HTTP client in the backend has been migrated from `axios` to the built-in `fetch` API, dropping a dependency now that Node 22 ships a stable fetch.
|
- The HTTP client in the backend has been migrated from `axios` to the built-in `fetch` API, dropping a dependency now that Node 22 ships a stable fetch.
|
||||||
- `admin/` and `ui/` workspaces moved from `rolldown-vite` to upstream **Vite 8**.
|
- `admin/` and `ui/` workspaces moved from `rolldown-vite` to upstream **Vite 8**.
|
||||||
- Build and CI moved to **pnpm 11** (`packageManager: "pnpm@11.0.6"`); the `Dockerfile`, snap, and all GitHub workflows are aligned. pnpm overrides have been migrated from `package.json` to `pnpm-workspace.yaml` to match pnpm 11's expectations.
|
- Build and CI moved to **pnpm 11** (`packageManager: "pnpm@11.1.2"`); the `Dockerfile`, snap, and all GitHub workflows are aligned. pnpm overrides have been migrated from `package.json` to `pnpm-workspace.yaml` to match pnpm 11's expectations.
|
||||||
- All client modules have been converted to ESM.
|
- All client modules have been converted to ESM.
|
||||||
- The CI matrix tests Node 22, 24, and 25; on PRs the matrix is reduced to a single Node version to keep feedback fast.
|
- The CI matrix tests Node 22, 24, and 25; on PRs the matrix is reduced to a single Node version to keep feedback fast.
|
||||||
- Frontend Playwright tests now run against the `/ether` plugin set, with feature-tag based skips so plugin-incompatible specs are excluded automatically.
|
- Frontend Playwright tests now run against the `/ether` plugin set, with feature-tag based skips so plugin-incompatible specs are excluded automatically.
|
||||||
|
|
|
||||||
41
Dockerfile
41
Dockerfile
|
|
@ -9,14 +9,15 @@ ARG BUILD_ENV=git
|
||||||
|
|
||||||
ARG PnpmVersion=11.0.6
|
ARG PnpmVersion=11.0.6
|
||||||
|
|
||||||
FROM node:22-alpine AS adminbuild
|
FROM node:24-alpine AS adminbuild
|
||||||
# Use corepack to provision pnpm and drop the bundled npm — its older
|
# Install pnpm directly via npm (rather than via corepack) so the same
|
||||||
# transitives (picomatch, brace-expansion) carry CVEs we don't otherwise
|
# image recipe keeps working on Node 25+, where corepack has been
|
||||||
# need. Refresh corepack first: the version bundled with Node 22 ships a
|
# dropped from the distribution. The node:24-alpine image also bundles
|
||||||
# stale signing-key list and rejects newer pnpm releases
|
# yarn; remove it first to avoid leaving an unused binary on PATH.
|
||||||
# (nodejs/corepack#612). Mirrors the workaround in snap/snapcraft.yaml.
|
# Drop bundled npm afterwards — its older transitives (picomatch,
|
||||||
RUN npm install -g corepack@latest && \
|
# brace-expansion) carry CVEs we don't otherwise need.
|
||||||
corepack enable && corepack prepare pnpm@${PnpmVersion} --activate && \
|
RUN rm -f /usr/local/bin/yarn /usr/local/bin/yarnpkg && \
|
||||||
|
npm install -g pnpm@${PnpmVersion} && \
|
||||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
WORKDIR /opt/etherpad-lite
|
WORKDIR /opt/etherpad-lite
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
@ -24,7 +25,7 @@ RUN pnpm install
|
||||||
RUN pnpm run build:ui
|
RUN pnpm run build:ui
|
||||||
|
|
||||||
|
|
||||||
FROM node:22-alpine AS build
|
FROM node:24-alpine AS build
|
||||||
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
|
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
|
||||||
|
|
||||||
# Set these arguments when building the image from behind a proxy
|
# Set these arguments when building the image from behind a proxy
|
||||||
|
|
@ -99,12 +100,17 @@ RUN groupadd --system ${EP_GID:+--gid "${EP_GID}" --non-unique} etherpad && \
|
||||||
ARG EP_DIR=/opt/etherpad-lite
|
ARG EP_DIR=/opt/etherpad-lite
|
||||||
RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
|
RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
|
||||||
|
|
||||||
|
# Install pnpm directly via npm (rather than via corepack) so the same
|
||||||
|
# recipe stays valid on Node 25+, which dropped corepack. Then drop
|
||||||
|
# both npm and the pre-bundled yarn binary to keep the runtime image
|
||||||
|
# free of unused tooling and known-CVE transitives.
|
||||||
|
#
|
||||||
# the mkdir is needed for configuration of openjdk-11-jre-headless, see
|
# the mkdir is needed for configuration of openjdk-11-jre-headless, see
|
||||||
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
|
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
|
||||||
RUN \
|
RUN \
|
||||||
mkdir -p /usr/share/man/man1 && \
|
mkdir -p /usr/share/man/man1 && \
|
||||||
npm install -g corepack@latest && \
|
rm -f /usr/local/bin/yarn /usr/local/bin/yarnpkg && \
|
||||||
corepack enable && corepack prepare pnpm@${PnpmVersion} --activate && \
|
npm install -g pnpm@${PnpmVersion} && \
|
||||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
||||||
apk update && apk upgrade && \
|
apk update && apk upgrade && \
|
||||||
apk add --no-cache \
|
apk add --no-cache \
|
||||||
|
|
@ -205,4 +211,15 @@ HEALTHCHECK --interval=5s --timeout=3s \
|
||||||
CMD wget -qO- http://127.0.0.1:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1
|
CMD wget -qO- http://127.0.0.1:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1
|
||||||
|
|
||||||
EXPOSE 9001
|
EXPOSE 9001
|
||||||
CMD ["pnpm", "run", "prod"]
|
# Run node directly instead of via `pnpm run prod`. pnpm 11's
|
||||||
|
# `runDepsStatusCheck` fires before every `pnpm run …` and spuriously
|
||||||
|
# decides node_modules is out of sync on first start under the named-
|
||||||
|
# volume layout used by docker-compose (mounting src/plugin_packages).
|
||||||
|
# It then tries to `pnpm install --production`, which either prompts to
|
||||||
|
# wipe node_modules (tty: true) or aborts with
|
||||||
|
# ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY (no tty). Bypassing pnpm
|
||||||
|
# at runtime sidesteps the check; the image's node_modules was already
|
||||||
|
# verified during build. See ether/etherpad#7718.
|
||||||
|
# `exec` makes node PID 1 so it receives SIGTERM directly and shuts down
|
||||||
|
# cleanly.
|
||||||
|
CMD ["sh", "-c", "cd src && exec node --require tsx/cjs node/server.ts"]
|
||||||
|
|
|
||||||
68
PRIVACY.md
Normal file
68
PRIVACY.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Privacy in Etherpad
|
||||||
|
|
||||||
|
## What this document is
|
||||||
|
|
||||||
|
A complete, current list of every network call Etherpad's own code makes
|
||||||
|
to a third party, plus how to turn each one off. Plugins are out of
|
||||||
|
scope — audit any plugin you install.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Etherpad ships with two outbound calls to `etherpad.org`. Both are
|
||||||
|
documented below. Both can be disabled with a single config value each.
|
||||||
|
No analytics, no usage pings, no third-party SDKs at runtime.
|
||||||
|
|
||||||
|
## Outbound calls
|
||||||
|
|
||||||
|
### 1. Version check
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| URL | `https://static.etherpad.org/info.json` (override via `updateServer`) |
|
||||||
|
| Frequency | hourly while the server runs |
|
||||||
|
| Payload | GET only; `User-Agent: Etherpad/<version>` |
|
||||||
|
| Purpose | surface an "update available" notice in the admin panel |
|
||||||
|
| Disable | set `privacy.updateCheck: false` in `settings.json` |
|
||||||
|
| Source | `src/node/utils/UpdateCheck.ts` |
|
||||||
|
|
||||||
|
### 2. Plugin catalog
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| URL | `https://static.etherpad.org/plugins.json` (override via `updateServer`) |
|
||||||
|
| Frequency | on admin-plugins page load (cached 10 min) |
|
||||||
|
| Payload | GET only; same `User-Agent` |
|
||||||
|
| Purpose | list installable `ep_*` plugins in the admin UI |
|
||||||
|
| Disable | set `privacy.pluginCatalog: false` in `settings.json` (manual install via CLI still works) |
|
||||||
|
| Source | `src/static/js/pluginfw/installer.ts` |
|
||||||
|
|
||||||
|
## What we removed
|
||||||
|
|
||||||
|
`swagger-ui-express` was dropped because the upstream npm package
|
||||||
|
injects a Scarf analytics pixel that cannot be disabled at install or
|
||||||
|
runtime (see [swagger-api/swagger-ui#10573](https://github.com/swagger-api/swagger-ui/issues/10573)).
|
||||||
|
`/api-docs` is now served by a vendored copy of [Scalar](https://github.com/scalar/scalar)
|
||||||
|
(MIT) with no outbound calls. The shell explicitly opts out of Scalar's
|
||||||
|
default font fetch (`withDefaultFonts: false`) and analytics
|
||||||
|
(`telemetry: false`), and pins a system-font stack via CSS.
|
||||||
|
|
||||||
|
`@scarf/scarf` is listed under `ignoredBuiltDependencies` in
|
||||||
|
`pnpm-workspace.yaml`, so its postinstall pixel is suppressed even if a
|
||||||
|
future transitive dep pulls Scarf in.
|
||||||
|
|
||||||
|
## What we will not add
|
||||||
|
|
||||||
|
- usage analytics or telemetry SDKs
|
||||||
|
- crash reporters that send data without explicit opt-in
|
||||||
|
- third-party CDN dependencies at runtime
|
||||||
|
- dependencies whose install or runtime phones home
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
Third-party plugins are out of this guarantee. Plugins run in your
|
||||||
|
Etherpad process with full access; audit any plugin you install.
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
Found an outbound call this doc doesn't list? Open an issue with the
|
||||||
|
label `privacy`.
|
||||||
18
README.md
18
README.md
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
Every keystroke is attributed to its author. Every revision is preserved. The timeslider lets you scrub through a document's entire history, character by character. Author colours make collaboration visible at a glance — not buried in a menu.
|
Every keystroke is attributed to its author. Every revision is preserved. The timeslider lets you scrub through a document's entire history, character by character. Author colours make collaboration visible at a glance — not buried in a menu.
|
||||||
|
|
||||||
Etherpad runs on your server, under your governance. No telemetry. No upsells. AI is a plugin you install, pointed at the model you choose, running on infrastructure you control — not a feature decided for you in a boardroom you weren't in.
|
Etherpad runs on your server, under your governance. No telemetry. No upsells. AI is a plugin you install, pointed at the model you choose, running on infrastructure you control — not a feature decided for you in a boardroom you weren't in. See [PRIVACY.md](PRIVACY.md) for the two opt-out network calls Etherpad's own code makes and how to disable each.
|
||||||
|
|
||||||
The code is Apache 2.0. The data format is open. It [scales to thousands of simultaneous editors per pad](http://scale.etherpad.org/). Translated into 105 languages. Extended through hundreds of plugins. Used by Wikimedia, governments, public-sector institutions, and self-hosters worldwide since 2009.
|
The code is Apache 2.0. The data format is open. It [scales to thousands of simultaneous editors per pad](http://scale.etherpad.org/). Translated into 105 languages. Extended through hundreds of plugins. Used by Wikimedia, governments, public-sector institutions, and self-hosters worldwide since 2009.
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ The code is Apache 2.0. The data format is open. It [scales to thousands of simu
|
||||||
|
|
||||||
## Try it out
|
## Try it out
|
||||||
|
|
||||||
[Try out a public Etherpad instance](https://github.com/ether/etherpad/wiki/Sites-That-Run-Etherpad#sites-that-run-etherpad)
|
[Try out a public Etherpad instance](https://scanner.etherpad.org)
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
|
|
@ -36,16 +36,14 @@ Etherpad has been doing the same thing — well — since 2009. No pivots, no ac
|
||||||
[](https://github.com/ether/etherpad/actions/workflows/load-test.yml)
|
[](https://github.com/ether/etherpad/actions/workflows/load-test.yml)
|
||||||
[](https://github.com/ether/etherpad/actions/workflows/rate-limit.yml)
|
[](https://github.com/ether/etherpad/actions/workflows/rate-limit.yml)
|
||||||
[](https://github.com/ether/etherpad/actions/workflows/docker.yml)
|
[](https://github.com/ether/etherpad/actions/workflows/docker.yml)
|
||||||
[](https://github.com/ether/etherpad/actions/workflows/frontend-admin-tests.yml)
|
[](https://github.com/ether/etherpad/actions/workflows/frontend-admin-tests.yml)
|
||||||
[](https://github.com/ether/etherpad/actions/workflows/frontend-tests.yml)
|
[](https://github.com/ether/etherpad/actions/workflows/frontend-tests.yml)
|
||||||
[](https://saucelabs.com/u/etherpad)
|
|
||||||
[](https://github.com/ether/etherpad/actions/workflows/windows.yml)
|
|
||||||
|
|
||||||
### Engagement
|
### Engagement
|
||||||
|
|
||||||
[](https://hub.docker.com/r/etherpad/etherpad)
|
[](https://hub.docker.com/r/etherpad/etherpad)
|
||||||
[](https://discord.com/invite/daEjfhw)
|
[](https://discord.com/invite/daEjfhw)
|
||||||
[](https://static.etherpad.org/index.html)
|
[](https://etherpad.org/plugins)
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
@ -60,13 +58,13 @@ For more than a decade, Etherpad has quietly underpinned the documents that matt
|
||||||
- **Newsrooms and investigative journalism teams** — where authorship and editing history matter for legal and editorial integrity.
|
- **Newsrooms and investigative journalism teams** — where authorship and editing history matter for legal and editorial integrity.
|
||||||
- **Tens of thousands of self-hosted instances** worldwide, run by IT teams who chose Etherpad because it is theirs.
|
- **Tens of thousands of self-hosted instances** worldwide, run by IT teams who chose Etherpad because it is theirs.
|
||||||
|
|
||||||
If your organisation runs Etherpad and would be willing to be listed publicly, please [add it to the wiki](https://github.com/ether/etherpad/wiki/Sites-That-Run-Etherpad).
|
[Public Etherpad Instances for you to try out. Third party instances not provided by the Etherpad foundation](https://scanner.etherpad.org/).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Quick install (one-liner)
|
### Quick install (one-liner)
|
||||||
|
|
||||||
The fastest way to get Etherpad running. Requires `git` and Node.js >= 22.
|
The fastest way to get Etherpad running. Requires `git` and Node.js >= 24.
|
||||||
|
|
||||||
**macOS / Linux / WSL:**
|
**macOS / Linux / WSL:**
|
||||||
|
|
||||||
|
|
@ -160,7 +158,7 @@ volumes:
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
[Node.js](https://nodejs.org/) >= 22.12.
|
[Node.js](https://nodejs.org/) >= 24.
|
||||||
|
|
||||||
### Windows, macOS, Linux
|
### Windows, macOS, Linux
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,66 @@
|
||||||
# React + TypeScript + Vite
|
# Admin UI
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Vite + React 19 single-page app served at `/admin`. Talks to the backend over
|
||||||
|
socket.io for the existing settings / plugins / pads pages, and (when
|
||||||
|
endpoints are added to the OpenAPI spec) over a typed REST client.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Scripts
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
| Script | What it does |
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| -------------------- | -------------------------------------------------------- |
|
||||||
|
| `pnpm dev` | `gen:api` + Vite dev server (expects backend on :9001). |
|
||||||
|
| `pnpm gen:api` | Regenerates `src/api/{schema.d.ts,version.ts}` from the OpenAPI spec. |
|
||||||
|
| `pnpm build` | `gen:api` + `tsc` + `vite build`. |
|
||||||
|
| `pnpm build-copy` | Same, but writes into `../src/templates/admin`. |
|
||||||
|
| `pnpm test` | `gen:api` + smoke tests for the API client wiring. |
|
||||||
|
| `pnpm lint` | ESLint. |
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## Typed API client
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
The admin uses [`openapi-typescript`] to generate types from
|
||||||
|
`src/node/hooks/express/openapi.ts`, [`openapi-fetch`] for typed requests, and
|
||||||
|
[`openapi-react-query`] for TanStack Query bindings.
|
||||||
|
|
||||||
- Configure the top-level `parserOptions` property like this:
|
[`openapi-typescript`]: https://github.com/openapi-ts/openapi-typescript
|
||||||
|
[`openapi-fetch`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch
|
||||||
|
[`openapi-react-query`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-react-query
|
||||||
|
|
||||||
```js
|
### Generated files
|
||||||
export default {
|
|
||||||
// other rules...
|
`admin/src/api/schema.d.ts` and `admin/src/api/version.ts` are generated by
|
||||||
parserOptions: {
|
`gen:api` and gitignored — never commit them. They are produced by:
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
```sh
|
||||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
pnpm --filter admin gen:api
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
`admin/scripts/gen-api.mjs` loads `src/node/hooks/express/openapi.ts`, calls
|
||||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
`generateDefinitionForVersion` for the latest API version, pipes the JSON
|
||||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
through `openapi-typescript` to produce `schema.d.ts`, and emits a runtime
|
||||||
|
constant `LATEST_API_VERSION` (read from `info.version` in the spec) to
|
||||||
|
`version.ts` so `client.ts` can build the right `/api/<version>/` baseUrl.
|
||||||
|
|
||||||
|
`gen:api` runs as the first step of `dev`, `build`, `build-copy`, and
|
||||||
|
`test`, so a fresh checkout produces the generated files automatically when
|
||||||
|
any of those scripts is invoked. After modifying any of the following, the
|
||||||
|
next `pnpm <dev|build|test>` will refresh the generated files; you can also
|
||||||
|
run `gen:api` directly:
|
||||||
|
|
||||||
|
- `src/node/hooks/express/openapi.ts`
|
||||||
|
- `src/node/handler/APIHandler.ts` (changes to `latestApiVersion`)
|
||||||
|
- the resource definitions referenced by `openapi.ts`
|
||||||
|
|
||||||
|
### Using the client
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { $api } from './api/client';
|
||||||
|
|
||||||
|
const SettingsPanel = () => {
|
||||||
|
const { data } = $api.useQuery('get', '/admin/settings'); // example
|
||||||
|
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin endpoints are not yet present in the OpenAPI spec — this client is
|
||||||
|
in place to support upcoming work (see issue #7638 follow-up). For now, it is
|
||||||
|
exercised only by the smoke test.
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,52 @@
|
||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.7.3",
|
"version": "3.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm gen:api && vite",
|
||||||
"build": "tsc && vite build",
|
"dev:only": "vite",
|
||||||
|
"gen:api": "node scripts/gen-api.mjs",
|
||||||
|
"build": "pnpm gen:api && tsc && vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
|
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-switch": "^1.2.6"
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@tanstack/react-query": "^5.100.10",
|
||||||
|
"@tanstack/react-query-devtools": "^5.100.10",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
|
"openapi-react-query": "^0.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
"@typescript-eslint/parser": "^8.59.2",
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"i18next": "^26.0.9",
|
"i18next": "^26.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.5",
|
"openapi-typescript": "^7.13.0",
|
||||||
"react-dom": "^19.2.5",
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"react-i18next": "^17.0.6",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.1",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tsx": "^4.22.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.13",
|
||||||
"vite-plugin-babel": "^1.6.0",
|
"vite-plugin-babel": "^1.7.1",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
admin/public/ep_admin_authors/en.json
Normal file
31
admin/public/ep_admin_authors/en.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"title": "Authors",
|
||||||
|
"search-placeholder": "Search by name or mapper",
|
||||||
|
"column.color": "Color",
|
||||||
|
"column.name": "Name",
|
||||||
|
"column.mapper": "Mapper",
|
||||||
|
"column.last-seen": "Last seen",
|
||||||
|
"column.author-id": "Author ID",
|
||||||
|
"column.actions": "Actions",
|
||||||
|
"show-erased": "Show erased authors",
|
||||||
|
"erase": "Erase",
|
||||||
|
"erase-disabled-tooltip": "Author erasure is disabled. Set gdprAuthorErasure.enabled = true in settings.json.",
|
||||||
|
"erased-stub": "(erased)",
|
||||||
|
"cap-warning": "Showing the first 1000 authors. Narrow your search to see more.",
|
||||||
|
"feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.",
|
||||||
|
"no-results": "No authors match this search.",
|
||||||
|
"confirm-preview-title": "Erase author {{name}}",
|
||||||
|
"confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.",
|
||||||
|
"confirm-irreversible": "This cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"continue": "Continue",
|
||||||
|
"loading-preview": "Loading preview…",
|
||||||
|
"erasing": "Erasing…",
|
||||||
|
"erase-success-toast": "Author {{authorID}} erased.",
|
||||||
|
"erase-error-toast": "Erase failed: {{error}}",
|
||||||
|
"no-mappers": "—",
|
||||||
|
"never-seen": "—",
|
||||||
|
"prev-page": "Previous Page",
|
||||||
|
"next-page": "Next Page",
|
||||||
|
"page-counter": "{{current}} out of {{total}}"
|
||||||
|
}
|
||||||
74
admin/scripts/__tests__/merge-openapi.test.mjs
Normal file
74
admin/scripts/__tests__/merge-openapi.test.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import {test} from 'node:test';
|
||||||
|
import {strict as assert} from 'node:assert';
|
||||||
|
import {mergeOpenAPI} from '../merge-openapi.mjs';
|
||||||
|
|
||||||
|
const minimal = (overrides = {}) => ({
|
||||||
|
openapi: '3.0.2',
|
||||||
|
info: {title: 'X', version: '0.0.0'},
|
||||||
|
paths: {},
|
||||||
|
components: {schemas: {}, securitySchemes: {}},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unions paths from both docs', () => {
|
||||||
|
const pub = minimal({paths: {'/createGroup': {post: {operationId: 'createGroup'}}}});
|
||||||
|
const adm = minimal({paths: {'/admin-auth/': {post: {operationId: 'verifyAdminAccess'}}}});
|
||||||
|
const out = mergeOpenAPI(pub, adm);
|
||||||
|
assert.deepEqual(Object.keys(out.paths).sort(), ['/admin-auth/', '/createGroup']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on path collision', () => {
|
||||||
|
const pub = minimal({paths: {'/x': {get: {}}}});
|
||||||
|
const adm = minimal({paths: {'/x': {post: {}}}});
|
||||||
|
assert.throws(() => mergeOpenAPI(pub, adm), /path collision/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unions components.schemas', () => {
|
||||||
|
const pub = minimal({components: {schemas: {A: {}}, securitySchemes: {}}});
|
||||||
|
const adm = minimal({components: {schemas: {B: {}}, securitySchemes: {}}});
|
||||||
|
const out = mergeOpenAPI(pub, adm);
|
||||||
|
assert.deepEqual(Object.keys(out.components.schemas).sort(), ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on schema name collision', () => {
|
||||||
|
const pub = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}});
|
||||||
|
const adm = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}});
|
||||||
|
assert.throws(() => mergeOpenAPI(pub, adm), /schema collision/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unions securitySchemes', () => {
|
||||||
|
const pub = minimal({components: {schemas: {}, securitySchemes: {apiKey: {}}}});
|
||||||
|
const adm = minimal({components: {schemas: {}, securitySchemes: {basicAuth: {}}}});
|
||||||
|
const out = mergeOpenAPI(pub, adm);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(out.components.securitySchemes).sort(),
|
||||||
|
['apiKey', 'basicAuth'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves public root security; admin per-operation security survives', () => {
|
||||||
|
const pub = minimal({security: [{apiKey: []}]});
|
||||||
|
const adm = minimal({
|
||||||
|
paths: {
|
||||||
|
'/admin-auth/': {
|
||||||
|
post: {
|
||||||
|
security: [{basicAuth: []}, {}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = mergeOpenAPI(pub, adm);
|
||||||
|
assert.deepEqual(out.security, [{apiKey: []}]);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.paths['/admin-auth/'].post.security,
|
||||||
|
[{basicAuth: []}, {}],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public info wins on conflict', () => {
|
||||||
|
const pub = minimal({info: {title: 'Public', version: '1.0'}});
|
||||||
|
const adm = minimal({info: {title: 'Admin', version: '2.0'}});
|
||||||
|
const out = mergeOpenAPI(pub, adm);
|
||||||
|
assert.equal(out.info.title, 'Public');
|
||||||
|
assert.equal(out.info.version, '1.0');
|
||||||
|
});
|
||||||
57
admin/scripts/dump-spec.ts
Normal file
57
admin/scripts/dump-spec.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// admin/scripts/dump-spec.ts
|
||||||
|
//
|
||||||
|
// Imports the public + admin OpenAPI spec builders from the etherpad
|
||||||
|
// source, merges them into one document, and writes JSON to argv[2].
|
||||||
|
// Invoked by admin/scripts/gen-api.mjs via `tsx`.
|
||||||
|
//
|
||||||
|
// Why a file argument instead of stdout: importing openapi*.ts triggers
|
||||||
|
// Settings init, which configures log4js to write INFO/WARN lines to
|
||||||
|
// stdout. Capturing stdout would mix logs with JSON.
|
||||||
|
|
||||||
|
import {writeFileSync} from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {fileURLToPath, pathToFileURL} from 'node:url';
|
||||||
|
// @ts-expect-error — sibling .mjs has no .d.ts; tsx resolves it at runtime.
|
||||||
|
import {mergeOpenAPI} from './merge-openapi.mjs';
|
||||||
|
|
||||||
|
const outFile = process.argv[2];
|
||||||
|
if (!outFile) {
|
||||||
|
process.stderr.write('Usage: tsx scripts/dump-spec.ts <output-path>\n');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(here, '..', '..');
|
||||||
|
|
||||||
|
const apiHandlerPath = path.join(repoRoot, 'src', 'node', 'handler', 'APIHandler.ts');
|
||||||
|
const openapiPath = path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts');
|
||||||
|
const openapiAdminPath = path.join(
|
||||||
|
repoRoot, 'src', 'node', 'hooks', 'express', 'openapi-admin.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
type ApiHandlerModule = {latestApiVersion: string};
|
||||||
|
type OpenApiModule = {
|
||||||
|
generateDefinitionForVersion: (version: string, style?: string) => unknown;
|
||||||
|
APIPathStyle: {FLAT: string; REST: string};
|
||||||
|
};
|
||||||
|
type OpenApiAdminModule = {
|
||||||
|
generateAdminDefinition: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiHandlerMod = await import(pathToFileURL(apiHandlerPath).href);
|
||||||
|
const openapiMod = await import(pathToFileURL(openapiPath).href);
|
||||||
|
const openapiAdminMod = await import(pathToFileURL(openapiAdminPath).href);
|
||||||
|
|
||||||
|
const apiHandler = (apiHandlerMod.default ?? apiHandlerMod) as ApiHandlerModule;
|
||||||
|
const openapi = (openapiMod.default ?? openapiMod) as OpenApiModule;
|
||||||
|
const openapiAdmin = (openapiAdminMod.default ?? openapiAdminMod) as OpenApiAdminModule;
|
||||||
|
|
||||||
|
const publicSpec = openapi.generateDefinitionForVersion(
|
||||||
|
apiHandler.latestApiVersion,
|
||||||
|
openapi.APIPathStyle.FLAT,
|
||||||
|
);
|
||||||
|
const adminSpec = openapiAdmin.generateAdminDefinition();
|
||||||
|
|
||||||
|
const merged = mergeOpenAPI(publicSpec, adminSpec);
|
||||||
|
|
||||||
|
writeFileSync(path.resolve(outFile), JSON.stringify(merged, null, 2), 'utf8');
|
||||||
78
admin/scripts/gen-api.mjs
Normal file
78
admin/scripts/gen-api.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// admin/scripts/gen-api.mjs
|
||||||
|
//
|
||||||
|
// Regenerates admin/src/api/schema.d.ts from the live OpenAPI spec exported
|
||||||
|
// by src/node/hooks/express/openapi.ts. Run via `pnpm --filter admin gen:api`.
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const adminRoot = path.resolve(here, '..');
|
||||||
|
const outFile = path.join(adminRoot, 'src', 'api', 'schema.d.ts');
|
||||||
|
|
||||||
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'etherpad-openapi-'));
|
||||||
|
const specPath = path.join(tmpDir, 'spec.json');
|
||||||
|
|
||||||
|
// On Windows pnpm resolves to pnpm.cmd, which spawnSync can only find via a
|
||||||
|
// shell. Use shell on Windows only to avoid Node's DEP0190 warning elsewhere.
|
||||||
|
// Every argument here is fixed (no user input) so the shell:true variant is
|
||||||
|
// not an injection risk.
|
||||||
|
const spawnOpts = {
|
||||||
|
cwd: adminRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dump = spawnSync(
|
||||||
|
'pnpm',
|
||||||
|
['exec', 'tsx', 'scripts/dump-spec.ts', specPath],
|
||||||
|
spawnOpts,
|
||||||
|
);
|
||||||
|
if (dump.status !== 0) {
|
||||||
|
console.error(`dump-spec.ts failed with exit code ${dump.status}`);
|
||||||
|
process.exit(dump.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gen = spawnSync(
|
||||||
|
'pnpm',
|
||||||
|
['exec', 'openapi-typescript', specPath, '-o', outFile],
|
||||||
|
spawnOpts,
|
||||||
|
);
|
||||||
|
if (gen.status !== 0) {
|
||||||
|
console.error(`openapi-typescript failed with exit code ${gen.status}`);
|
||||||
|
process.exit(gen.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
`// GENERATED — do not edit. Run \`pnpm --filter admin gen:api\` to regenerate.\n` +
|
||||||
|
`// Source: src/node/hooks/express/openapi.ts (#7638)\n\n`;
|
||||||
|
const body = readFileSync(outFile, 'utf8');
|
||||||
|
writeFileSync(outFile, header + body, 'utf8');
|
||||||
|
|
||||||
|
// Emit a runtime-side version constant so client.ts can build the right
|
||||||
|
// baseUrl. Generated paths are unprefixed (e.g. "/createGroup"), but the
|
||||||
|
// backend mounts the FLAT-style spec under /api/<version>/.
|
||||||
|
const spec = JSON.parse(readFileSync(specPath, 'utf8'));
|
||||||
|
const apiVersion = spec?.info?.version;
|
||||||
|
if (typeof apiVersion !== 'string' || apiVersion.length === 0) {
|
||||||
|
console.error('OpenAPI spec is missing info.version; cannot emit version.ts');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const versionFile = path.join(adminRoot, 'src', 'api', 'version.ts');
|
||||||
|
writeFileSync(
|
||||||
|
versionFile,
|
||||||
|
header +
|
||||||
|
`export const LATEST_API_VERSION = ${JSON.stringify(apiVersion)};\n` +
|
||||||
|
`export const API_BASE_URL = \`/api/\${LATEST_API_VERSION}\`;\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Wrote ${path.relative(process.cwd(), outFile)}`);
|
||||||
|
console.log(`Wrote ${path.relative(process.cwd(), versionFile)}`);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
56
admin/scripts/merge-openapi.mjs
Normal file
56
admin/scripts/merge-openapi.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// admin/scripts/merge-openapi.mjs
|
||||||
|
//
|
||||||
|
// Deep-merges the public-API OpenAPI document with the admin OpenAPI
|
||||||
|
// document into a single document for openapi-typescript to consume.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - paths: union by key; collision throws
|
||||||
|
// - components.{schemas,parameters,responses,securitySchemes}: union by name; collision throws
|
||||||
|
// - root info, servers, security: public wins (admin's are ignored at the root)
|
||||||
|
// - per-operation security on admin paths is preserved untouched
|
||||||
|
|
||||||
|
const unionMap = (label, a = {}, b = {}) => {
|
||||||
|
const out = {...a};
|
||||||
|
for (const [k, v] of Object.entries(b)) {
|
||||||
|
if (k in out) {
|
||||||
|
throw new Error(`${label} on key "${k}"`);
|
||||||
|
}
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeOpenAPI = (publicDoc, adminDoc) => {
|
||||||
|
if (!publicDoc || !adminDoc) {
|
||||||
|
throw new Error('mergeOpenAPI requires both publicDoc and adminDoc');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
openapi: publicDoc.openapi || adminDoc.openapi,
|
||||||
|
info: publicDoc.info,
|
||||||
|
...(publicDoc.servers ? {servers: publicDoc.servers} : {}),
|
||||||
|
...(publicDoc.security ? {security: publicDoc.security} : {}),
|
||||||
|
paths: unionMap('path collision', publicDoc.paths, adminDoc.paths),
|
||||||
|
components: {
|
||||||
|
schemas: unionMap(
|
||||||
|
'schema collision',
|
||||||
|
publicDoc.components?.schemas,
|
||||||
|
adminDoc.components?.schemas,
|
||||||
|
),
|
||||||
|
parameters: unionMap(
|
||||||
|
'parameter collision',
|
||||||
|
publicDoc.components?.parameters,
|
||||||
|
adminDoc.components?.parameters,
|
||||||
|
),
|
||||||
|
responses: unionMap(
|
||||||
|
'response collision',
|
||||||
|
publicDoc.components?.responses,
|
||||||
|
adminDoc.components?.responses,
|
||||||
|
),
|
||||||
|
securitySchemes: unionMap(
|
||||||
|
'securityScheme collision',
|
||||||
|
publicDoc.components?.securitySchemes,
|
||||||
|
adminDoc.components?.securitySchemes,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
/* Raw textarea (kept dark to signal "this is code") */
|
||||||
|
textarea.settings {
|
||||||
|
font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid #333;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
textarea.settings:focus {
|
||||||
|
outline: 2px solid #007acc;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- mode toggle --- */
|
||||||
|
.settings-mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.settings-mode-toggle button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #555;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-mode-toggle button.active {
|
||||||
|
background: var(--etherpad-color, #0f775b);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- form (light, two-column) --- */
|
||||||
|
.settings-form {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.settings-section-header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.settings-section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.settings-section-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.settings-section-body {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-column row: label | control, with help below spanning column 2.
|
||||||
|
* Single-column on narrow widths. */
|
||||||
|
.settings-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||||
|
gap: 6px 18px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid #f4f4f4;
|
||||||
|
}
|
||||||
|
.settings-row:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.settings-row-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.settings-row-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-row-help {
|
||||||
|
grid-column: 2;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #666;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.settings-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.settings-row-help {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- nested subsections (objects/arrays inside a section) --- */
|
||||||
|
.settings-subsection {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 8px 18px;
|
||||||
|
border-left: 3px solid #e2e2e2;
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
.settings-subsection-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.settings-subsection-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #444;
|
||||||
|
font-size: 13.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.settings-subsection-help {
|
||||||
|
color: #777;
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.settings-subsection-body .settings-row {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- leaf widgets (light) --- */
|
||||||
|
.settings-widget-string,
|
||||||
|
.settings-widget-number {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
color: #222;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.settings-widget-string:focus,
|
||||||
|
.settings-widget-number:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--etherpad-color, #0f775b);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 119, 91, 0.15);
|
||||||
|
}
|
||||||
|
.settings-widget-number.invalid {
|
||||||
|
border-color: #ce5050;
|
||||||
|
}
|
||||||
|
.settings-widget-null {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.settings-widget-env {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #f4f8ff;
|
||||||
|
color: #335;
|
||||||
|
border: 1px dashed #88a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.settings-widget-env-icon {
|
||||||
|
font-style: normal;
|
||||||
|
color: #557;
|
||||||
|
}
|
||||||
|
.settings-widget-env-name {
|
||||||
|
font-family: "Fira Code", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.settings-widget-env-default-label {
|
||||||
|
color: #557;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.settings-widget-env-default-input {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ccd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-family: "Fira Code", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #804;
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 240px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.settings-widget-env-default-input:focus {
|
||||||
|
outline: 2px solid var(--accent, #2b8a3e);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radix switch (boolean) */
|
||||||
|
.settings-widget-boolean {
|
||||||
|
appearance: none;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ccc;
|
||||||
|
border: 0;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.settings-widget-boolean[data-state="checked"] {
|
||||||
|
background: var(--etherpad-color, #0f775b);
|
||||||
|
}
|
||||||
|
.settings-widget-boolean-thumb {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(2px);
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.settings-widget-boolean[data-state="checked"] .settings-widget-boolean-thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- parse error --- */
|
||||||
|
.settings-parse-error {
|
||||||
|
border: 1px solid #d99;
|
||||||
|
background: #fff5f5;
|
||||||
|
color: #842;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.settings-parse-error-detail {
|
||||||
|
margin: 8px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: "Fira Code", monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.settings-parse-error button {
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--etherpad-color, #0f775b);
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
@ -6,45 +6,37 @@ import {NavLink, Outlet, useNavigate} from "react-router-dom";
|
||||||
import {useStore} from "./store/store.ts";
|
import {useStore} from "./store/store.ts";
|
||||||
import {LoadingScreen} from "./utils/LoadingScreen.tsx";
|
import {LoadingScreen} from "./utils/LoadingScreen.tsx";
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell} from "lucide-react";
|
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react";
|
||||||
import {UpdateBanner} from "./components/UpdateBanner";
|
import {UpdateBanner} from "./components/UpdateBanner";
|
||||||
|
|
||||||
const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : ''
|
const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : ''
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const setSettings = useStore(state => state.setSettings);
|
const setSettings = useStore(state => state.setSettings);
|
||||||
|
const erasureEnabled = useStore(state => state.gdprAuthorErasureEnabled)
|
||||||
const {t} = useTranslation()
|
const {t} = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
|
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
|
||||||
|
const updateStatus = useStore(state => state.updateStatus)
|
||||||
|
const version = updateStatus?.currentVersion ?? null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/admin-auth/', {
|
fetch('/admin-auth/', {method: 'POST'}).then((value) => {
|
||||||
method: 'POST'
|
if (!value.ok) navigate('/login')
|
||||||
}).then((value) => {
|
}).catch(() => navigate('/login'))
|
||||||
if (!value.ok) {
|
|
||||||
navigate('/login')
|
|
||||||
}
|
|
||||||
}).catch(() => {
|
|
||||||
navigate('/login')
|
|
||||||
})
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t('admin.page-title')
|
document.title = t('admin.page-title')
|
||||||
|
|
||||||
useStore.getState().setShowLoading(true);
|
useStore.getState().setShowLoading(true);
|
||||||
const settingSocket = connect(`${WS_URL}/settings`, {
|
|
||||||
transports: ['websocket'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {
|
const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']});
|
||||||
transports: ['websocket'],
|
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']})
|
||||||
})
|
|
||||||
|
|
||||||
pluginsSocket.on('connect', () => {
|
pluginsSocket.on('connect', () => {
|
||||||
useStore.getState().setPluginsSocket(pluginsSocket);
|
useStore.getState().setPluginsSocket(pluginsSocket);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
settingSocket.on('connect', () => {
|
settingSocket.on('connect', () => {
|
||||||
useStore.getState().setSettingsSocket(settingSocket);
|
useStore.getState().setSettingsSocket(settingSocket);
|
||||||
useStore.getState().setShowLoading(false)
|
useStore.getState().setShowLoading(false)
|
||||||
|
|
@ -53,32 +45,32 @@ export const App = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
settingSocket.on('disconnect', (reason) => {
|
settingSocket.on('disconnect', (reason) => {
|
||||||
// The settingSocket.io client will automatically try to reconnect for all reasons other than "io
|
|
||||||
// server disconnect".
|
|
||||||
useStore.getState().setShowLoading(true)
|
useStore.getState().setShowLoading(true)
|
||||||
if (reason === 'io server disconnect') {
|
if (reason === 'io server disconnect') settingSocket.connect();
|
||||||
settingSocket.connect();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
settingSocket.on('settings', (settings) => {
|
settingSocket.on('settings', (settings: any) => {
|
||||||
/* Check whether the settings.json is authorized to be viewed */
|
if (settings && typeof settings.flags === 'object' && settings.flags) {
|
||||||
|
useStore.getState().setGdprAuthorErasureEnabled(
|
||||||
|
!!settings.flags.gdprAuthorErasure);
|
||||||
|
}
|
||||||
if (settings.results === 'NOT_ALLOWED') {
|
if (settings.results === 'NOT_ALLOWED') {
|
||||||
console.log('Not allowed to view settings.json')
|
console.log('Not allowed to view settings.json')
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isJSONClean(settings.results)) setSettings(settings.results);
|
||||||
/* Check to make sure the JSON is clean before proceeding */
|
else alert(t('admin_settings.invalid_json'));
|
||||||
if (isJSONClean(settings.results)) {
|
|
||||||
setSettings(settings.results);
|
|
||||||
} else {
|
|
||||||
alert('Invalid JSON');
|
|
||||||
}
|
|
||||||
useStore.getState().setShowLoading(false);
|
useStore.getState().setShowLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingSocket.on('saveprogress', (status) => {
|
settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => {
|
||||||
console.log(status)
|
const {setToastState} = useStore.getState();
|
||||||
|
if (status === 'saved') {
|
||||||
|
setToastState({open: true, title: t('admin_settings.toast.saved'), success: true});
|
||||||
|
} else {
|
||||||
|
const detail = payload?.message ?? '';
|
||||||
|
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -87,37 +79,109 @@ export const App = () => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div id="wrapper" className={`${sidebarOpen ? '': 'closed' }`}>
|
const closeOnMobile = () => {
|
||||||
<LoadingScreen/>
|
if (window.innerWidth < 768) setSidebarOpen(false)
|
||||||
<div className="menu">
|
}
|
||||||
<div className="inner-menu">
|
|
||||||
<span>
|
return (
|
||||||
<Crown width={40} height={40}/>
|
<div id="wrapper" className={sidebarOpen ? '' : 'closed'}>
|
||||||
<h1>Etherpad</h1>
|
<LoadingScreen/>
|
||||||
</span>
|
<div className="menu">
|
||||||
<ul onClick={()=>{
|
<div className="inner-menu">
|
||||||
if (window.innerWidth < 768) {
|
<div className="sidebar-top">
|
||||||
setSidebarOpen(false)
|
<button
|
||||||
}
|
className="sidebar-burger"
|
||||||
}}>
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
<li><NavLink to="/plugins"><Cable/><Trans i18nKey="admin_plugins"/></NavLink></li>
|
aria-label={t('admin.toggle_sidebar')}
|
||||||
<li><NavLink to={"/settings"}><Wrench/><Trans i18nKey="admin_settings"/></NavLink></li>
|
>
|
||||||
<li><NavLink to={"/help"}> <Construction/> <Trans i18nKey="admin_plugins_info"/></NavLink></li>
|
<LucideMenu size={20}/>
|
||||||
<li><NavLink to={"/pads"}><NotepadText/><Trans
|
</button>
|
||||||
i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></NavLink></li>
|
{sidebarOpen && (
|
||||||
<li><NavLink to={"/shout"}><PhoneCall/>Communication</NavLink></li>
|
<div className="sidebar-brand">
|
||||||
<li><NavLink to={"/update"}><Bell/><Trans i18nKey="update.page.title"/></NavLink></li>
|
<div className="sidebar-brand-mark"><Crown size={20} strokeWidth={1.8}/></div>
|
||||||
</ul>
|
<span className="sidebar-brand-name">Etherpad</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav" onClick={closeOnMobile}>
|
||||||
|
<NavLink
|
||||||
|
to="/plugins"
|
||||||
|
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
|
||||||
|
title={sidebarOpen ? undefined : t('admin_plugins')}
|
||||||
|
>
|
||||||
|
<span className="sidebar-nav-icon"><Cable size={18}/></span>
|
||||||
|
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin_plugins"/></span>}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/settings"
|
||||||
|
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
|
||||||
|
title={sidebarOpen ? undefined : t('admin_settings')}
|
||||||
|
>
|
||||||
|
<span className="sidebar-nav-icon"><Wrench size={18}/></span>
|
||||||
|
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin_settings"/></span>}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/help"
|
||||||
|
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
|
||||||
|
title={sidebarOpen ? undefined : t('admin_plugins_info')}
|
||||||
|
>
|
||||||
|
<span className="sidebar-nav-icon"><Construction size={18}/></span>
|
||||||
|
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin_plugins_info"/></span>}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/pads"
|
||||||
|
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
|
||||||
|
title={sidebarOpen ? undefined : undefined}
|
||||||
|
>
|
||||||
|
<span className="sidebar-nav-icon"><NotepadText size={18}/></span>
|
||||||
|
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></span>}
|
||||||
|
</NavLink>
|
||||||
|
{erasureEnabled && (
|
||||||
|
<NavLink
|
||||||
|
to="/authors"
|
||||||
|
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
|
||||||
|
title={sidebarOpen ? undefined : t('ep_admin_authors:title', {ns: 'ep_admin_authors'})}
|
||||||
|
>
|
||||||
|
<span className="sidebar-nav-icon"><Users size={18}/></span>
|
||||||
|
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="ep_admin_authors:title" ns="ep_admin_authors"/></span>}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
<NavLink
|
||||||
|
to="/shout"
|
||||||
|
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
|
||||||
|
title={sidebarOpen ? undefined : t('admin.shout')}
|
||||||
|
>
|
||||||
|
<span className="sidebar-nav-icon"><PhoneCall size={18}/></span>
|
||||||
|
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin.shout"/></span>}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/update"
|
||||||
|
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
|
||||||
|
title={sidebarOpen ? undefined : t('update.page.title')}
|
||||||
|
>
|
||||||
|
<span className="sidebar-nav-icon"><Bell size={18}/></span>
|
||||||
|
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="update.page.title"/></span>}
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<div className="sidebar-footer-row">
|
||||||
|
<span className="sidebar-status-dot"/>
|
||||||
|
<span>{version ? `v${version}` : 'Etherpad'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="innerwrapper">
|
||||||
|
<UpdateBanner/>
|
||||||
|
<Outlet/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="icon-button" onClick={() => {
|
)
|
||||||
setSidebarOpen(!sidebarOpen)
|
|
||||||
}}><LucideMenu/></button>
|
|
||||||
<div className="innerwrapper">
|
|
||||||
<UpdateBanner/>
|
|
||||||
<Outlet/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
|
||||||
40
admin/src/api/QueryProvider.tsx
Normal file
40
admin/src/api/QueryProvider.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// admin/src/api/QueryProvider.tsx
|
||||||
|
//
|
||||||
|
// TanStack Query provider for the admin UI. Devtools are loaded lazily and
|
||||||
|
// only in dev builds so they don't ship to production.
|
||||||
|
|
||||||
|
import { lazy, Suspense, useState, type ReactNode } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const Devtools = import.meta.env.DEV
|
||||||
|
? lazy(() =>
|
||||||
|
import('@tanstack/react-query-devtools').then((m) => ({
|
||||||
|
default: m.ReactQueryDevtools,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export const QueryProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [client] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
{children}
|
||||||
|
{Devtools && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Devtools initialIsOpen={false} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
admin/src/api/__tests__/client.test.ts
Normal file
20
admin/src/api/__tests__/client.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// admin/src/api/__tests__/client.test.ts
|
||||||
|
//
|
||||||
|
// Smoke test that the OpenAPI client module loads and exposes the expected
|
||||||
|
// surface. Catches toolchain wiring regressions (missing peer deps,
|
||||||
|
// generator output that doesn't export `paths`, etc.).
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
test('client module exports public + admin clients and query hooks', async () => {
|
||||||
|
const mod = await import('../client.ts');
|
||||||
|
assert.ok(mod.fetchClient, 'fetchClient export is present');
|
||||||
|
assert.ok(mod.adminFetchClient, 'adminFetchClient export is present');
|
||||||
|
assert.ok(mod.$api, '$api export is present');
|
||||||
|
assert.ok(mod.$adminApi, '$adminApi export is present');
|
||||||
|
assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function');
|
||||||
|
assert.equal(typeof mod.adminFetchClient.GET, 'function', 'adminFetchClient.GET is a function');
|
||||||
|
assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function');
|
||||||
|
assert.equal(typeof mod.$adminApi.useQuery, 'function', '$adminApi.useQuery is a function');
|
||||||
|
});
|
||||||
30
admin/src/api/client.ts
Normal file
30
admin/src/api/client.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// admin/src/api/client.ts
|
||||||
|
//
|
||||||
|
// Typed HTTP clients and TanStack Query hooks derived from the generated
|
||||||
|
// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`.
|
||||||
|
//
|
||||||
|
// The merged spec covers two surfaces with different baseUrls:
|
||||||
|
//
|
||||||
|
// - Public versioned API at /api/<version>/ (paths like /createGroup)
|
||||||
|
// - Admin endpoints at root (paths like /admin-auth/)
|
||||||
|
//
|
||||||
|
// We narrow the generated `paths` interface by URL prefix and create one
|
||||||
|
// typed client per surface. TypeScript then rejects calling an admin path on
|
||||||
|
// the public client (or vice versa) at compile time — there is no shared
|
||||||
|
// client whose runtime baseUrl would silently target the wrong surface.
|
||||||
|
|
||||||
|
import createClient from 'openapi-fetch';
|
||||||
|
import createQueryHooks from 'openapi-react-query';
|
||||||
|
import type { paths } from './schema';
|
||||||
|
import { API_BASE_URL } from './version';
|
||||||
|
|
||||||
|
type AdminPath = Extract<keyof paths, `/admin${string}`>;
|
||||||
|
type PublicPath = Exclude<keyof paths, AdminPath>;
|
||||||
|
type PublicPaths = Pick<paths, PublicPath>;
|
||||||
|
type AdminPaths = Pick<paths, AdminPath>;
|
||||||
|
|
||||||
|
export const fetchClient = createClient<PublicPaths>({ baseUrl: API_BASE_URL });
|
||||||
|
export const adminFetchClient = createClient<AdminPaths>({ baseUrl: '/' });
|
||||||
|
|
||||||
|
export const $api = createQueryHooks(fetchClient);
|
||||||
|
export const $adminApi = createQueryHooks(adminFetchClient);
|
||||||
37
admin/src/components/ColorSwatch.tsx
Normal file
37
admin/src/components/ColorSwatch.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
type Props = {
|
||||||
|
color: string | number | null;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolves the colorId stored on globalAuthor records into a CSS color.
|
||||||
|
// AuthorManager stores either a string hex (legacy) or an integer index
|
||||||
|
// into the palette returned by getColorPalette() — we re-derive the
|
||||||
|
// palette here rather than fetch it because the order is stable and the
|
||||||
|
// admin already has many other small constants inline.
|
||||||
|
const PALETTE = [
|
||||||
|
'#ffc7c7', '#fff1c7', '#e3ffc7', '#c7ffd5', '#c7ffff', '#c7d5ff',
|
||||||
|
'#e3c7ff', '#ffc7f1', '#ffa8a8', '#ffe699', '#cfff9e', '#99ffb3',
|
||||||
|
'#a3ffff', '#99b3ff', '#cc99ff', '#ff99e5', '#e7b1b1', '#e9dcAf',
|
||||||
|
'#cde9af', '#bfedcc', '#b1e7e7', '#c3cdee', '#d2b8ea', '#eec3e6',
|
||||||
|
'#e9cece', '#e7e0ca', '#d3e5c7', '#bce1c5', '#c1e2e2', '#c1c9e2',
|
||||||
|
'#cfc1e2', '#e0bdd9', '#baded3', '#a0f8eb', '#b1e7e0', '#c3c8e4',
|
||||||
|
'#cec5e2', '#b1d5e7', '#cda8f0', '#f0f0a8', '#f2f2a6', '#f5a8eb',
|
||||||
|
'#c5f9a9', '#ececbb', '#e7c4bc', '#daf0b2', '#b0a0fd', '#bce2e7',
|
||||||
|
'#cce2bb', '#ec9afe', '#edabbd', '#aeaeea', '#c4e7b1', '#d722bb',
|
||||||
|
'#f3a5e7', '#ffa8a8', '#d8c0c5', '#eaaedd', '#adc6eb', '#bedad1',
|
||||||
|
'#dee9af', '#e9afc2', '#f8d2a0', '#b3b3e6',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ColorSwatch = ({color, size = 14}: Props) => {
|
||||||
|
let resolved = '#ccc';
|
||||||
|
if (typeof color === 'string') {
|
||||||
|
resolved = color;
|
||||||
|
} else if (typeof color === 'number' && color >= 0 && color < PALETTE.length) {
|
||||||
|
resolved = PALETTE[color];
|
||||||
|
}
|
||||||
|
return <span style={{
|
||||||
|
display: 'inline-block', width: size, height: size,
|
||||||
|
background: resolved, border: '1px solid rgba(0,0,0,0.2)',
|
||||||
|
borderRadius: 3, verticalAlign: 'middle',
|
||||||
|
}}/>;
|
||||||
|
};
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
import {FC, JSX, ReactElement} from "react";
|
import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react";
|
||||||
|
|
||||||
export type IconButtonProps = {
|
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title' | 'onClick'> & {
|
||||||
style?: React.CSSProperties,
|
|
||||||
icon: JSX.Element,
|
icon: JSX.Element,
|
||||||
title: string|ReactElement,
|
title: string|ReactElement,
|
||||||
onClick: ()=>void,
|
onClick: ()=>void,
|
||||||
className?: string,
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IconButton:FC<IconButtonProps> = ({icon,className,onClick,title, disabled, style})=>{
|
export const IconButton: FC<IconButtonProps> = ({icon, className, onClick, title, type = 'button', ...rest}) => (
|
||||||
return <button style={style} onClick={onClick} className={"icon-button "+ className} disabled={disabled}>
|
<button {...rest} type={type} onClick={onClick} className={"icon-button " + (className ?? "")}>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import {useEffect} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {Trans, useTranslation} from 'react-i18next';
|
import {Trans, useTranslation} from 'react-i18next';
|
||||||
import {useStore} from '../store/store';
|
import {useStore} from '../store/store';
|
||||||
|
|
||||||
|
const fmtRemaining = (ms: number): string => {
|
||||||
|
if (ms <= 0) return '0s';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
|
||||||
|
};
|
||||||
|
|
||||||
export const UpdateBanner = () => {
|
export const UpdateBanner = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const updateStatus = useStore((s) => s.updateStatus);
|
const updateStatus = useStore((s) => s.updateStatus);
|
||||||
|
|
@ -17,7 +25,51 @@ export const UpdateBanner = () => {
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [setUpdateStatus]);
|
}, [setUpdateStatus]);
|
||||||
|
|
||||||
if (!updateStatus || !updateStatus.latest) return null;
|
const scheduledFor = updateStatus?.execution?.status === 'scheduled'
|
||||||
|
? (updateStatus.execution as {scheduledFor: string}).scheduledFor
|
||||||
|
: null;
|
||||||
|
const [remainingMs, setRemainingMs] = useState<number>(() =>
|
||||||
|
scheduledFor ? Math.max(0, new Date(scheduledFor).getTime() - Date.now()) : 0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scheduledFor) return;
|
||||||
|
const target = new Date(scheduledFor).getTime();
|
||||||
|
setRemainingMs(Math.max(0, target - Date.now()));
|
||||||
|
const id = setInterval(() => setRemainingMs(Math.max(0, target - Date.now())), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [scheduledFor]);
|
||||||
|
|
||||||
|
if (!updateStatus) return null;
|
||||||
|
|
||||||
|
// Terminal rollback-failed wins over the regular "update available" banner —
|
||||||
|
// an admin who left the system in this state needs to fix it before any
|
||||||
|
// other admin work matters.
|
||||||
|
if (updateStatus.execution?.status === 'rollback-failed') {
|
||||||
|
return (
|
||||||
|
<div className="update-banner update-banner-terminal" role="alert">
|
||||||
|
<strong><Trans i18nKey="update.banner.terminal.rollback-failed"/></strong>{' '}
|
||||||
|
<Link to="/update">{t('update.banner.cta')}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: scheduled update — show countdown banner instead of the plain
|
||||||
|
// "update available" one.
|
||||||
|
if (updateStatus.execution?.status === 'scheduled') {
|
||||||
|
const exec = updateStatus.execution as {targetTag: string; scheduledFor: string};
|
||||||
|
return (
|
||||||
|
<div className="update-banner update-banner-scheduled" role="status">
|
||||||
|
<strong>
|
||||||
|
<Trans
|
||||||
|
i18nKey="update.banner.scheduled"
|
||||||
|
values={{tag: exec.targetTag, remaining: fmtRemaining(remainingMs)}}
|
||||||
|
/>
|
||||||
|
</strong>{' '}
|
||||||
|
<Link to="/update">{t('update.banner.cta')}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateStatus.latest) return null;
|
||||||
if (updateStatus.currentVersion === updateStatus.latest.version) return null;
|
if (updateStatus.currentVersion === updateStatus.latest.version) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
144
admin/src/components/settings/FormView.tsx
Normal file
144
admin/src/components/settings/FormView.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { parseTree, getNodePath, type JSONPath, type Node, type ParseError } from 'jsonc-parser';
|
||||||
|
import { useStore } from '../../store/store';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { editJsonc } from './jsoncEdit';
|
||||||
|
import { JsoncNode } from './JsoncNode';
|
||||||
|
import { ParseErrorBanner } from './ParseErrorBanner';
|
||||||
|
import { extractAdjacentComments } from './comments';
|
||||||
|
import { lookupTemplateComment } from './templateComments';
|
||||||
|
import { labelAndHelp } from './labels';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSwitchToRaw: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parser-error token labels are kept in English — they are technical tokens
|
||||||
|
// matching the jsonc-parser error enum, not user-facing prose.
|
||||||
|
const ParseErrorMessage: Record<number, string> = {
|
||||||
|
1: 'Invalid symbol',
|
||||||
|
2: 'Invalid number format',
|
||||||
|
3: 'Property name expected',
|
||||||
|
4: 'Value expected',
|
||||||
|
5: 'Colon expected',
|
||||||
|
6: 'Comma expected',
|
||||||
|
7: 'Closing brace expected',
|
||||||
|
8: 'Closing bracket expected',
|
||||||
|
9: 'End of file expected',
|
||||||
|
16: 'Unexpected end of comment',
|
||||||
|
17: 'Unexpected end of string',
|
||||||
|
18: 'Unexpected end of number',
|
||||||
|
19: 'Invalid unicode',
|
||||||
|
20: 'Invalid escape character',
|
||||||
|
21: 'Invalid character',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatErrors = (errors: ParseError[]): string =>
|
||||||
|
errors.length === 0
|
||||||
|
? ''
|
||||||
|
: errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n');
|
||||||
|
|
||||||
|
const Section = ({ title, description, children }: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<section className="settings-section">
|
||||||
|
<header className="settings-section-header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{description && <p>{description}</p>}
|
||||||
|
</header>
|
||||||
|
<div className="settings-section-body">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
const propertyKey = (prop: Node): string =>
|
||||||
|
prop.type === 'property' && prop.children?.[0]?.type === 'string'
|
||||||
|
? String(prop.children[0].value)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const propertyComment = (prop: Node, text: string, key: string): string | null => {
|
||||||
|
const valueNode = prop.children?.[1];
|
||||||
|
if (!valueNode) return null;
|
||||||
|
const live = extractAdjacentComments(text, prop.offset, valueNode.offset, valueNode.length);
|
||||||
|
if (live.leading) return live.leading;
|
||||||
|
// Section headings prefer the leading block comment; only fall back to the
|
||||||
|
// trailing comment if no leading documentation exists at all.
|
||||||
|
const tmpl = lookupTemplateComment([key]);
|
||||||
|
if (!tmpl) return null;
|
||||||
|
return tmpl.leading || tmpl.trailing || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormView = ({ onSwitchToRaw }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const rawText = useStore(s => s.settings);
|
||||||
|
|
||||||
|
// While settings haven't loaded yet, show an empty busy placeholder so we
|
||||||
|
// don't flash a parse-error banner for the undefined→'' empty-string case.
|
||||||
|
if (rawText === undefined) {
|
||||||
|
return <div className="settings-form" data-testid="settings-form-view" aria-busy="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = rawText;
|
||||||
|
|
||||||
|
const errors: ParseError[] = [];
|
||||||
|
const tree = parseTree(text, errors, { allowTrailingComma: true });
|
||||||
|
|
||||||
|
// Always read the latest text from the store instead of closing over the
|
||||||
|
// render-time snapshot, so rapid sequential edits don't clobber each other.
|
||||||
|
const onEdit = (path: JSONPath, value: unknown) => {
|
||||||
|
const current = useStore.getState().settings ?? '';
|
||||||
|
useStore.getState().setSettings(editJsonc(current, path, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tree || errors.length > 0 || tree.type !== 'object') {
|
||||||
|
return <ParseErrorBanner message={formatErrors(errors)} onSwitchToRaw={onSwitchToRaw} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generalProps: Node[] = [];
|
||||||
|
const sectionProps: Node[] = [];
|
||||||
|
for (const prop of tree.children ?? []) {
|
||||||
|
if (prop.type !== 'property' || !prop.children?.[1]) continue;
|
||||||
|
const valueType = prop.children[1].type;
|
||||||
|
if (valueType === 'object' || valueType === 'array') sectionProps.push(prop);
|
||||||
|
else generalProps.push(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-form" data-testid="settings-form-view">
|
||||||
|
{generalProps.length > 0 && (
|
||||||
|
<Section title={t('admin_settings.section.general')}>
|
||||||
|
{generalProps.map((prop) => {
|
||||||
|
const propPath = getNodePath(prop);
|
||||||
|
const propKey = propPath.join('.');
|
||||||
|
return (
|
||||||
|
<JsoncNode
|
||||||
|
key={propKey}
|
||||||
|
node={prop.children![1]}
|
||||||
|
property={prop}
|
||||||
|
text={text}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{sectionProps.map((prop) => {
|
||||||
|
const key = propertyKey(prop);
|
||||||
|
const { label, help } = labelAndHelp(propertyComment(prop, text, key), key);
|
||||||
|
const propPath = getNodePath(prop);
|
||||||
|
const sectionKey = propPath.join('.');
|
||||||
|
return (
|
||||||
|
<Section key={sectionKey} title={label} description={help}>
|
||||||
|
<JsoncNode
|
||||||
|
node={prop.children![1]}
|
||||||
|
property={prop}
|
||||||
|
text={text}
|
||||||
|
onEdit={onEdit}
|
||||||
|
suppressOwnHeader
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
admin/src/components/settings/JsoncNode.tsx
Normal file
173
admin/src/components/settings/JsoncNode.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import type { JSONPath, Node } from 'jsonc-parser';
|
||||||
|
import { getNodePath } from 'jsonc-parser';
|
||||||
|
import { extractAdjacentComments } from './comments';
|
||||||
|
import { matchEnvPlaceholder } from './envPill';
|
||||||
|
import { lookupTemplateComment } from './templateComments';
|
||||||
|
import { humanize, labelAndHelp } from './labels';
|
||||||
|
import { StringInput } from './widgets/StringInput';
|
||||||
|
import { NumberInput } from './widgets/NumberInput';
|
||||||
|
import { BooleanToggle } from './widgets/BooleanToggle';
|
||||||
|
import { NullChip } from './widgets/NullChip';
|
||||||
|
import { EnvPill } from './widgets/EnvPill';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The value node (not the property node). */
|
||||||
|
node: Node;
|
||||||
|
/** The property node, when this value is the value-side of `"key": value`. */
|
||||||
|
property?: Node;
|
||||||
|
text: string;
|
||||||
|
onEdit: (path: JSONPath, value: unknown) => void;
|
||||||
|
/**
|
||||||
|
* When true, this group's own label/header is suppressed because a
|
||||||
|
* containing Section already rendered it. The group's children still
|
||||||
|
* render. Used for top-level object/array sections in FormView.
|
||||||
|
*/
|
||||||
|
suppressOwnHeader?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const propertyKey = (property: Node | undefined): string => {
|
||||||
|
if (!property || property.type !== 'property') return '';
|
||||||
|
const k = property.children?.[0];
|
||||||
|
return k?.type === 'string' ? String(k.value) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLeaf = (
|
||||||
|
node: Node,
|
||||||
|
path: JSONPath,
|
||||||
|
text: string,
|
||||||
|
onEdit: (path: JSONPath, value: unknown) => void,
|
||||||
|
) => {
|
||||||
|
if (node.type === 'string') {
|
||||||
|
const raw = text.slice(node.offset, node.offset + node.length);
|
||||||
|
const env = matchEnvPlaceholder(raw);
|
||||||
|
if (env) {
|
||||||
|
return (
|
||||||
|
<EnvPill
|
||||||
|
placeholder={env}
|
||||||
|
path={path}
|
||||||
|
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StringInput
|
||||||
|
value={String(node.value)}
|
||||||
|
path={path}
|
||||||
|
onChange={v => onEdit(path, v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.type === 'number') {
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
value={Number(node.value)}
|
||||||
|
path={path}
|
||||||
|
onChange={v => onEdit(path, v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<BooleanToggle
|
||||||
|
value={Boolean(node.value)}
|
||||||
|
path={path}
|
||||||
|
onChange={v => onEdit(path, v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.type === 'null') {
|
||||||
|
return <NullChip path={path} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
|
||||||
|
const path = getNodePath(node);
|
||||||
|
const key = propertyKey(property);
|
||||||
|
|
||||||
|
const anchor = property ?? node;
|
||||||
|
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
|
||||||
|
const tmpl = property ? lookupTemplateComment(path) : null;
|
||||||
|
const leading = fileComments.leading || tmpl?.leading || '';
|
||||||
|
const trailing = fileComments.trailing || tmpl?.trailing || '';
|
||||||
|
|
||||||
|
// Leading block comments (e.g. /* Description … */ above a key) carry the
|
||||||
|
// descriptive label — use labelAndHelp's first-sentence split.
|
||||||
|
// Trailing same-line comments (e.g. "altF9": true, /* focus on … */) are
|
||||||
|
// brief per-key annotations: the key itself reads as the label, the comment
|
||||||
|
// belongs in the help slot below the control. See #7740.
|
||||||
|
let label: string;
|
||||||
|
let help: string;
|
||||||
|
if (leading) {
|
||||||
|
const r = labelAndHelp(leading, key);
|
||||||
|
label = r.label;
|
||||||
|
help = [r.help, trailing].filter(Boolean).join(' ');
|
||||||
|
} else if (trailing) {
|
||||||
|
label = humanize(key);
|
||||||
|
help = trailing;
|
||||||
|
} else {
|
||||||
|
label = humanize(key);
|
||||||
|
help = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowId = `settings-row-${path.join('.') || 'root'}`;
|
||||||
|
const helpId = help ? `${rowId}-help` : undefined;
|
||||||
|
|
||||||
|
// ---- Object / array groups ----
|
||||||
|
if (node.type === 'object' || node.type === 'array') {
|
||||||
|
const children = (node.children ?? []).map((child) => {
|
||||||
|
// For object: child is a property node, drill into its value node.
|
||||||
|
// For array: child is a value node directly.
|
||||||
|
if (node.type === 'object') {
|
||||||
|
const valueNode = child.children?.[1];
|
||||||
|
if (!valueNode) return null;
|
||||||
|
const propPath = getNodePath(child);
|
||||||
|
const propKey = propPath.join('.');
|
||||||
|
return (
|
||||||
|
<JsoncNode
|
||||||
|
key={propKey}
|
||||||
|
node={valueNode}
|
||||||
|
property={child}
|
||||||
|
text={text}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Array element: use stable JSON path as key.
|
||||||
|
const childPath = getNodePath(child);
|
||||||
|
return <JsoncNode key={childPath.join('.')} node={child} text={text} onEdit={onEdit} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (suppressOwnHeader || !property) {
|
||||||
|
// Render children flat — the containing Section provides the label.
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested group within a section: render as a sub-section with its own
|
||||||
|
// heading, indented under its parent.
|
||||||
|
return (
|
||||||
|
<div className="settings-subsection" data-testid={`group-${path.join('.')}`}>
|
||||||
|
<div className="settings-subsection-header">
|
||||||
|
<span className="settings-subsection-title">{label}</span>
|
||||||
|
{help && <span className="settings-subsection-help">{help}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="settings-subsection-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Leaf row ----
|
||||||
|
return (
|
||||||
|
<div className="settings-row" id={rowId}>
|
||||||
|
<label className="settings-row-label" htmlFor={`field-${path.join('.')}`}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="settings-row-control">
|
||||||
|
{renderLeaf(node, path, text, onEdit)}
|
||||||
|
</div>
|
||||||
|
{help && (
|
||||||
|
<p className="settings-row-help" id={helpId}>{help}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
admin/src/components/settings/ModeToggle.tsx
Normal file
36
admin/src/components/settings/ModeToggle.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export type Mode = 'form' | 'raw';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: Mode;
|
||||||
|
onChange: (mode: Mode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModeToggle = ({ mode, onChange }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'form'}
|
||||||
|
data-testid="mode-toggle-form"
|
||||||
|
className={mode === 'form' ? 'active' : ''}
|
||||||
|
onClick={() => onChange('form')}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="admin_settings.mode.form" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'raw'}
|
||||||
|
data-testid="mode-toggle-raw"
|
||||||
|
className={mode === 'raw' ? 'active' : ''}
|
||||||
|
onClick={() => onChange('raw')}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="admin_settings.mode.raw" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
admin/src/components/settings/ParseErrorBanner.tsx
Normal file
16
admin/src/components/settings/ParseErrorBanner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Trans } from 'react-i18next';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
message: string;
|
||||||
|
onSwitchToRaw: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => (
|
||||||
|
<div className="settings-parse-error" role="alert" data-testid="parse-error-banner">
|
||||||
|
<strong><Trans i18nKey="admin_settings.parse_error.title" /></strong>
|
||||||
|
<pre className="settings-parse-error-detail">{message}</pre>
|
||||||
|
<button type="button" onClick={onSwitchToRaw} data-testid="parse-error-switch-raw">
|
||||||
|
<Trans i18nKey="admin_settings.parse_error.cta" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
93
admin/src/components/settings/__tests__/comments.test.ts
Normal file
93
admin/src/components/settings/__tests__/comments.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// admin/src/components/settings/__tests__/comments.test.ts
|
||||||
|
//
|
||||||
|
// Regression coverage for https://github.com/ether/etherpad/issues/7740.
|
||||||
|
// A previous version of findLeading treated any line ending in `*/` as a
|
||||||
|
// comment continuation; a JSON line like
|
||||||
|
// "altF9": true, /* focus on the File Menu and/or editbar */
|
||||||
|
// then leaked into the next sibling's "leading comment", which the form
|
||||||
|
// view rendered as the row label.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { extractAdjacentComments } from '../comments.ts';
|
||||||
|
import { humanize, labelAndHelp } from '../labels.ts';
|
||||||
|
|
||||||
|
const padShortcutText = `{
|
||||||
|
"padShortcutEnabled" : {
|
||||||
|
"altF9": true, /* focus on the File Menu and/or editbar */
|
||||||
|
"altC": true, /* focus on the Chat window */
|
||||||
|
"cmdShift2": true, /* shows a gritter popup showing a line author */
|
||||||
|
"delete": true,
|
||||||
|
"return": true,
|
||||||
|
"esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */
|
||||||
|
"cmdS": true /* save a revision */
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const offsetsFor = (text: string, key: string) => {
|
||||||
|
const keyOffset = text.indexOf(`"${key}"`);
|
||||||
|
const valOffset = text.indexOf('true', keyOffset);
|
||||||
|
return { keyOffset, valOffset, valLength: 4 };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('does not absorb prior JSON line with trailing comment as leading', () => {
|
||||||
|
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'altC');
|
||||||
|
const { leading, trailing } =
|
||||||
|
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
|
||||||
|
assert.equal(leading, '');
|
||||||
|
assert.equal(trailing, 'focus on the Chat window');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not accumulate multiple prior trailing-comment lines', () => {
|
||||||
|
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'cmdShift2');
|
||||||
|
const { leading } =
|
||||||
|
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
|
||||||
|
assert.equal(leading, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leading is empty when prior line is plain code (no trailing comment)', () => {
|
||||||
|
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'return');
|
||||||
|
const { leading } =
|
||||||
|
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
|
||||||
|
assert.equal(leading, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still recognises JSDoc-style leading block comments', () => {
|
||||||
|
const text = `{
|
||||||
|
/*
|
||||||
|
* Pad Shortcut Keys
|
||||||
|
*/
|
||||||
|
"padShortcutEnabled" : {}
|
||||||
|
}`;
|
||||||
|
const keyOffset = text.indexOf('"padShortcutEnabled"');
|
||||||
|
const valOffset = text.indexOf('{}');
|
||||||
|
const { leading, trailing } = extractAdjacentComments(text, keyOffset, valOffset, 2);
|
||||||
|
assert.equal(leading, 'Pad Shortcut Keys');
|
||||||
|
assert.equal(trailing, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still recognises single-line // leading comments', () => {
|
||||||
|
const text = `{
|
||||||
|
// Whether to enable the thing.
|
||||||
|
"thing": true
|
||||||
|
}`;
|
||||||
|
const keyOffset = text.indexOf('"thing"');
|
||||||
|
const valOffset = text.indexOf('true');
|
||||||
|
const { leading } = extractAdjacentComments(text, keyOffset, valOffset, 4);
|
||||||
|
assert.equal(leading, 'Whether to enable the thing.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('humanize spaces camelCase and capitalises only the first word', () => {
|
||||||
|
assert.equal(humanize('requireAuthentication'), 'Require authentication');
|
||||||
|
assert.equal(humanize('altF9'), 'Alt f9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labelAndHelp splits a leading block at the first sentence boundary', () => {
|
||||||
|
const { label, help } = labelAndHelp(
|
||||||
|
'Name your instance! Optional context follows.',
|
||||||
|
'title',
|
||||||
|
);
|
||||||
|
assert.equal(label, 'Name your instance!');
|
||||||
|
assert.equal(help, 'Optional context follows.');
|
||||||
|
});
|
||||||
95
admin/src/components/settings/comments.ts
Normal file
95
admin/src/components/settings/comments.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
// admin/src/components/settings/comments.ts
|
||||||
|
//
|
||||||
|
// Given the source text and a property's `keyOffset` (jsonc-parser's
|
||||||
|
// Node.offset for the property node), extract:
|
||||||
|
// - `leading`: the contiguous run of `/* */` or `//` comments
|
||||||
|
// immediately above the key. At most one blank line is allowed
|
||||||
|
// between the comment block and the key.
|
||||||
|
// - `trailing`: a single `// ...` or `/* ... */` on the same line
|
||||||
|
// as the value, after any trailing comma.
|
||||||
|
|
||||||
|
export type AdjacentComments = {
|
||||||
|
leading: string;
|
||||||
|
trailing: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LINE_BREAK = /\r?\n/;
|
||||||
|
|
||||||
|
const stripCommentMarkers = (raw: string): string => {
|
||||||
|
// raw is a concatenation of comment tokens separated by newlines.
|
||||||
|
// Drop /* */ and // markers and trim each line.
|
||||||
|
return raw
|
||||||
|
.split(LINE_BREAK)
|
||||||
|
.map(line => line
|
||||||
|
.replace(/^\s*\/\*+/, '')
|
||||||
|
.replace(/\*+\/\s*$/, '')
|
||||||
|
.replace(/^\s*\*\s?/, '')
|
||||||
|
.replace(/^\s*\/\/\s?/, '')
|
||||||
|
.trim())
|
||||||
|
.filter(line => line.length > 0)
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const findLeading = (text: string, keyOffset: number): string => {
|
||||||
|
// Walk backwards from keyOffset to the start of the line containing it.
|
||||||
|
const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1;
|
||||||
|
let cursor = lineStart;
|
||||||
|
let blankLineSeen = false;
|
||||||
|
const collected: string[] = [];
|
||||||
|
|
||||||
|
while (cursor > 0) {
|
||||||
|
// Look at the previous line.
|
||||||
|
const prevLineEnd = cursor - 1; // the '\n' before our cursor's line
|
||||||
|
const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1;
|
||||||
|
const line = text.slice(prevLineStart, prevLineEnd);
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed === '') {
|
||||||
|
if (blankLineSeen) break;
|
||||||
|
blankLineSeen = true;
|
||||||
|
cursor = prevLineStart;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A JSON line with a trailing `/* … */` comment (e.g.
|
||||||
|
// "altF9": true, /* focus on the File Menu and/or editbar */
|
||||||
|
// ) ends with `*/` but is NOT a comment continuation. Only treat a
|
||||||
|
// previous line as part of the leading comment block if it structurally
|
||||||
|
// opens (`/*`), continues (`*` — JSDoc style, covers ` */` close), or
|
||||||
|
// is a single-line `//` comment. This matches the comment styles used
|
||||||
|
// in settings.json.template; #7740.
|
||||||
|
const isComment =
|
||||||
|
trimmed.startsWith('//') ||
|
||||||
|
trimmed.startsWith('/*') ||
|
||||||
|
trimmed.startsWith('*');
|
||||||
|
|
||||||
|
if (!isComment) break;
|
||||||
|
|
||||||
|
collected.unshift(line);
|
||||||
|
cursor = prevLineStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripCommentMarkers(collected.join('\n'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const findTrailing = (text: string, valueOffset: number, valueLength: number): string => {
|
||||||
|
// Trailing comments only exist on the same line as the value. If there's
|
||||||
|
// no newline after the value the file has no line structure (e.g. minified
|
||||||
|
// settings.json) and `//` inside any later string literal would otherwise
|
||||||
|
// be matched as a comment.
|
||||||
|
const lineEnd = text.indexOf('\n', valueOffset + valueLength);
|
||||||
|
if (lineEnd === -1) return '';
|
||||||
|
const slice = text.slice(valueOffset + valueLength, lineEnd);
|
||||||
|
const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice);
|
||||||
|
return m ? stripCommentMarkers(m[1]) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractAdjacentComments = (
|
||||||
|
text: string,
|
||||||
|
keyOffset: number,
|
||||||
|
valueOffset: number,
|
||||||
|
valueLength: number,
|
||||||
|
): AdjacentComments => ({
|
||||||
|
leading: findLeading(text, keyOffset),
|
||||||
|
trailing: findTrailing(text, valueOffset, valueLength),
|
||||||
|
});
|
||||||
21
admin/src/components/settings/envPill.ts
Normal file
21
admin/src/components/settings/envPill.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// admin/src/components/settings/envPill.ts
|
||||||
|
//
|
||||||
|
// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw
|
||||||
|
// slice of a string node. The slice INCLUDES the surrounding quotes,
|
||||||
|
// because jsonc-parser exposes node.offset/length over the whole literal.
|
||||||
|
|
||||||
|
export type EnvPlaceholder = {
|
||||||
|
variable: string;
|
||||||
|
defaultValue: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/;
|
||||||
|
|
||||||
|
export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => {
|
||||||
|
const m = RE.exec(rawSlice);
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
variable: m[1],
|
||||||
|
defaultValue: m[2] ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
11
admin/src/components/settings/jsoncEdit.ts
Normal file
11
admin/src/components/settings/jsoncEdit.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// admin/src/components/settings/jsoncEdit.ts
|
||||||
|
import { applyEdits, modify, type JSONPath } from 'jsonc-parser';
|
||||||
|
|
||||||
|
const FORMATTING = {
|
||||||
|
formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editJsonc = (text: string, path: JSONPath, value: unknown): string => {
|
||||||
|
const edits = modify(text, path, value, FORMATTING);
|
||||||
|
return edits.length === 0 ? text : applyEdits(text, edits);
|
||||||
|
};
|
||||||
46
admin/src/components/settings/labels.ts
Normal file
46
admin/src/components/settings/labels.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Pretty-label derivation. The first sentence of a key's documentation
|
||||||
|
// comment is its label; the rest stays in the help-text slot. When no
|
||||||
|
// comment exists, fall back to a humanized key name (camelCase → "Camel
|
||||||
|
// case").
|
||||||
|
|
||||||
|
const SENTENCE_END = /[.!?](\s|$)/;
|
||||||
|
|
||||||
|
export const humanize = (key: string): string => {
|
||||||
|
if (!key) return key;
|
||||||
|
// Split camelCase / PascalCase / snake_case / kebab-case
|
||||||
|
const words = key
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/);
|
||||||
|
if (words.length === 0) return key;
|
||||||
|
return words[0].charAt(0).toUpperCase() + words[0].slice(1) +
|
||||||
|
(words.length > 1 ? ' ' + words.slice(1).join(' ') : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitFirstSentence = (text: string): { head: string; rest: string } => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const m = SENTENCE_END.exec(trimmed);
|
||||||
|
if (!m) return { head: trimmed, rest: '' };
|
||||||
|
const cut = m.index + 1; // include the punctuation
|
||||||
|
return {
|
||||||
|
head: trimmed.slice(0, cut).trim(),
|
||||||
|
rest: trimmed.slice(cut).trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const labelAndHelp = (
|
||||||
|
comment: string | null | undefined,
|
||||||
|
key: string,
|
||||||
|
): { label: string; help: string } => {
|
||||||
|
if (!comment || !comment.trim()) {
|
||||||
|
return { label: humanize(key), help: '' };
|
||||||
|
}
|
||||||
|
const { head, rest } = splitFirstSentence(comment);
|
||||||
|
return {
|
||||||
|
label: head || humanize(key),
|
||||||
|
help: rest,
|
||||||
|
};
|
||||||
|
};
|
||||||
50
admin/src/components/settings/templateComments.ts
Normal file
50
admin/src/components/settings/templateComments.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Build a fallback path → comment map from `settings.json.template`. The live
|
||||||
|
// settings.json is per-developer and often lacks comments; the template is the
|
||||||
|
// authoritative source of per-key documentation.
|
||||||
|
|
||||||
|
import { parseTree, type JSONPath, type Node } from 'jsonc-parser';
|
||||||
|
import { extractAdjacentComments, type AdjacentComments } from './comments';
|
||||||
|
|
||||||
|
// Injected by Vite at build time from settings.json.template (see vite.config.ts).
|
||||||
|
// Inlining at config time avoids widening the dev server's filesystem allowlist
|
||||||
|
// to the repo root, which would expose settings.json/credentials.json over the
|
||||||
|
// dev server.
|
||||||
|
declare const __SETTINGS_TEMPLATE__: string;
|
||||||
|
const templateText: string = __SETTINGS_TEMPLATE__;
|
||||||
|
|
||||||
|
const pathKey = (path: JSONPath): string => path.map(String).join('.');
|
||||||
|
|
||||||
|
const buildMap = (text: string): Map<string, AdjacentComments> => {
|
||||||
|
const map = new Map<string, AdjacentComments>();
|
||||||
|
const tree = parseTree(text, [], { allowTrailingComma: true });
|
||||||
|
if (!tree) return map;
|
||||||
|
|
||||||
|
const walk = (node: Node, path: JSONPath) => {
|
||||||
|
if (node.type === 'object') {
|
||||||
|
for (const prop of node.children ?? []) {
|
||||||
|
if (prop.type !== 'property' || !prop.children || prop.children.length < 2) continue;
|
||||||
|
const keyNode = prop.children[0];
|
||||||
|
const valueNode = prop.children[1];
|
||||||
|
if (keyNode.type !== 'string') continue;
|
||||||
|
const childPath = [...path, String(keyNode.value)];
|
||||||
|
const adjacent = extractAdjacentComments(
|
||||||
|
text, prop.offset, valueNode.offset, valueNode.length,
|
||||||
|
);
|
||||||
|
if (adjacent.leading || adjacent.trailing) {
|
||||||
|
map.set(pathKey(childPath), adjacent);
|
||||||
|
}
|
||||||
|
walk(valueNode, childPath);
|
||||||
|
}
|
||||||
|
} else if (node.type === 'array') {
|
||||||
|
(node.children ?? []).forEach((child, i) => walk(child, [...path, i]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(tree, []);
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateMap = buildMap(templateText);
|
||||||
|
|
||||||
|
export const lookupTemplateComment = (path: JSONPath): AdjacentComments | null =>
|
||||||
|
templateMap.get(pathKey(path)) ?? null;
|
||||||
20
admin/src/components/settings/widgets/BooleanToggle.tsx
Normal file
20
admin/src/components/settings/widgets/BooleanToggle.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import * as Switch from '@radix-ui/react-switch';
|
||||||
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: boolean;
|
||||||
|
path: JSONPath;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BooleanToggle = ({ value, path, onChange }: Props) => (
|
||||||
|
<Switch.Root
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
id={`field-${path.join('.')}`}
|
||||||
|
className="settings-widget settings-widget-boolean"
|
||||||
|
data-testid={`field-${path.join('.')}`}
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="settings-widget-boolean-thumb" />
|
||||||
|
</Switch.Root>
|
||||||
|
);
|
||||||
57
admin/src/components/settings/widgets/EnvPill.tsx
Normal file
57
admin/src/components/settings/widgets/EnvPill.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
|
import type { EnvPlaceholder } from '../envPill';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
placeholder: EnvPlaceholder;
|
||||||
|
path: JSONPath;
|
||||||
|
onChange: (newDefault: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitize = (s: string) => s.replace(/[}]/g, '');
|
||||||
|
|
||||||
|
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const initial = placeholder.defaultValue ?? '';
|
||||||
|
const [draft, setDraft] = useState(initial);
|
||||||
|
const focused = useRef(false);
|
||||||
|
|
||||||
|
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
|
||||||
|
// only while the input isn't focused so we don't trample mid-typing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused.current) setDraft(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const id = `field-${path.join('.')}`;
|
||||||
|
const testid = `env-${path.join('.')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="settings-widget settings-widget-env"
|
||||||
|
title={t('admin_settings.env_pill.tooltip', { variable: placeholder.variable })}
|
||||||
|
>
|
||||||
|
<span className="settings-widget-env-icon" aria-hidden>ⓔ</span>
|
||||||
|
<span className="settings-widget-env-name">{placeholder.variable}</span>
|
||||||
|
<span className="settings-widget-env-default-label" aria-hidden>
|
||||||
|
{t('admin_settings.env_pill.default_label')}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
data-testid={testid}
|
||||||
|
className="settings-widget-env-default-input"
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label={t('admin_settings.env_pill.input_aria', { variable: placeholder.variable })}
|
||||||
|
onFocus={() => { focused.current = true; }}
|
||||||
|
onBlur={() => { focused.current = false; }}
|
||||||
|
onChange={e => {
|
||||||
|
const v = sanitize(e.target.value);
|
||||||
|
setDraft(v);
|
||||||
|
onChange(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
10
admin/src/components/settings/widgets/NullChip.tsx
Normal file
10
admin/src/components/settings/widgets/NullChip.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
|
|
||||||
|
type Props = { path: JSONPath };
|
||||||
|
|
||||||
|
export const NullChip = ({ path }: Props) => (
|
||||||
|
<span
|
||||||
|
className="settings-widget settings-widget-null"
|
||||||
|
data-testid={`field-${path.join('.')}`}
|
||||||
|
>null</span>
|
||||||
|
);
|
||||||
48
admin/src/components/settings/widgets/NumberInput.tsx
Normal file
48
admin/src/components/settings/widgets/NumberInput.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: number;
|
||||||
|
path: JSONPath;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NumberInput = ({ value, path, onChange }: Props) => {
|
||||||
|
const [draft, setDraft] = useState(String(value));
|
||||||
|
const [invalid, setInvalid] = useState(false);
|
||||||
|
const focusedRef = useRef(false);
|
||||||
|
|
||||||
|
// Sync draft when the prop value changes (e.g. after a server round-trip
|
||||||
|
// canonicalises the number) — but only when the input is not focused so we
|
||||||
|
// don't stomp on the user while they are typing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedRef.current) {
|
||||||
|
setDraft(String(value));
|
||||||
|
setInvalid(false);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
id={`field-${path.join('.')}`}
|
||||||
|
className={'settings-widget settings-widget-number' + (invalid ? ' invalid' : '')}
|
||||||
|
data-testid={`field-${path.join('.')}`}
|
||||||
|
value={draft}
|
||||||
|
onFocus={() => { focusedRef.current = true; }}
|
||||||
|
onBlur={() => { focusedRef.current = false; }}
|
||||||
|
onChange={e => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setDraft(next);
|
||||||
|
const parsed = Number(next);
|
||||||
|
if (next.trim() !== '' && Number.isFinite(parsed)) {
|
||||||
|
setInvalid(false);
|
||||||
|
onChange(parsed);
|
||||||
|
} else {
|
||||||
|
setInvalid(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
admin/src/components/settings/widgets/StringInput.tsx
Normal file
19
admin/src/components/settings/widgets/StringInput.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
path: JSONPath;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StringInput = ({ value, path, onChange }: Props) => (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={`field-${path.join('.')}`}
|
||||||
|
className="settings-widget settings-widget-string"
|
||||||
|
data-testid={`field-${path.join('.')}`}
|
||||||
|
value={value}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
1129
admin/src/index.css
1129
admin/src/index.css
File diff suppressed because it is too large
Load diff
|
|
@ -58,7 +58,7 @@ i18n
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init(
|
.init(
|
||||||
{
|
{
|
||||||
ns: ['translation','ep_admin_pads'],
|
ns: ['translation','ep_admin_pads','ep_admin_authors'],
|
||||||
fallbackLng: 'en'
|
fallbackLng: 'en'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ import * as Toast from '@radix-ui/react-toast'
|
||||||
import {I18nextProvider} from "react-i18next";
|
import {I18nextProvider} from "react-i18next";
|
||||||
import i18n from "./localization/i18n.ts";
|
import i18n from "./localization/i18n.ts";
|
||||||
import {PadPage} from "./pages/PadPage.tsx";
|
import {PadPage} from "./pages/PadPage.tsx";
|
||||||
|
import {AuthorPage} from "./pages/AuthorPage.tsx";
|
||||||
import {ToastDialog} from "./utils/Toast.tsx";
|
import {ToastDialog} from "./utils/Toast.tsx";
|
||||||
import {ShoutPage} from "./pages/ShoutPage.tsx";
|
import {ShoutPage} from "./pages/ShoutPage.tsx";
|
||||||
import {UpdatePage} from "./pages/UpdatePage.tsx";
|
import {UpdatePage} from "./pages/UpdatePage.tsx";
|
||||||
|
import {QueryProvider} from './api/QueryProvider.tsx';
|
||||||
|
|
||||||
const router = createBrowserRouter(createRoutesFromElements(
|
const router = createBrowserRouter(createRoutesFromElements(
|
||||||
<><Route element={<App/>}>
|
<><Route element={<App/>}>
|
||||||
|
|
@ -22,6 +24,7 @@ const router = createBrowserRouter(createRoutesFromElements(
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/help" element={<HelpPage/>}/>
|
<Route path="/help" element={<HelpPage/>}/>
|
||||||
<Route path="/pads" element={<PadPage/>}/>
|
<Route path="/pads" element={<PadPage/>}/>
|
||||||
|
<Route path="/authors" element={<AuthorPage/>}/>
|
||||||
<Route path="/shout" element={<ShoutPage/>}/>
|
<Route path="/shout" element={<ShoutPage/>}/>
|
||||||
<Route path="/update" element={<UpdatePage/>}/>
|
<Route path="/update" element={<UpdatePage/>}/>
|
||||||
</Route><Route path="/login">
|
</Route><Route path="/login">
|
||||||
|
|
@ -34,11 +37,13 @@ const router = createBrowserRouter(createRoutesFromElements(
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<QueryProvider>
|
||||||
<Toast.Provider>
|
<I18nextProvider i18n={i18n}>
|
||||||
<ToastDialog/>
|
<Toast.Provider>
|
||||||
<RouterProvider router={router}/>
|
<ToastDialog/>
|
||||||
</Toast.Provider>
|
<RouterProvider router={router}/>
|
||||||
</I18nextProvider>
|
</Toast.Provider>
|
||||||
|
</I18nextProvider>
|
||||||
|
</QueryProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
295
admin/src/pages/AuthorPage.tsx
Normal file
295
admin/src/pages/AuthorPage.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import {ChevronLeft, ChevronRight, Trash2} from "lucide-react";
|
||||||
|
import {useStore} from "../store/store.ts";
|
||||||
|
import {SearchField} from "../components/SearchField.tsx";
|
||||||
|
import {ColorSwatch} from "../components/ColorSwatch.tsx";
|
||||||
|
import {IconButton} from "../components/IconButton.tsx";
|
||||||
|
import {determineSorting} from "../utils/sorting.ts";
|
||||||
|
import {useDebounce} from "../utils/useDebounce.ts";
|
||||||
|
import {
|
||||||
|
AnonymizePreview, AnonymizeResult, AuthorRow, AuthorSearchQuery,
|
||||||
|
AuthorSearchResult, AuthorSortBy,
|
||||||
|
} from "../utils/AuthorSearch.ts";
|
||||||
|
|
||||||
|
type DialogState =
|
||||||
|
| {phase: 'closed'}
|
||||||
|
| {phase: 'loading-preview', authorID: string, name: string | null}
|
||||||
|
| {phase: 'preview', preview: AnonymizePreview}
|
||||||
|
| {phase: 'committing', preview: AnonymizePreview};
|
||||||
|
|
||||||
|
export const AuthorPage = () => {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const settingsSocket = useStore((s) => s.settingsSocket);
|
||||||
|
const authors = useStore((s) => s.authors);
|
||||||
|
const setAuthors = useStore((s) => s.setAuthors);
|
||||||
|
const erasureEnabled = useStore((s) => s.gdprAuthorErasureEnabled);
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [includeErased, setIncludeErased] = useState(false);
|
||||||
|
const [searchParams, setSearchParams] = useState<AuthorSearchQuery>({
|
||||||
|
pattern: '', offset: 0, limit: 12,
|
||||||
|
sortBy: 'name', ascending: true, includeErased: false,
|
||||||
|
});
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [dialog, setDialog] = useState<DialogState>({phase: 'closed'});
|
||||||
|
|
||||||
|
const pages = useMemo(() => {
|
||||||
|
if (!authors) return 0;
|
||||||
|
return Math.ceil(authors.total / searchParams.limit);
|
||||||
|
}, [authors, searchParams.limit]);
|
||||||
|
|
||||||
|
useDebounce(() => {
|
||||||
|
setCurrentPage(0);
|
||||||
|
setSearchParams((p) => ({...p, pattern: searchTerm, offset: 0}));
|
||||||
|
}, 500, [searchTerm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParams((p) => ({...p, includeErased, offset: 0}));
|
||||||
|
setCurrentPage(0);
|
||||||
|
}, [includeErased]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsSocket) return;
|
||||||
|
settingsSocket.emit('authorLoad', searchParams);
|
||||||
|
}, [settingsSocket, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsSocket) return;
|
||||||
|
const onLoad = (data: AuthorSearchResult) => setAuthors(data);
|
||||||
|
const onPreview = (data: AnonymizePreview) => {
|
||||||
|
if (data.error) {
|
||||||
|
useStore.getState().setToastState({
|
||||||
|
open: true, success: false,
|
||||||
|
title: t('ep_admin_authors:erase-error-toast', {error: data.error}),
|
||||||
|
});
|
||||||
|
setDialog({phase: 'closed'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDialog((cur) =>
|
||||||
|
cur.phase === 'loading-preview' && cur.authorID === data.authorID
|
||||||
|
? {phase: 'preview', preview: data}
|
||||||
|
: cur);
|
||||||
|
};
|
||||||
|
const onErase = (data: AnonymizeResult) => {
|
||||||
|
if (data.error) {
|
||||||
|
useStore.getState().setToastState({
|
||||||
|
open: true, success: false,
|
||||||
|
title: t('ep_admin_authors:erase-error-toast', {error: data.error}),
|
||||||
|
});
|
||||||
|
setDialog({phase: 'closed'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useStore.getState().setToastState({
|
||||||
|
open: true, success: true,
|
||||||
|
title: t('ep_admin_authors:erase-success-toast', {authorID: data.authorID}),
|
||||||
|
});
|
||||||
|
const cur = useStore.getState().authors;
|
||||||
|
if (cur) {
|
||||||
|
setAuthors({
|
||||||
|
...cur,
|
||||||
|
results: cur.results.map((r): AuthorRow =>
|
||||||
|
r.authorID === data.authorID
|
||||||
|
? {...r, name: null, erased: true, mapper: []}
|
||||||
|
: r),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDialog({phase: 'closed'});
|
||||||
|
};
|
||||||
|
settingsSocket.on('results:authorLoad', onLoad);
|
||||||
|
settingsSocket.on('results:anonymizeAuthorPreview', onPreview);
|
||||||
|
settingsSocket.on('results:anonymizeAuthor', onErase);
|
||||||
|
return () => {
|
||||||
|
settingsSocket.off('results:authorLoad', onLoad);
|
||||||
|
settingsSocket.off('results:anonymizeAuthorPreview', onPreview);
|
||||||
|
settingsSocket.off('results:anonymizeAuthor', onErase);
|
||||||
|
};
|
||||||
|
}, [settingsSocket, setAuthors, t]);
|
||||||
|
|
||||||
|
const sortBy = (col: AuthorSortBy) => () => {
|
||||||
|
setCurrentPage(0);
|
||||||
|
setSearchParams((p) => ({
|
||||||
|
...p, sortBy: col,
|
||||||
|
ascending: p.sortBy === col ? !p.ascending : true,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openErase = (row: AuthorRow) => {
|
||||||
|
setDialog({phase: 'loading-preview', authorID: row.authorID, name: row.name});
|
||||||
|
settingsSocket?.emit('anonymizeAuthorPreview', {authorID: row.authorID});
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitErase = () => {
|
||||||
|
if (dialog.phase !== 'preview') return;
|
||||||
|
setDialog({phase: 'committing', preview: dialog.preview});
|
||||||
|
settingsSocket?.emit('anonymizeAuthor', {authorID: dialog.preview.authorID});
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastSeenLabel = (row: AuthorRow) =>
|
||||||
|
row.lastSeen
|
||||||
|
? new Date(row.lastSeen).toLocaleString()
|
||||||
|
: t('ep_admin_authors:never-seen');
|
||||||
|
|
||||||
|
const mapperLabel = (row: AuthorRow) => {
|
||||||
|
if (row.mapper.length === 0) return t('ep_admin_authors:no-mappers');
|
||||||
|
if (row.mapper.length === 1) return row.mapper[0];
|
||||||
|
return `${row.mapper[0]} +${row.mapper.length - 1}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
{!erasureEnabled && (
|
||||||
|
<div role="alert"
|
||||||
|
style={{margin: '0 0 12px', padding: '12px',
|
||||||
|
background: '#fff8e1', border: '1px solid #f0c36d',
|
||||||
|
borderRadius: 4}}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:feature-disabled-banner"
|
||||||
|
ns="ep_admin_authors"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog.Root open={dialog.phase !== 'closed'}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
|
{dialog.phase === 'loading-preview' && <div>
|
||||||
|
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
|
||||||
|
</div>}
|
||||||
|
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
|
||||||
|
const p = dialog.preview;
|
||||||
|
return <div>
|
||||||
|
<Dialog.Title asChild>
|
||||||
|
<h3>{t('ep_admin_authors:confirm-preview-title',
|
||||||
|
{name: p.name || p.authorID})}</h3>
|
||||||
|
</Dialog.Title>
|
||||||
|
<p>{t('ep_admin_authors:confirm-preview-counters', {
|
||||||
|
tokenMappings: p.removedTokenMappings,
|
||||||
|
externalMappings: p.removedExternalMappings,
|
||||||
|
chatMessages: p.clearedChatMessages,
|
||||||
|
affectedPads: p.affectedPads,
|
||||||
|
})}</p>
|
||||||
|
<p><strong>
|
||||||
|
<Trans i18nKey="ep_admin_authors:confirm-irreversible"
|
||||||
|
ns="ep_admin_authors"/>
|
||||||
|
</strong></p>
|
||||||
|
<div className="settings-button-bar">
|
||||||
|
<button onClick={() => setDialog({phase: 'closed'})}
|
||||||
|
disabled={dialog.phase === 'committing'}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:cancel"
|
||||||
|
ns="ep_admin_authors"/>
|
||||||
|
</button>
|
||||||
|
<button onClick={commitErase}
|
||||||
|
disabled={dialog.phase === 'committing' || !erasureEnabled}
|
||||||
|
title={erasureEnabled ? undefined :
|
||||||
|
t('ep_admin_authors:erase-disabled-tooltip')}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:continue"
|
||||||
|
ns="ep_admin_authors"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{dialog.phase === 'committing' && <p style={{marginTop: 8}}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:erasing"
|
||||||
|
ns="ep_admin_authors"/>
|
||||||
|
</p>}
|
||||||
|
</div>;
|
||||||
|
})()}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<span className="manage-pads-header">
|
||||||
|
<h1>
|
||||||
|
<Trans i18nKey="ep_admin_authors:title" ns="ep_admin_authors"/>
|
||||||
|
</h1>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SearchField value={searchTerm}
|
||||||
|
onChange={(v) => setSearchTerm(v.target.value)}
|
||||||
|
placeholder={t('ep_admin_authors:search-placeholder')}/>
|
||||||
|
|
||||||
|
<label style={{display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
margin: '8px 0'}}>
|
||||||
|
<input type="checkbox" checked={includeErased}
|
||||||
|
onChange={(e) => setIncludeErased(e.target.checked)}/>
|
||||||
|
<Trans i18nKey="ep_admin_authors:show-erased" ns="ep_admin_authors"/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{authors?.cappedAt != null && (
|
||||||
|
<p style={{color: '#a35'}}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:cap-warning" ns="ep_admin_authors"/>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr className="search-pads">
|
||||||
|
<th><Trans i18nKey="ep_admin_authors:column.color" ns="ep_admin_authors"/></th>
|
||||||
|
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'name')}
|
||||||
|
onClick={sortBy('name')}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:column.name" ns="ep_admin_authors"/>
|
||||||
|
</th>
|
||||||
|
<th><Trans i18nKey="ep_admin_authors:column.mapper" ns="ep_admin_authors"/></th>
|
||||||
|
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastSeen')}
|
||||||
|
onClick={sortBy('lastSeen')}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:column.last-seen" ns="ep_admin_authors"/>
|
||||||
|
</th>
|
||||||
|
<th><Trans i18nKey="ep_admin_authors:column.author-id" ns="ep_admin_authors"/></th>
|
||||||
|
<th><Trans i18nKey="ep_admin_authors:column.actions" ns="ep_admin_authors"/></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="search-pads-body">
|
||||||
|
{authors?.results.length === 0 && <tr><td colSpan={6}
|
||||||
|
style={{textAlign: 'center', padding: '12px'}}>
|
||||||
|
<Trans i18nKey="ep_admin_authors:no-results" ns="ep_admin_authors"/>
|
||||||
|
</td></tr>}
|
||||||
|
{authors?.results.map((row) => (
|
||||||
|
<tr key={row.authorID}>
|
||||||
|
<td style={{textAlign: 'center'}}><ColorSwatch color={row.colorId}/></td>
|
||||||
|
<td style={{textAlign: 'center'}}>
|
||||||
|
{row.erased
|
||||||
|
? <em><Trans i18nKey="ep_admin_authors:erased-stub"
|
||||||
|
ns="ep_admin_authors"/></em>
|
||||||
|
: (row.name ?? '—')}
|
||||||
|
</td>
|
||||||
|
<td style={{textAlign: 'center'}} title={row.mapper.join(', ')}>
|
||||||
|
{mapperLabel(row)}
|
||||||
|
</td>
|
||||||
|
<td style={{textAlign: 'center'}}>{lastSeenLabel(row)}</td>
|
||||||
|
<td style={{textAlign: 'center', fontFamily: 'monospace'}}>
|
||||||
|
{row.authorID}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="settings-button-bar">
|
||||||
|
<IconButton icon={<Trash2/>}
|
||||||
|
title={<Trans i18nKey="ep_admin_authors:erase"
|
||||||
|
ns="ep_admin_authors"/>}
|
||||||
|
onClick={() => openErase(row)}
|
||||||
|
disabled={!erasureEnabled || row.erased}/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="settings-button-bar pad-pagination">
|
||||||
|
<button disabled={currentPage === 0} onClick={() => {
|
||||||
|
setCurrentPage(currentPage - 1);
|
||||||
|
setSearchParams((p) => ({...p,
|
||||||
|
offset: (currentPage - 1) * searchParams.limit}));
|
||||||
|
}}><ChevronLeft/><span>
|
||||||
|
<Trans i18nKey="ep_admin_authors:prev-page" ns="ep_admin_authors"/>
|
||||||
|
</span></button>
|
||||||
|
<span>{t('ep_admin_authors:page-counter',
|
||||||
|
{current: currentPage + 1, total: pages})}</span>
|
||||||
|
<button disabled={pages === 0 || pages === currentPage + 1} onClick={() => {
|
||||||
|
const next = currentPage + 1;
|
||||||
|
setCurrentPage(next);
|
||||||
|
setSearchParams((p) => ({...p,
|
||||||
|
offset: next * searchParams.limit}));
|
||||||
|
}}><span>
|
||||||
|
<Trans i18nKey="ep_admin_authors:next-page" ns="ep_admin_authors"/>
|
||||||
|
</span><ChevronRight/></button>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
@ -1,70 +1,225 @@
|
||||||
import {Trans} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
import {useStore} from "../store/store.ts";
|
import {useStore} from "../store/store.ts";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {HelpObj} from "./Plugin.ts";
|
import {HelpObj} from "./Plugin.ts";
|
||||||
|
import {Copy, Search, X, Plug} from "lucide-react";
|
||||||
|
|
||||||
export const HelpPage = () => {
|
export const HelpPage = () => {
|
||||||
const settingsSocket = useStore(state=>state.settingsSocket)
|
const settingsSocket = useStore(state => state.settingsSocket)
|
||||||
const [helpData, setHelpData] = useState<HelpObj>();
|
const {t} = useTranslation()
|
||||||
|
const [helpData, setHelpData] = useState<HelpObj>()
|
||||||
|
const [tab, setTab] = useState<'server' | 'client'>('server')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(!settingsSocket) return;
|
if (!settingsSocket) return
|
||||||
settingsSocket?.on('reply:help', (data) => {
|
settingsSocket.on('reply:help', (data) => setHelpData(data))
|
||||||
setHelpData(data)
|
settingsSocket.emit('help')
|
||||||
});
|
}, [settingsSocket])
|
||||||
|
|
||||||
settingsSocket?.emit('help');
|
const serverHooks = useMemo(() => {
|
||||||
}, [settingsSocket]);
|
if (!helpData) return []
|
||||||
|
return Object.keys(helpData.installedServerHooks).map(hookName => ({
|
||||||
|
name: hookName,
|
||||||
|
parts: Object.keys((helpData.installedServerHooks as Record<string, Record<string, unknown>>)[hookName] ?? {}),
|
||||||
|
}))
|
||||||
|
}, [helpData])
|
||||||
|
|
||||||
const renderHooks = (hooks:Record<string, Record<string, string>>) => {
|
const clientHooks = useMemo(() => {
|
||||||
return Object.keys(hooks).map((hookName, i) => {
|
if (!helpData) return []
|
||||||
return <div key={hookName+i}>
|
return Object.keys(helpData.installedClientHooks).map(hookName => ({
|
||||||
<h3>{hookName}</h3>
|
name: hookName,
|
||||||
<ul>
|
parts: Object.keys(helpData.installedClientHooks[hookName] ?? {}),
|
||||||
{Object.keys(hooks[hookName]).map((hook, i) => <li key={hook+i}>{hook}
|
}))
|
||||||
<ul key={hookName+hook+i}>
|
}, [helpData])
|
||||||
{Object.keys(hooks[hookName][hook]).map((subHook, i) => <li key={i}>{subHook}</li>)}
|
|
||||||
</ul>
|
const hooks = tab === 'server' ? serverHooks : clientHooks
|
||||||
</li>)}
|
|
||||||
</ul>
|
const filteredHooks = useMemo(() => {
|
||||||
</div>
|
if (!q.trim()) return hooks
|
||||||
})
|
const s = q.toLowerCase()
|
||||||
|
return hooks.filter(h =>
|
||||||
|
h.name.toLowerCase().includes(s) || h.parts.some(p => p.toLowerCase().includes(s))
|
||||||
|
)
|
||||||
|
}, [hooks, q])
|
||||||
|
|
||||||
|
const totalBindings = hooks.reduce((n, h) => n + h.parts.length, 0)
|
||||||
|
|
||||||
|
const updateAvailable = helpData
|
||||||
|
? helpData.epVersion.localeCompare(helpData.latestVersion, undefined, {numeric: true}) < 0
|
||||||
|
: false
|
||||||
|
|
||||||
|
const copyDiag = () => {
|
||||||
|
if (!helpData) return
|
||||||
|
navigator.clipboard?.writeText(JSON.stringify({
|
||||||
|
version: helpData.epVersion,
|
||||||
|
latestVersion: helpData.latestVersion,
|
||||||
|
gitCommit: helpData.gitCommit,
|
||||||
|
plugins: helpData.installedPlugins.length,
|
||||||
|
parts: helpData.installedParts.length,
|
||||||
|
hookBindings: totalBindings,
|
||||||
|
}, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!helpData) return (
|
||||||
if (!helpData) return <div></div>
|
<div className="pm-page">
|
||||||
|
<div className="pm-empty"><div className="pm-empty-icon">⋯</div></div>
|
||||||
return <div>
|
|
||||||
<h1><Trans i18nKey="admin_plugins_info.version"/></h1>
|
|
||||||
<div className="help-block">
|
|
||||||
<div><Trans i18nKey="admin_plugins_info.version_number"/></div>
|
|
||||||
<div>{helpData?.epVersion}</div>
|
|
||||||
<div><Trans i18nKey="admin_plugins_info.version_latest"/></div>
|
|
||||||
<div>{helpData.latestVersion}</div>
|
|
||||||
<div>Git sha</div>
|
|
||||||
<div>{helpData.gitCommit}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
|
)
|
||||||
<ul>
|
|
||||||
{helpData.installedPlugins.map((plugin, i) => <li key={plugin+i}>{plugin}</li>)}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
|
return (
|
||||||
<ul>
|
<div className="pm-page">
|
||||||
{helpData.installedParts.map((part, i) => <li key={part+i}>{part}</li>)}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
|
{/* ── Page header ── */}
|
||||||
{
|
<div className="pm-header">
|
||||||
renderHooks(helpData.installedServerHooks)
|
<div>
|
||||||
}
|
<div className="pm-crumbs">Admin <span className="pm-crumbs-sep">›</span> <Trans i18nKey="admin_plugins_info"/></div>
|
||||||
|
<h1 className="pm-title"><Trans i18nKey="admin_plugins_info"/></h1>
|
||||||
|
<p className="pm-subtitle"><Trans i18nKey="admin_plugins_info.subtitle"/></p>
|
||||||
|
</div>
|
||||||
|
<div className="pm-header-actions">
|
||||||
|
<button className="pm-btn pm-btn-ghost" onClick={copyDiag}>
|
||||||
|
<Copy size={14}/> <Trans i18nKey="admin_plugins_info.copy_diagnostics"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>
|
{/* ── Version block ── */}
|
||||||
<Trans i18nKey="admin_plugins_info.hooks_client"/>
|
<section className="pm-help-version">
|
||||||
{
|
<div className="pm-hv-main">
|
||||||
renderHooks(helpData.installedClientHooks)
|
<div className="pm-hv-lbl"><Trans i18nKey="admin_plugins_info.version"/></div>
|
||||||
}
|
<div className="pm-hv-num">{helpData.epVersion}</div>
|
||||||
</h2>
|
<div className={`pm-hv-status${updateAvailable ? ' is-warn' : ' is-ok'}`}>
|
||||||
|
<span className="pm-hv-dot"/>
|
||||||
|
{updateAvailable
|
||||||
|
? t('admin_plugins_info.update_available', {version: helpData.latestVersion})
|
||||||
|
: t('admin_plugins_info.up_to_date')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pm-hv-meta">
|
||||||
|
<div className="pm-hv-cell">
|
||||||
|
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.version_latest"/></div>
|
||||||
|
<div className="pm-hv-cell-val">{helpData.latestVersion}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pm-hv-cell">
|
||||||
|
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.git_sha"/></div>
|
||||||
|
<div className="pm-hv-cell-val pm-mono">
|
||||||
|
{helpData.gitCommit}
|
||||||
|
<button
|
||||||
|
className="pm-mini-btn"
|
||||||
|
onClick={() => navigator.clipboard?.writeText(helpData.gitCommit)}
|
||||||
|
title={t('admin_plugins_info.copy_value', {label: t('admin_plugins_info.git_sha')})}
|
||||||
|
>
|
||||||
|
<Copy size={11}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pm-hv-cell">
|
||||||
|
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins.installed"/></div>
|
||||||
|
<div className="pm-hv-cell-val">{helpData.installedPlugins.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pm-hv-cell">
|
||||||
|
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.parts"/></div>
|
||||||
|
<div className="pm-hv-cell-val">{helpData.installedParts.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pm-hv-cell">
|
||||||
|
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.hook_bindings"/></div>
|
||||||
|
<div className="pm-hv-cell-val">{totalBindings}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
{/* ── Plugins + Parts ── */}
|
||||||
|
<section className="pm-section">
|
||||||
|
<div className="pm-help-grid">
|
||||||
|
<div className="pm-help-card">
|
||||||
|
<div className="pm-section-header pm-sec-tight">
|
||||||
|
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
|
||||||
|
<span className="pm-count-badge">{helpData.installedPlugins.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pm-tag-cloud">
|
||||||
|
{helpData.installedPlugins.map(p => (
|
||||||
|
<span key={p} className="pm-pill pm-pill-mono">
|
||||||
|
<span className="pm-pill-ico"><Plug size={11}/></span>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pm-help-card">
|
||||||
|
<div className="pm-section-header pm-sec-tight">
|
||||||
|
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
|
||||||
|
<span className="pm-count-badge">{helpData.installedParts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pm-tag-cloud">
|
||||||
|
{helpData.installedParts.map(p => {
|
||||||
|
const slash = p.indexOf('/')
|
||||||
|
const ns = slash >= 0 ? p.slice(0, slash) : p
|
||||||
|
const name = slash >= 0 ? p.slice(slash + 1) : ''
|
||||||
|
return (
|
||||||
|
<span key={p} className="pm-pill pm-pill-mono" title={p}>
|
||||||
|
<span className="pm-pill-ns">{ns}</span>
|
||||||
|
{name && <><span className="pm-pill-sep">/</span><span>{name}</span></>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Hooks ── */}
|
||||||
|
<section className="pm-section">
|
||||||
|
<div className="pm-section-header">
|
||||||
|
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
|
||||||
|
<span className="pm-count-badge">{filteredHooks.length}</span>
|
||||||
|
<div className="pm-spacer"/>
|
||||||
|
<div className="pm-toolbar">
|
||||||
|
<div className="pm-tabs">
|
||||||
|
<button className={`pm-tab${tab === 'server' ? ' is-on' : ''}`} onClick={() => setTab('server')}>
|
||||||
|
<Trans i18nKey="admin_plugins_info.tab_server"/> <span className="pm-tab-n">{serverHooks.length}</span>
|
||||||
|
</button>
|
||||||
|
<button className={`pm-tab${tab === 'client' ? ' is-on' : ''}`} onClick={() => setTab('client')}>
|
||||||
|
<Trans i18nKey="admin_plugins_info.tab_client"/> <span className="pm-tab-n">{clientHooks.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pm-search">
|
||||||
|
<Search size={14} className="pm-search-icon"/>
|
||||||
|
<input
|
||||||
|
className="pm-search-input"
|
||||||
|
value={q}
|
||||||
|
onChange={e => setQ(e.target.value)}
|
||||||
|
placeholder={t('admin_plugins_info.search_placeholder')}
|
||||||
|
/>
|
||||||
|
{q && <button className="pm-search-clear" onClick={() => setQ('')}><X size={12}/></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredHooks.length > 0 ? (
|
||||||
|
<div className="pm-hooks">
|
||||||
|
{filteredHooks.map(h => (
|
||||||
|
<div key={h.name} className="pm-hook">
|
||||||
|
<div className="pm-hook-h">
|
||||||
|
<span className="pm-hook-name">{h.name}</span>
|
||||||
|
<span className="pm-hook-count">{t('admin_plugins_info.bindings_label', {count: h.parts.length})}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pm-hook-parts">
|
||||||
|
{h.parts.map(p => (
|
||||||
|
<span key={p} className="pm-pill pm-pill-mono pm-pill-sm">{p}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pm-empty">
|
||||||
|
<div className="pm-empty-icon">∅</div>
|
||||||
|
<div className="pm-empty-title"><Trans i18nKey="admin_plugins_info.no_hooks"/></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,269 +3,400 @@ import {useEffect, useMemo, useState} from "react";
|
||||||
import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts";
|
import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts";
|
||||||
import {useDebounce} from "../utils/useDebounce.ts";
|
import {useDebounce} from "../utils/useDebounce.ts";
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
import {SearchField} from "../components/SearchField.tsx";
|
import {ArrowUpFromDot, Download, ExternalLink, Plug, RefreshCw, Search, Trash, X} from "lucide-react";
|
||||||
import {ArrowUpFromDot, Download, Trash} from "lucide-react";
|
|
||||||
import {IconButton} from "../components/IconButton.tsx";
|
import {IconButton} from "../components/IconButton.tsx";
|
||||||
import {determineSorting} from "../utils/sorting.ts";
|
|
||||||
|
|
||||||
|
|
||||||
export const HomePage = () => {
|
export const HomePage = () => {
|
||||||
const pluginsSocket = useStore(state=>state.pluginsSocket)
|
const pluginsSocket = useStore(state => state.pluginsSocket)
|
||||||
const [plugins,setPlugins] = useState<PluginDef[]>([])
|
const [plugins, setPlugins] = useState<PluginDef[]>([])
|
||||||
const installedPlugins = useStore(state=>state.installedPlugins)
|
const [catalogDisabled, setCatalogDisabled] = useState(false)
|
||||||
const setInstalledPlugins = useStore(state=>state.setInstalledPlugins)
|
const installedPlugins = useStore(state => state.installedPlugins)
|
||||||
|
const setInstalledPlugins = useStore(state => state.setInstalledPlugins)
|
||||||
|
// Default sort: name ascending. PR #7716 set this to "downloads desc" but
|
||||||
|
// the backend (src/static/js/pluginfw/installer.ts) never populates
|
||||||
|
// `downloads`, so the "Most popular" sort/"Popular" tag/Downloads column
|
||||||
|
// were dead UI — removed alongside this default.
|
||||||
const [searchParams, setSearchParams] = useState<SearchParams>({
|
const [searchParams, setSearchParams] = useState<SearchParams>({
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 99999,
|
limit: 99999,
|
||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortDir: 'asc',
|
sortDir: 'asc',
|
||||||
searchTerm: ''
|
searchTerm: '',
|
||||||
})
|
})
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||||
|
const {t} = useTranslation()
|
||||||
|
|
||||||
const filteredInstallablePlugins = useMemo(()=>{
|
const updatableCount = useMemo(
|
||||||
return plugins.sort((a, b)=>{
|
() => installedPlugins.filter(p => p.updatable).length,
|
||||||
if(searchParams.sortBy === "version"){
|
[installedPlugins]
|
||||||
if(searchParams.sortDir === "asc"){
|
)
|
||||||
return a.version.localeCompare(b.version)
|
|
||||||
}
|
// "Core" plugins are the ones Etherpad ships as part of the runtime
|
||||||
return b.version.localeCompare(a.version)
|
// (currently just ep_etherpad-lite). Derive from data rather than
|
||||||
|
// hardcoding 1 — future packaging changes may bundle more.
|
||||||
|
const coreCount = useMemo(
|
||||||
|
() => installedPlugins.filter(p => p.name === 'ep_etherpad-lite').length,
|
||||||
|
[installedPlugins]
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedInstalledPlugins = useMemo(
|
||||||
|
() => [...installedPlugins].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[installedPlugins]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredInstallablePlugins = useMemo(() => {
|
||||||
|
return [...plugins].sort((a, b) => {
|
||||||
|
const dir = searchParams.sortDir === 'asc' ? 1 : -1
|
||||||
|
if (searchParams.sortBy === 'version') {
|
||||||
|
return a.version.localeCompare(b.version) * dir
|
||||||
}
|
}
|
||||||
|
if (searchParams.sortBy === 'last-updated') {
|
||||||
if(searchParams.sortBy === "last-updated"){
|
return a.time.localeCompare(b.time) * dir
|
||||||
if(searchParams.sortDir === "asc"){
|
|
||||||
return a.time.localeCompare(b.time)
|
|
||||||
}
|
|
||||||
return b.time.localeCompare(a.time)
|
|
||||||
}
|
}
|
||||||
|
return a.name.localeCompare(b.name) * dir
|
||||||
|
|
||||||
if (searchParams.sortBy === "name") {
|
|
||||||
if(searchParams.sortDir === "asc"){
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
}
|
|
||||||
return b.name.localeCompare(a.name)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
})
|
||||||
}, [plugins, searchParams])
|
}, [plugins, searchParams])
|
||||||
|
|
||||||
const sortedInstalledPlugins = useMemo(()=>{
|
useEffect(() => {
|
||||||
return useStore.getState().installedPlugins.sort((a, b)=>{
|
if (!pluginsSocket) return
|
||||||
|
|
||||||
if(a.name < b.name){
|
const onInstalled = (data: {installed: InstalledPlugin[]}) => {
|
||||||
return -1
|
setInstalledPlugins(data.installed)
|
||||||
}
|
}
|
||||||
if(a.name > b.name){
|
const onUpdatable = (data: {updatable: string[]}) => {
|
||||||
return 1
|
const updated = useStore.getState().installedPlugins.map(plugin =>
|
||||||
}
|
data.updatable.includes(plugin.name) ? {...plugin, updatable: true} : plugin
|
||||||
return 0
|
)
|
||||||
|
setInstalledPlugins(updated)
|
||||||
|
}
|
||||||
|
const onFinishedInstall = (data: {plugin: string; code?: string | null; error?: string | null}) => {
|
||||||
|
if (data?.error) {
|
||||||
|
const key = data.code === 'PLUGIN_REQUIRES_NEWER_ETHERPAD'
|
||||||
|
? 'admin_plugins.install_error_requires_newer_etherpad'
|
||||||
|
: 'admin_plugins.install_error'
|
||||||
|
useStore.getState().setToastState({
|
||||||
|
open: true,
|
||||||
|
title: t(key, {plugin: data.plugin, error: data.error}),
|
||||||
|
success: false,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
} ,[installedPlugins, searchParams])
|
pluginsSocket.emit('getInstalled')
|
||||||
|
}
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
const onFinishedUninstall = () => {
|
||||||
const {t} = useTranslation()
|
console.log('Finished uninstall')
|
||||||
|
}
|
||||||
|
const onConnect = () => {
|
||||||
useEffect(() => {
|
pluginsSocket.emit('getInstalled')
|
||||||
if(!pluginsSocket){
|
pluginsSocket.emit('search', searchParams)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginsSocket.on('results:installed', (data:{
|
|
||||||
installed: InstalledPlugin[]
|
|
||||||
})=>{
|
|
||||||
setInstalledPlugins(data.installed)
|
|
||||||
})
|
|
||||||
|
|
||||||
pluginsSocket.on('results:updatable', (data) => {
|
|
||||||
const newInstalledPlugins = useStore.getState().installedPlugins.map(plugin => {
|
|
||||||
if (data.updatable.includes(plugin.name)) {
|
|
||||||
return {
|
|
||||||
...plugin,
|
|
||||||
updatable: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plugin
|
|
||||||
})
|
|
||||||
setInstalledPlugins(newInstalledPlugins)
|
|
||||||
})
|
|
||||||
|
|
||||||
pluginsSocket.on('finished:install', () => {
|
|
||||||
pluginsSocket!.emit('getInstalled');
|
|
||||||
})
|
|
||||||
|
|
||||||
pluginsSocket.on('finished:uninstall', () => {
|
|
||||||
console.log("Finished uninstall")
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// Reload on reconnect
|
|
||||||
pluginsSocket.on('connect', ()=>{
|
|
||||||
// Initial retrieval of installed plugins
|
|
||||||
pluginsSocket.emit('getInstalled');
|
|
||||||
pluginsSocket.emit('search', searchParams)
|
|
||||||
})
|
|
||||||
|
|
||||||
pluginsSocket.emit('getInstalled');
|
|
||||||
|
|
||||||
// check for updates every 5mins
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
pluginsSocket.emit('checkUpdates');
|
|
||||||
}, 1000 * 60 * 5);
|
|
||||||
|
|
||||||
return ()=>{
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [pluginsSocket]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pluginsSocket) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pluginsSocket?.emit('search', searchParams)
|
|
||||||
pluginsSocket!.on('results:search', (data: {
|
|
||||||
results: PluginDef[]
|
|
||||||
}) => {
|
|
||||||
setPlugins(data.results)
|
|
||||||
})
|
|
||||||
pluginsSocket!.on('results:searcherror', (data: {error: string}) => {
|
|
||||||
console.log(data.error)
|
|
||||||
useStore.getState().setToastState({
|
|
||||||
open: true,
|
|
||||||
title: "Error retrieving plugins",
|
|
||||||
success: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [searchParams, pluginsSocket]);
|
|
||||||
|
|
||||||
const uninstallPlugin = (pluginName: string)=>{
|
|
||||||
pluginsSocket!.emit('uninstall', pluginName);
|
|
||||||
// Remove plugin
|
|
||||||
setInstalledPlugins(installedPlugins.filter(i=>i.name !== pluginName))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const installPlugin = (pluginName: string)=>{
|
const onCatalogDisabled = () => setCatalogDisabled(true)
|
||||||
pluginsSocket!.emit('install', pluginName);
|
|
||||||
setPlugins(plugins.filter(plugin=>plugin.name !== pluginName))
|
pluginsSocket.on('results:installed', onInstalled)
|
||||||
|
pluginsSocket.on('results:updatable', onUpdatable)
|
||||||
|
pluginsSocket.on('finished:install', onFinishedInstall)
|
||||||
|
pluginsSocket.on('finished:uninstall', onFinishedUninstall)
|
||||||
|
pluginsSocket.on('connect', onConnect)
|
||||||
|
pluginsSocket.on('results:catalogDisabled', onCatalogDisabled)
|
||||||
|
|
||||||
|
pluginsSocket.emit('getInstalled')
|
||||||
|
|
||||||
|
const interval = setInterval(() => pluginsSocket.emit('checkUpdates'), 1000 * 60 * 5)
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
pluginsSocket.off('results:installed', onInstalled)
|
||||||
|
pluginsSocket.off('results:updatable', onUpdatable)
|
||||||
|
pluginsSocket.off('finished:install', onFinishedInstall)
|
||||||
|
pluginsSocket.off('finished:uninstall', onFinishedUninstall)
|
||||||
|
pluginsSocket.off('connect', onConnect)
|
||||||
|
pluginsSocket.off('results:catalogDisabled', onCatalogDisabled)
|
||||||
|
}
|
||||||
|
}, [pluginsSocket])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginsSocket) return
|
||||||
|
|
||||||
|
const onSearchResults = (data: {results: PluginDef[]}) => {
|
||||||
|
setPlugins(data.results)
|
||||||
|
}
|
||||||
|
const onSearchError = () => {
|
||||||
|
useStore.getState().setToastState({open: true, title: t('admin_plugins.error_retrieving'), success: false})
|
||||||
}
|
}
|
||||||
|
|
||||||
useDebounce(()=>{
|
pluginsSocket.emit('search', searchParams)
|
||||||
setSearchParams({
|
pluginsSocket.on('results:search', onSearchResults)
|
||||||
...searchParams,
|
pluginsSocket.on('results:searcherror', onSearchError)
|
||||||
offset: 0,
|
|
||||||
searchTerm: searchTerm
|
|
||||||
})
|
|
||||||
}, 500, [searchTerm])
|
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pluginsSocket.off('results:search', onSearchResults)
|
||||||
|
pluginsSocket.off('results:searcherror', onSearchError)
|
||||||
|
}
|
||||||
|
}, [searchParams, pluginsSocket])
|
||||||
|
|
||||||
return <div>
|
const uninstallPlugin = (pluginName: string) => {
|
||||||
<h1><Trans i18nKey="admin_plugins"/></h1>
|
pluginsSocket!.emit('uninstall', pluginName)
|
||||||
|
setInstalledPlugins(installedPlugins.filter(i => i.name !== pluginName))
|
||||||
|
}
|
||||||
|
|
||||||
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
|
const installPlugin = (pluginName: string) => {
|
||||||
|
pluginsSocket!.emit('install', pluginName)
|
||||||
|
setPlugins(plugins.filter(p => p.name !== pluginName))
|
||||||
|
}
|
||||||
|
|
||||||
<table id="installed-plugins">
|
useDebounce(() => {
|
||||||
<thead>
|
setSearchParams({...searchParams, offset: 0, searchTerm})
|
||||||
<tr>
|
}, 500, [searchTerm])
|
||||||
<th><Trans i18nKey="admin_plugins.name"/></th>
|
|
||||||
<th><Trans i18nKey="admin_plugins.version"/></th>
|
|
||||||
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody style={{overflow: 'auto'}}>
|
|
||||||
{sortedInstalledPlugins.map((plugin, index) => {
|
|
||||||
return <tr key={index}>
|
|
||||||
<td><a rel="noopener noreferrer" href={`https://npmjs.com/${plugin.name}`} target="_blank">{plugin.name}</a></td>
|
|
||||||
<td>{plugin.version}</td>
|
|
||||||
<td>
|
|
||||||
{
|
|
||||||
plugin.updatable ?
|
|
||||||
<IconButton onClick={() => installPlugin(plugin.name)} icon={<ArrowUpFromDot/>} title="Update"></IconButton>
|
|
||||||
: <IconButton disabled={plugin.name == "ep_etherpad-lite"} icon={<Trash/>} title={<Trans i18nKey="admin_plugins.installed_uninstall.value"/>} onClick={() => uninstallPlugin(plugin.name)}/>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pm-page">
|
||||||
|
|
||||||
<h2><Trans i18nKey="admin_plugins.available"/></h2>
|
{catalogDisabled && (
|
||||||
<SearchField onChange={v=>{setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/>
|
<div className="pm-banner pm-banner-info" role="status">
|
||||||
|
<Trans i18nKey="admin_plugins.catalog_disabled"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="table-container">
|
{/* ── Page header ────────────────────────────────────────────────── */}
|
||||||
<table id="available-plugins">
|
<div className="pm-header">
|
||||||
<thead>
|
<div>
|
||||||
<tr>
|
<div className="pm-crumbs">
|
||||||
<th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'name')} onClick={()=>{
|
Admin <span className="pm-crumbs-sep">›</span> <Trans i18nKey="admin_plugins.crumbs"/>
|
||||||
setSearchParams({
|
</div>
|
||||||
...searchParams,
|
<h1 className="pm-title">{t('admin_plugins')}</h1>
|
||||||
sortBy: 'name',
|
<p className="pm-subtitle">
|
||||||
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
|
<Trans i18nKey="admin_plugins.subtitle"/>
|
||||||
})
|
</p>
|
||||||
}}>
|
</div>
|
||||||
<Trans i18nKey="admin_plugins.name" /></th>
|
<div className="pm-header-actions">
|
||||||
<th style={{width: '30%'}}><Trans i18nKey="admin_plugins.description"/></th>
|
<button
|
||||||
<th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'version')} onClick={()=>{
|
className="pm-btn pm-btn-ghost"
|
||||||
setSearchParams({
|
onClick={() => pluginsSocket?.emit('search', searchParams)}
|
||||||
...searchParams,
|
>
|
||||||
sortBy: 'version',
|
<RefreshCw size={14}/> <Trans i18nKey="admin_plugins.reload_catalog"/>
|
||||||
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
|
</button>
|
||||||
})
|
<a
|
||||||
}}><Trans i18nKey="admin_plugins.version"/></th>
|
className="pm-btn pm-btn-primary pm-btn-link"
|
||||||
<th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'last-updated')} onClick={()=>{
|
href={`//www.npmjs.com/search?q=${encodeURIComponent(searchTerm ? `keywords:etherpad ${searchTerm}` : 'keywords:etherpad')}`}
|
||||||
setSearchParams({
|
target="_blank"
|
||||||
...searchParams,
|
rel="noreferrer"
|
||||||
sortBy: 'last-updated',
|
>
|
||||||
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
|
<ExternalLink size={14}/> <Trans i18nKey="admin_plugins.search_npm"/>
|
||||||
})
|
</a>
|
||||||
}}><Trans i18nKey="admin_plugins.last-update"/></th>
|
</div>
|
||||||
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody style={{overflow: 'auto'}}>
|
|
||||||
{(filteredInstallablePlugins.length > 0) ?
|
|
||||||
filteredInstallablePlugins.map((plugin) => {
|
|
||||||
return <tr key={plugin.name}>
|
|
||||||
<td><a rel="noopener noreferrer" href={`https://npmjs.com/${plugin.name}`} target="_blank">{plugin.name}</a></td>
|
|
||||||
<td>
|
|
||||||
{plugin.description}
|
|
||||||
{plugin.disables && plugin.disables.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="plugin-disables"
|
|
||||||
role="alert"
|
|
||||||
title={t('admin_plugins.disables.warning_title')}
|
|
||||||
style={{
|
|
||||||
marginTop: '0.25rem',
|
|
||||||
padding: '0.2rem 0.5rem',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '0.85em',
|
|
||||||
background: 'rgba(180, 83, 9, 0.15)',
|
|
||||||
border: '1px solid rgba(180, 83, 9, 0.4)',
|
|
||||||
color: '#92400e',
|
|
||||||
display: 'inline-block',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong><Trans i18nKey="admin_plugins.disables.label"/></strong>{' '}
|
|
||||||
{plugin.disables
|
|
||||||
.map((tag) => tag.replace(/^@feature:/, ''))
|
|
||||||
.join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{plugin.version}</td>
|
|
||||||
<td>{plugin.time}</td>
|
|
||||||
<td>
|
|
||||||
<IconButton icon={<Download/>} onClick={() => installPlugin(plugin.name)} title={<Trans i18nKey="admin_plugins.available_install.value"/>}/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
})
|
|
||||||
:
|
|
||||||
<tr><td colSpan={5}>{searchTerm == '' ? <Trans i18nKey="pad.loading"/>: <Trans i18nKey="admin_plugins.available_not-found"/>}</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Stats row ──────────────────────────────────────────────────── */}
|
||||||
|
<div className="pm-stats">
|
||||||
|
<div className="pm-stat pm-stat--primary">
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.installed"/></div>
|
||||||
|
<div className="pm-stat-value">{installedPlugins.length}</div>
|
||||||
|
<div className="pm-stat-hint">{t('admin_plugins.core_count', {count: coreCount})}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pm-stat">
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.available"/></div>
|
||||||
|
<div className="pm-stat-value">{plugins.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`pm-stat${updatableCount > 0 ? ' pm-stat--warn' : ''}`}>
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.updates_available"/></div>
|
||||||
|
<div className="pm-stat-value">{updatableCount}</div>
|
||||||
|
{updatableCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="pm-stat-action"
|
||||||
|
onClick={() => pluginsSocket?.emit('checkUpdates')}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="admin_plugins.update_now"/> →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pm-stat">
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.source"/></div>
|
||||||
|
<div className="pm-stat-value pm-stat-value--sm">npm</div>
|
||||||
|
<div className="pm-stat-hint">registry.npmjs.org</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Installed plugins ──────────────────────────────────────────── */}
|
||||||
|
<section className="pm-section">
|
||||||
|
<div className="pm-section-header">
|
||||||
|
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
|
||||||
|
<span className="pm-count-badge">{installedPlugins.length}</span>
|
||||||
|
<div className="pm-spacer"/>
|
||||||
|
<button
|
||||||
|
className="pm-btn pm-btn-ghost"
|
||||||
|
onClick={() => pluginsSocket?.emit('checkUpdates')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14}/> <Trans i18nKey="admin_plugins.check_updates"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pm-installed">
|
||||||
|
{sortedInstalledPlugins.map(plugin => (
|
||||||
|
<div key={plugin.name} className="pm-installed-row">
|
||||||
|
<div className="pm-installed-icon">
|
||||||
|
<Plug size={16}/>
|
||||||
|
</div>
|
||||||
|
<div className="pm-installed-main">
|
||||||
|
<div className="pm-installed-title">
|
||||||
|
<a
|
||||||
|
className="pm-mono pm-plugin-link"
|
||||||
|
href={`https://npmjs.com/${plugin.name}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>{plugin.name}</a>
|
||||||
|
{plugin.name === 'ep_etherpad-lite' && (
|
||||||
|
<span className="pm-tag pm-tag--core"><Trans i18nKey="admin_plugins.tag_core"/></span>
|
||||||
|
)}
|
||||||
|
<span className="pm-tag pm-tag--ver">v{plugin.version}</span>
|
||||||
|
</div>
|
||||||
|
{plugin.description && (
|
||||||
|
<div className="pm-installed-desc">{plugin.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pm-installed-actions">
|
||||||
|
{plugin.updatable ? (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => installPlugin(plugin.name)}
|
||||||
|
icon={<ArrowUpFromDot size={14}/>}
|
||||||
|
title={t('admin_plugins.update_tooltip')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
disabled={plugin.name === 'ep_etherpad-lite'}
|
||||||
|
icon={<Trash size={14}/>}
|
||||||
|
title={<Trans i18nKey="admin_plugins.installed_uninstall.value"/>}
|
||||||
|
onClick={() => uninstallPlugin(plugin.name)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Available plugins ──────────────────────────────────────────── */}
|
||||||
|
<section className="pm-section">
|
||||||
|
<div className="pm-section-header">
|
||||||
|
<h2><Trans i18nKey="admin_plugins.available"/></h2>
|
||||||
|
<span className="pm-count-badge">{filteredInstallablePlugins.length}</span>
|
||||||
|
<div className="pm-spacer"/>
|
||||||
|
<div className="pm-toolbar">
|
||||||
|
<div className="pm-search">
|
||||||
|
<Search size={14} className="pm-search-icon"/>
|
||||||
|
<input
|
||||||
|
className="pm-search-input"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
placeholder={t('admin_plugins.available_search.placeholder')}
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button className="pm-search-clear" onClick={() => setSearchTerm('')}>
|
||||||
|
<X size={12}/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="pm-select"
|
||||||
|
value={searchParams.sortBy}
|
||||||
|
onChange={e => {
|
||||||
|
const sortBy = e.target.value as SearchParams['sortBy']
|
||||||
|
setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
sortBy,
|
||||||
|
sortDir: 'asc',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="name">{t('admin_plugins.sort.name')}</option>
|
||||||
|
<option value="version">{t('admin_plugins.sort.version')}</option>
|
||||||
|
<option value="last-updated">{t('admin_plugins.sort.last_updated')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="pm-sort-dir"
|
||||||
|
onClick={() => setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
sortDir: searchParams.sortDir === 'asc' ? 'desc' : 'asc',
|
||||||
|
})}
|
||||||
|
title={t(searchParams.sortDir === 'asc'
|
||||||
|
? 'admin_plugins.sort_ascending'
|
||||||
|
: 'admin_plugins.sort_descending')}
|
||||||
|
aria-label={t(searchParams.sortDir === 'asc'
|
||||||
|
? 'admin_plugins.sort_ascending'
|
||||||
|
: 'admin_plugins.sort_descending')}
|
||||||
|
>
|
||||||
|
{searchParams.sortDir === 'asc' ? '↑' : '↓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredInstallablePlugins.length > 0 ? (
|
||||||
|
<div className="pm-table-wrap">
|
||||||
|
<table className="pm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><Trans i18nKey="admin_plugins.name"/></th>
|
||||||
|
<th><Trans i18nKey="admin_plugins.description"/></th>
|
||||||
|
<th style={{width: 62, textAlign: 'right'}}><Trans i18nKey="admin_plugins.version"/></th>
|
||||||
|
<th style={{width: 96}}><Trans i18nKey="admin_plugins.last-update"/></th>
|
||||||
|
<th style={{width: 108, textAlign: 'right'}}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredInstallablePlugins.map(plugin => (
|
||||||
|
<tr key={plugin.name}>
|
||||||
|
<td>
|
||||||
|
<div className="pm-cell-name">
|
||||||
|
<span className="pm-cell-icon"><Plug size={13}/></span>
|
||||||
|
<div className="pm-cell-title">
|
||||||
|
<a
|
||||||
|
className="pm-mono pm-plugin-link"
|
||||||
|
href={`https://npmjs.com/${plugin.name}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>{plugin.name}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="pm-cell-desc">
|
||||||
|
{plugin.description}
|
||||||
|
{plugin.disables && plugin.disables.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="plugin-disables"
|
||||||
|
role="alert"
|
||||||
|
title={t('admin_plugins.disables.warning_title')}
|
||||||
|
>
|
||||||
|
<strong><Trans i18nKey="admin_plugins.disables.label"/></strong>{' '}
|
||||||
|
{plugin.disables
|
||||||
|
.map(tag => tag.replace(/^@feature:/, ''))
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="pm-num">{plugin.version}</td>
|
||||||
|
<td className="pm-cell-date">{plugin.time}</td>
|
||||||
|
<td className="pm-cell-action">
|
||||||
|
<button
|
||||||
|
className="pm-btn pm-btn-primary pm-btn--sm"
|
||||||
|
onClick={() => installPlugin(plugin.name)}
|
||||||
|
>
|
||||||
|
<Download size={13}/> <Trans i18nKey="admin_plugins.available_install.value"/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pm-empty">
|
||||||
|
<div className="pm-empty-icon">∅</div>
|
||||||
|
<div className="pm-empty-title">
|
||||||
|
{searchTerm === ''
|
||||||
|
? <Trans i18nKey="pad.loading"/>
|
||||||
|
: <Trans i18nKey="admin_plugins.available_not-found"/>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {useNavigate} from "react-router-dom";
|
||||||
import {SubmitHandler, useForm} from "react-hook-form";
|
import {SubmitHandler, useForm} from "react-hook-form";
|
||||||
import {Eye, EyeOff} from "lucide-react";
|
import {Eye, EyeOff} from "lucide-react";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
|
||||||
type Inputs = {
|
type Inputs = {
|
||||||
username: string
|
username: string
|
||||||
|
|
@ -12,6 +13,7 @@ type Inputs = {
|
||||||
export const LoginScreen = ()=>{
|
export const LoginScreen = ()=>{
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [passwordVisible, setPasswordVisible] = useState<boolean>(false)
|
const [passwordVisible, setPasswordVisible] = useState<boolean>(false)
|
||||||
|
const {t} = useTranslation()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -27,7 +29,7 @@ export const LoginScreen = ()=>{
|
||||||
if(!r.ok) {
|
if(!r.ok) {
|
||||||
useStore.getState().setToastState({
|
useStore.getState().setToastState({
|
||||||
open: true,
|
open: true,
|
||||||
title: "Login failed",
|
title: t('admin_login.failed'),
|
||||||
success: false
|
success: false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -40,21 +42,21 @@ export const LoginScreen = ()=>{
|
||||||
|
|
||||||
return <div className="login-background login-page">
|
return <div className="login-background login-page">
|
||||||
<div className="login-box login-form">
|
<div className="login-box login-form">
|
||||||
<h1 className="login-title">Etherpad</h1>
|
<h1 className="login-title">{t('admin_login.title')}</h1>
|
||||||
<form className="login-inner-box input-control" onSubmit={handleSubmit(login)}>
|
<form className="login-inner-box input-control" onSubmit={handleSubmit(login)}>
|
||||||
<div>Username</div>
|
<div>{t('admin_login.username')}</div>
|
||||||
<input {...register('username', {
|
<input {...register('username', {
|
||||||
required: true
|
required: true
|
||||||
})} className="login-textinput input-control" type="text" placeholder="Username"/>
|
})} className="login-textinput input-control" type="text" placeholder={t('admin_login.username')}/>
|
||||||
<div>Password</div>
|
<div>{t('admin_login.password')}</div>
|
||||||
<span className="icon-input">
|
<span className="icon-input">
|
||||||
<input {...register('password', {
|
<input {...register('password', {
|
||||||
required: true
|
required: true
|
||||||
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder="Password"/>
|
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder={t('admin_login.password')}/>
|
||||||
{passwordVisible? <Eye onClick={()=>setPasswordVisible(!passwordVisible)}/> :
|
{passwordVisible? <Eye onClick={()=>setPasswordVisible(!passwordVisible)}/> :
|
||||||
<EyeOff onClick={()=>setPasswordVisible(!passwordVisible)}/>}
|
<EyeOff onClick={()=>setPasswordVisible(!passwordVisible)}/>}
|
||||||
</span>
|
</span>
|
||||||
<input type="submit" value="Login" className="login-button"/>
|
<input type="submit" value={t('admin_login.submit')} className="login-button"/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,282 +3,454 @@ import {useEffect, useMemo, useState} from "react";
|
||||||
import {useStore} from "../store/store.ts";
|
import {useStore} from "../store/store.ts";
|
||||||
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
|
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
|
||||||
import {useDebounce} from "../utils/useDebounce.ts";
|
import {useDebounce} from "../utils/useDebounce.ts";
|
||||||
import {determineSorting} from "../utils/sorting.ts";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
import {IconButton} from "../components/IconButton.tsx";
|
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
|
||||||
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon} from "lucide-react";
|
|
||||||
import {SearchField} from "../components/SearchField.tsx";
|
|
||||||
import {useForm} from "react-hook-form";
|
import {useForm} from "react-hook-form";
|
||||||
|
import type {TFunction} from "i18next";
|
||||||
|
|
||||||
type PadCreateProps = {
|
type PadCreateProps = { padName: string }
|
||||||
padName: string
|
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'
|
||||||
|
|
||||||
|
const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale']
|
||||||
|
|
||||||
|
const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7
|
||||||
|
const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365
|
||||||
|
|
||||||
|
function relativeTime(t: TFunction, ts: number): string {
|
||||||
|
const d = (Date.now() - ts) / 1000
|
||||||
|
if (d < 60) return t('admin_pads.relative.just_now')
|
||||||
|
if (d < 3600) return t('admin_pads.relative.minutes', {count: Math.floor(d / 60)})
|
||||||
|
if (d < 86400) return t('admin_pads.relative.hours', {count: Math.floor(d / 3600)})
|
||||||
|
if (d < 86400 * 7) return t('admin_pads.relative.days', {count: Math.floor(d / 86400)})
|
||||||
|
if (d < 86400 * 30) return t('admin_pads.relative.weeks', {count: Math.floor(d / 86400 / 7)})
|
||||||
|
if (d < 86400 * 365) return t('admin_pads.relative.months', {count: Math.floor(d / 86400 / 30)})
|
||||||
|
return t('admin_pads.relative.years', {count: Math.floor(d / 86400 / 365)})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PadPage = ()=>{
|
function fmtDate(locale: string, ts: number): string {
|
||||||
const settingsSocket = useStore(state=>state.settingsSocket)
|
const d = new Date(ts)
|
||||||
|
return (
|
||||||
|
d.toLocaleDateString(locale, {day: '2-digit', month: 'short', year: 'numeric'}) +
|
||||||
|
' · ' +
|
||||||
|
d.toLocaleTimeString(locale, {hour: '2-digit', minute: '2-digit'})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// i18next's language detector reads ?lng= from the URL, so the value can be
|
||||||
|
// attacker-controlled and structurally invalid (e.g. "en_US", "💥", " ").
|
||||||
|
// Intl.* throws RangeError on bad tags, which would crash the pads page
|
||||||
|
// during render. Normalise underscores → dashes and let the Intl runtime
|
||||||
|
// tell us which subset of the tag it can support; on failure, fall back to
|
||||||
|
// 'en' to mirror i18next's fallbackLng so dates render in a sane locale
|
||||||
|
// rather than the user's browser default fighting the page copy.
|
||||||
|
function sanitizeLocale(lng?: string): string {
|
||||||
|
if (!lng) return 'en'
|
||||||
|
const normalized = lng.trim().replace(/_/g, '-')
|
||||||
|
if (!normalized) return 'en'
|
||||||
|
try {
|
||||||
|
const [supported] = Intl.DateTimeFormat.supportedLocalesOf([normalized])
|
||||||
|
return supported ?? 'en'
|
||||||
|
} catch {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PadPage = () => {
|
||||||
|
const settingsSocket = useStore(state => state.settingsSocket)
|
||||||
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
|
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
|
||||||
offset: 0,
|
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false,
|
||||||
limit: 12,
|
|
||||||
pattern: '',
|
|
||||||
sortBy: 'padName',
|
|
||||||
ascending: true
|
|
||||||
})
|
})
|
||||||
const {t} = useTranslation()
|
const {t, i18n} = useTranslation()
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||||
const pads = useStore(state=>state.pads)
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0)
|
const [filter, setFilter] = useState<FilterId>('all')
|
||||||
const [deleteDialog, setDeleteDialog] = useState<boolean>(false)
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [errorText, setErrorText] = useState<string|null>(null)
|
const pads = useStore(state => state.pads)
|
||||||
const [padToDelete, setPadToDelete] = useState<string>('')
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
const [createPadDialogOpen, setCreatePadDialogOpen] = useState<boolean>(false)
|
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||||
|
const [errorText, setErrorText] = useState<string | null>(null)
|
||||||
|
const [padToDelete, setPadToDelete] = useState('')
|
||||||
|
const [createPadDialogOpen, setCreatePadDialogOpen] = useState(false)
|
||||||
const {register, handleSubmit} = useForm<PadCreateProps>()
|
const {register, handleSubmit} = useForm<PadCreateProps>()
|
||||||
const pages = useMemo(()=>{
|
|
||||||
if(!pads){
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.ceil(pads!.total / searchParams.limit)
|
const pages = useMemo(
|
||||||
},[pads, searchParams.limit])
|
() => pads ? Math.ceil(pads.total / searchParams.limit) : 0,
|
||||||
|
[pads, searchParams.limit]
|
||||||
|
)
|
||||||
|
|
||||||
useDebounce(()=>{
|
const filteredResults = useMemo(() => {
|
||||||
setSearchParams({
|
const r = pads?.results ?? []
|
||||||
...searchParams,
|
if (filter === 'active') return r.filter(p => p.userCount > 0)
|
||||||
pattern: searchTerm
|
if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited))
|
||||||
})
|
if (filter === 'empty') return r.filter(p => p.revisionNumber === 0)
|
||||||
|
if (filter === 'stale') return r.filter(p => isStale(p.lastEdited))
|
||||||
|
return r
|
||||||
|
}, [pads, filter])
|
||||||
|
|
||||||
|
const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads])
|
||||||
|
const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads])
|
||||||
|
const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads])
|
||||||
|
const lastActivity = useMemo(() => {
|
||||||
|
const r = pads?.results ?? []
|
||||||
|
return r.length ? Math.max(...r.map(p => p.lastEdited)) : null
|
||||||
|
}, [pads])
|
||||||
|
|
||||||
|
const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName))
|
||||||
|
const toggleAll = () => {
|
||||||
|
const s = new Set(selected)
|
||||||
|
if (allSelected) filteredResults.forEach(p => s.delete(p.padName))
|
||||||
|
else filteredResults.forEach(p => s.add(p.padName))
|
||||||
|
setSelected(s)
|
||||||
|
}
|
||||||
|
const toggleOne = (name: string) => {
|
||||||
|
const s = new Set(selected)
|
||||||
|
s.has(name) ? s.delete(name) : s.add(name)
|
||||||
|
setSelected(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
useDebounce(() => {
|
||||||
|
setSearchParams({...searchParams, pattern: searchTerm})
|
||||||
}, 500, [searchTerm])
|
}, 500, [searchTerm])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(!settingsSocket){
|
if (!settingsSocket) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsSocket.emit('padLoad', searchParams)
|
settingsSocket.emit('padLoad', searchParams)
|
||||||
|
}, [settingsSocket, searchParams])
|
||||||
}, [settingsSocket, searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(!settingsSocket){
|
if (!settingsSocket) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{
|
settingsSocket.on('results:padLoad', (data: PadSearchResult) => {
|
||||||
useStore.getState().setPads(data);
|
useStore.getState().setPads(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
settingsSocket.on('results:deletePad', (padID: string) => {
|
||||||
settingsSocket.on('results:deletePad', (padID: string)=>{
|
const newPads = useStore.getState().pads?.results?.filter(p => p.padName !== padID)
|
||||||
const newPads = useStore.getState().pads?.results?.filter((pad)=>{
|
useStore.getState().setPads({total: useStore.getState().pads!.total - 1, results: newPads})
|
||||||
return pad.padName !== padID
|
|
||||||
})
|
|
||||||
useStore.getState().setPads({
|
|
||||||
total: useStore.getState().pads!.total-1,
|
|
||||||
results: newPads
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type SettingsSocketCreateReponse = {
|
type CreateResponse = {error: string} | {success: string}
|
||||||
error: string
|
settingsSocket.on('results:createPad', (rep: CreateResponse) => {
|
||||||
} | {
|
if ('error' in rep) {
|
||||||
success: string
|
useStore.getState().setToastState({open: true, title: rep.error, success: false})
|
||||||
}
|
} else {
|
||||||
|
useStore.getState().setToastState({open: true, title: rep.success, success: true})
|
||||||
settingsSocket.on('results:createPad', (rep: SettingsSocketCreateReponse)=>{
|
setCreatePadDialogOpen(false)
|
||||||
if ('error' in rep) {
|
settingsSocket.emit('padLoad', searchParams)
|
||||||
useStore.getState().setToastState({
|
|
||||||
open: true,
|
|
||||||
title: rep.error,
|
|
||||||
success: false
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
useStore.getState().setToastState({
|
|
||||||
open: true,
|
|
||||||
title: rep.success,
|
|
||||||
success: true
|
|
||||||
})
|
|
||||||
setCreatePadDialogOpen(false)
|
|
||||||
// reload pads
|
|
||||||
settingsSocket.emit('padLoad', searchParams)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
settingsSocket.on('results:cleanupPadRevisions', (data)=>{
|
|
||||||
const newPads = useStore.getState().pads?.results ?? []
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
setErrorText(data.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newPads.forEach((pad)=>{
|
|
||||||
if (pad.padName === data.padId) {
|
|
||||||
pad.revisionNumber = data.keepRevisions
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
useStore.getState().setPads({
|
|
||||||
results: newPads,
|
|
||||||
total: useStore.getState().pads!.total
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}, [settingsSocket, pads]);
|
|
||||||
|
|
||||||
const deletePad = (padID: string)=>{
|
settingsSocket.on('results:cleanupPadRevisions', (data) => {
|
||||||
settingsSocket?.emit('deletePad', padID)
|
const newPads = useStore.getState().pads?.results ?? []
|
||||||
}
|
if (data.error) { setErrorText(data.error); return }
|
||||||
|
newPads.forEach(p => { if (p.padName === data.padId) p.revisionNumber = data.keepRevisions })
|
||||||
|
useStore.getState().setPads({results: newPads, total: useStore.getState().pads!.total})
|
||||||
|
})
|
||||||
|
}, [settingsSocket, pads])
|
||||||
|
|
||||||
const cleanupPad = (padID: string)=>{
|
const deletePad = (id: string) => settingsSocket?.emit('deletePad', id)
|
||||||
settingsSocket?.emit('cleanupPadRevisions', padID)
|
const cleanupPad = (id: string) => settingsSocket?.emit('cleanupPadRevisions', id)
|
||||||
}
|
const onPadCreate = (data: PadCreateProps) => settingsSocket?.emit('createPad', {padName: data.padName})
|
||||||
|
|
||||||
const onPadCreate = (data: PadCreateProps)=>{
|
return (
|
||||||
settingsSocket?.emit('createPad', {
|
<div className="pm-page">
|
||||||
padName: data.padName
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
{/* ── Dialogs ── */}
|
||||||
|
<Dialog.Root open={deleteDialog}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
|
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
|
||||||
|
<div className="settings-button-bar">
|
||||||
|
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
|
||||||
|
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
return <div>
|
<Dialog.Root open={errorText !== null}>
|
||||||
<Dialog.Root open={deleteDialog}><Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="dialog-confirm-overlay" />
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
<Dialog.Content className="dialog-confirm-content">
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
<div className="">
|
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
|
||||||
<div className=""></div>
|
<div className="settings-button-bar">
|
||||||
<div className="">
|
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
|
||||||
{t("ep_admin_pads:ep_adminpads2_confirm", {
|
</div>
|
||||||
padID: padToDelete,
|
</Dialog.Content>
|
||||||
})}
|
</Dialog.Portal>
|
||||||
</div>
|
</Dialog.Root>
|
||||||
<div className="settings-button-bar">
|
|
||||||
<button onClick={()=>{
|
<Dialog.Root open={createPadDialogOpen}>
|
||||||
setDeleteDialog(false)
|
<Dialog.Portal>
|
||||||
}}>Cancel</button>
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
<button onClick={()=>{
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
deletePad(padToDelete)
|
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
|
||||||
setDeleteDialog(false)
|
<form onSubmit={handleSubmit(onPadCreate)}>
|
||||||
}}>Ok</button>
|
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
|
||||||
</div>
|
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
|
||||||
</div>
|
<label><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></label>
|
||||||
</Dialog.Content>
|
<input {...register('padName', {required: true})}/>
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
<Dialog.Root open={errorText !== null}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
|
||||||
<Dialog.Content className="dialog-confirm-content">
|
|
||||||
<div>
|
|
||||||
<div>Error occured: {errorText}</div>
|
|
||||||
<div className="settings-button-bar">
|
|
||||||
<button onClick={() => {
|
|
||||||
setErrorText(null)
|
|
||||||
}}>OK</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
<Dialog.Root open={createPadDialogOpen}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="dialog-confirm-overlay" />
|
|
||||||
<Dialog.Content className="dialog-confirm-content">
|
|
||||||
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
|
|
||||||
<form onSubmit={handleSubmit(onPadCreate)}>
|
|
||||||
<button className="dialog-close-button" onClick={()=>{
|
|
||||||
setCreatePadDialogOpen(false);
|
|
||||||
}}>x</button>
|
|
||||||
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
|
|
||||||
<label><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></label>
|
|
||||||
<input {...register('padName', {
|
|
||||||
required: true
|
|
||||||
})}/>
|
|
||||||
</div>
|
|
||||||
<input type="submit" value={t('admin_settings.createPad')} className="login-button" />
|
|
||||||
</form>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
<span className="manage-pads-header">
|
|
||||||
<h1><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
|
|
||||||
<span style={{width: '29px', marginBottom: 'auto', marginTop: 'auto', flexGrow: 1}}><IconButton style={{float: 'right'}} icon={<PlusIcon/>} title={<Trans i18nKey="index.newPad"/>} onClick={()=>{
|
|
||||||
setCreatePadDialogOpen(true)
|
|
||||||
}}/></span>
|
|
||||||
</span>
|
|
||||||
<SearchField value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr className="search-pads">
|
|
||||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{
|
|
||||||
setSearchParams({
|
|
||||||
...searchParams,
|
|
||||||
sortBy: 'padName',
|
|
||||||
ascending: !searchParams.ascending
|
|
||||||
})
|
|
||||||
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></th>
|
|
||||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{
|
|
||||||
setSearchParams({
|
|
||||||
...searchParams,
|
|
||||||
sortBy: 'userCount',
|
|
||||||
ascending: !searchParams.ascending
|
|
||||||
})
|
|
||||||
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_pad-user-count"/></th>
|
|
||||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{
|
|
||||||
setSearchParams({
|
|
||||||
...searchParams,
|
|
||||||
sortBy: 'lastEdited',
|
|
||||||
ascending: !searchParams.ascending
|
|
||||||
})
|
|
||||||
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
|
|
||||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{
|
|
||||||
setSearchParams({
|
|
||||||
...searchParams,
|
|
||||||
sortBy: 'revisionNumber',
|
|
||||||
ascending: !searchParams.ascending
|
|
||||||
})
|
|
||||||
}}>Revision number</th>
|
|
||||||
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="search-pads-body">
|
|
||||||
{
|
|
||||||
pads?.results?.map((pad)=>{
|
|
||||||
return <tr key={pad.padName}>
|
|
||||||
<td style={{textAlign: 'center'}}>{pad.padName}</td>
|
|
||||||
<td style={{textAlign: 'center'}}>{pad.userCount}</td>
|
|
||||||
<td style={{textAlign: 'center'}}>{new Date(pad.lastEdited).toLocaleString()}</td>
|
|
||||||
<td style={{textAlign: 'center'}}>{pad.revisionNumber}</td>
|
|
||||||
<td>
|
|
||||||
<div className="settings-button-bar">
|
|
||||||
<IconButton icon={<Trash2/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_delete.value"/>} onClick={()=>{
|
|
||||||
setPadToDelete(pad.padName)
|
|
||||||
setDeleteDialog(true)
|
|
||||||
}}/>
|
|
||||||
<IconButton icon={<FileStack/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_cleanup"/>} onClick={()=>{
|
|
||||||
cleanupPad(pad.padName)
|
|
||||||
}}/>
|
|
||||||
<IconButton icon={<Eye/>} title={<Trans i18nKey="index.createOpenPad"/>} onClick={()=>window.open(`../../p/${pad.padName}`, '_blank')}/>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<input type="submit" value={t('admin_settings.create_pad')} className="login-button"/>
|
||||||
</tr>
|
</form>
|
||||||
})
|
</Dialog.Content>
|
||||||
}
|
</Dialog.Portal>
|
||||||
</tbody>
|
</Dialog.Root>
|
||||||
</table>
|
|
||||||
<div className="settings-button-bar pad-pagination">
|
{/* ── Page header ── */}
|
||||||
<button disabled={currentPage == 0} onClick={()=>{
|
<div className="pm-header">
|
||||||
setCurrentPage(currentPage-1)
|
<div>
|
||||||
setSearchParams({
|
<div className="pm-crumbs">Admin <span className="pm-crumbs-sep">›</span> <Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></div>
|
||||||
...searchParams,
|
<h1 className="pm-title"><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
|
||||||
offset: (Number(currentPage)-1)*searchParams.limit})
|
<p className="pm-subtitle"><Trans i18nKey="admin_pads.subtitle"/></p>
|
||||||
}}><ChevronLeft/><span>Previous Page</span></button>
|
</div>
|
||||||
<span>{currentPage+1} out of {pages}</span>
|
<div className="pm-header-actions">
|
||||||
<button disabled={pages == 0 || pages == currentPage+1} onClick={()=>{
|
<button className="pm-btn pm-btn-ghost" onClick={() => settingsSocket?.emit('padLoad', searchParams)}>
|
||||||
const newCurrentPage = currentPage+1
|
<RefreshCw size={14}/> <Trans i18nKey="admin_pads.refresh"/>
|
||||||
setCurrentPage(newCurrentPage)
|
</button>
|
||||||
setSearchParams({
|
<button className="pm-btn pm-btn-primary" onClick={() => setCreatePadDialogOpen(true)}>
|
||||||
...searchParams,
|
<PlusIcon size={14}/> <Trans i18nKey="index.newPad"/>
|
||||||
offset: (Number(newCurrentPage))*searchParams.limit
|
</button>
|
||||||
})
|
</div>
|
||||||
}}><span>Next Page</span><ChevronRight/></button>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Stats ── */}
|
||||||
|
<div className="pm-stats">
|
||||||
|
<div className="pm-stat pm-stat--primary">
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.total"/></div>
|
||||||
|
<div className="pm-stat-value">{pads?.total ?? '—'}</div>
|
||||||
|
<div className="pm-stat-hint">{activeCount > 0
|
||||||
|
? t('admin_pads.stats.users_active', {count: activeCount})
|
||||||
|
: t('admin_pads.stats.no_active_users')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pm-stat">
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.active_users"/></div>
|
||||||
|
<div className="pm-stat-value">{totalUsers}</div>
|
||||||
|
<div className="pm-stat-hint"><Trans i18nKey="admin_pads.stats.across_pads"/></div>
|
||||||
|
</div>
|
||||||
|
<div className={`pm-stat${emptyCount > 0 ? ' pm-stat--warn' : ''}`}>
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.empty_pads"/></div>
|
||||||
|
<div className="pm-stat-value">{emptyCount}</div>
|
||||||
|
<div className="pm-stat-hint"><Trans i18nKey="admin_pads.stats.revisions_zero"/></div>
|
||||||
|
{emptyCount > 0 && (
|
||||||
|
<button className="pm-stat-action" onClick={() => setFilter('empty')}>{t('admin_pads.show')} →</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pm-stat">
|
||||||
|
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.last_activity"/></div>
|
||||||
|
<div className="pm-stat-value pm-stat-value--sm">
|
||||||
|
{lastActivity ? relativeTime(t, lastActivity) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="pm-stat-hint">{pads?.results?.[0]?.padName ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Pads section ── */}
|
||||||
|
<section className="pm-section">
|
||||||
|
<div className="pm-section-header">
|
||||||
|
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
|
||||||
|
<span className="pm-count-badge">{filteredResults.length}</span>
|
||||||
|
<div className="pm-spacer"/>
|
||||||
|
<div className="pm-toolbar">
|
||||||
|
<div className="pm-search">
|
||||||
|
<Search size={14} className="pm-search-icon"/>
|
||||||
|
<input
|
||||||
|
className="pm-search-input"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button className="pm-search-clear" onClick={() => setSearchTerm('')}><X size={12}/></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="pm-select"
|
||||||
|
value={searchParams.sortBy}
|
||||||
|
onChange={e => setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
sortBy: e.target.value,
|
||||||
|
// Keep current direction when only the column changes; the
|
||||||
|
// ↑/↓ button below is the sole control for direction.
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
|
||||||
|
<option value="padName">{t('admin_pads.sort.name')}</option>
|
||||||
|
<option value="userCount">{t('admin_pads.sort.user_count')}</option>
|
||||||
|
<option value="revisionNumber">{t('admin_pads.sort.revision_number')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="pm-sort-dir"
|
||||||
|
onClick={() => setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
ascending: !searchParams.ascending,
|
||||||
|
})}
|
||||||
|
title={t(searchParams.ascending
|
||||||
|
? 'admin_plugins.sort_ascending'
|
||||||
|
: 'admin_plugins.sort_descending')}
|
||||||
|
aria-label={t(searchParams.ascending
|
||||||
|
? 'admin_plugins.sort_ascending'
|
||||||
|
: 'admin_plugins.sort_descending')}
|
||||||
|
>
|
||||||
|
{searchParams.ascending ? '↑' : '↓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<div className="pm-chips">
|
||||||
|
{PAD_FILTER_IDS.map(id => (
|
||||||
|
<button key={id} className={`pm-chip${filter === id ? ' is-on' : ''}`} onClick={() => setFilter(id)}>
|
||||||
|
{t(`admin_pads.filter.${id}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk bar */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="pm-bulk">
|
||||||
|
<span className="pm-bulk-count">{t('admin_pads.selected_count', {count: selected.size})}</span>
|
||||||
|
<div className="pm-spacer"/>
|
||||||
|
<button className="pm-btn pm-btn-ghost" onClick={() => {
|
||||||
|
selected.forEach(name => cleanupPad(name))
|
||||||
|
setSelected(new Set())
|
||||||
|
}}>
|
||||||
|
<History size={14}/> <Trans i18nKey="admin_pads.bulk.cleanup_history"/>
|
||||||
|
</button>
|
||||||
|
<button className="pm-btn pm-btn-danger" onClick={() => {
|
||||||
|
selected.forEach(name => deletePad(name))
|
||||||
|
setSelected(new Set())
|
||||||
|
}}>
|
||||||
|
<Trash2 size={14}/> <Trans i18nKey="admin_pads.bulk.delete"/>
|
||||||
|
</button>
|
||||||
|
<button className="pm-btn pm-btn-icon" onClick={() => setSelected(new Set())} title={t('admin_pads.bulk.clear_selection')}>
|
||||||
|
<X size={14}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredResults.length > 0 ? (
|
||||||
|
<div className="pm-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{width: 40}}>
|
||||||
|
<label className="pm-check">
|
||||||
|
<input type="checkbox" checked={allSelected} onChange={toggleAll}/>
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
<th><Trans i18nKey="admin_pads.col.pad"/></th>
|
||||||
|
<th style={{width: 100, textAlign: 'center'}}><Trans i18nKey="admin_pads.col.users"/></th>
|
||||||
|
<th style={{width: 110, textAlign: 'right'}}><Trans i18nKey="admin_pads.col.revisions"/></th>
|
||||||
|
<th style={{width: 210}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
|
||||||
|
<th style={{width: 170, textAlign: 'right'}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredResults.map(pad => {
|
||||||
|
const isEmpty = pad.revisionNumber === 0
|
||||||
|
const isSel = selected.has(pad.padName)
|
||||||
|
return (
|
||||||
|
<tr key={pad.padName} className={`${isSel ? 'is-sel' : ''} ${isEmpty ? 'is-empty' : ''}`}>
|
||||||
|
<td>
|
||||||
|
<label className="pm-check">
|
||||||
|
<input type="checkbox" checked={isSel} onChange={() => toggleOne(pad.padName)}/>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="pm-pad-name">
|
||||||
|
<span className="pm-pad-mark" data-empty={isEmpty || undefined}>
|
||||||
|
<FileStack size={13}/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="pm-pad-title">{pad.padName}</div>
|
||||||
|
<div className="pm-pad-sub">
|
||||||
|
{isEmpty
|
||||||
|
? t('admin_pads.empty_never_edited')
|
||||||
|
: t('admin_pads.revisions_count', {count: pad.revisionNumber})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{textAlign: 'center'}}>
|
||||||
|
{pad.userCount > 0 ? (
|
||||||
|
<span className="pm-users-pill"><span className="pm-users-dot"/> {pad.userCount}</span>
|
||||||
|
) : (
|
||||||
|
<span className="pm-users-pill is-muted">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="pm-num">{pad.revisionNumber.toLocaleString(locale)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="pm-time">
|
||||||
|
<span className="pm-time-rel">{relativeTime(t, pad.lastEdited)}</span>
|
||||||
|
<span className="pm-time-abs">{fmtDate(locale, pad.lastEdited)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="pm-cell-action">
|
||||||
|
<div className="pm-row-actions">
|
||||||
|
<button
|
||||||
|
className="pm-btn-icon"
|
||||||
|
title={t('ep_admin_pads:ep_adminpads2_cleanup')}
|
||||||
|
onClick={() => cleanupPad(pad.padName)}
|
||||||
|
>
|
||||||
|
<History size={14}/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pm-btn-icon pm-btn-icon--danger"
|
||||||
|
title={t('ep_admin_pads:ep_adminpads2_delete.value')}
|
||||||
|
onClick={() => { setPadToDelete(pad.padName); setDeleteDialog(true) }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14}/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pm-btn pm-btn-primary pm-btn--sm"
|
||||||
|
onClick={() => window.open(`../../p/${pad.padName}`, '_blank')}
|
||||||
|
>
|
||||||
|
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pm-empty">
|
||||||
|
<div className="pm-empty-icon">∅</div>
|
||||||
|
<div className="pm-empty-title"><Trans i18nKey="ep_admin_pads:ep_adminpads2_no-results"/></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="pm-pagination">
|
||||||
|
<button
|
||||||
|
className="pm-btn pm-btn-ghost"
|
||||||
|
disabled={currentPage === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const p = currentPage - 1
|
||||||
|
setCurrentPage(p)
|
||||||
|
setSearchParams({...searchParams, offset: p * searchParams.limit})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
|
||||||
|
</button>
|
||||||
|
<span className="pm-pagination-info">{currentPage + 1} / {pages || 1}</span>
|
||||||
|
<button
|
||||||
|
className="pm-btn pm-btn-ghost"
|
||||||
|
disabled={pages === 0 || pages === currentPage + 1}
|
||||||
|
onClick={() => {
|
||||||
|
const p = currentPage + 1
|
||||||
|
setCurrentPage(p)
|
||||||
|
setSearchParams({...searchParams, offset: p * searchParams.limit})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ export type InstalledPlugin = {
|
||||||
name: string,
|
name: string,
|
||||||
path: string,
|
path: string,
|
||||||
realPath: string,
|
realPath: string,
|
||||||
version:string,
|
version: string,
|
||||||
|
description?: string,
|
||||||
updatable?: boolean
|
updatable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,123 @@
|
||||||
import {useStore} from "../store/store.ts";
|
import React, { useState } from 'react';
|
||||||
import {isJSONClean, cleanComments} from "../utils/utils.ts";
|
import { useStore } from '../store/store';
|
||||||
import {Trans} from "react-i18next";
|
import { isJSONClean, cleanComments } from '../utils/utils';
|
||||||
import {IconButton} from "../components/IconButton.tsx";
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import {RotateCw, Save} from "lucide-react";
|
import { IconButton } from '../components/IconButton';
|
||||||
|
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
|
||||||
|
import { FormView } from '../components/settings/FormView';
|
||||||
|
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
|
||||||
|
|
||||||
export const SettingsPage = ()=>{
|
const TAB_INDENT = ' ';
|
||||||
const settingsSocket = useStore(state=>state.settingsSocket)
|
|
||||||
const settings = cleanComments(useStore(state=>state.settings))
|
|
||||||
|
|
||||||
return <div className="settings-page">
|
export const SettingsPage = () => {
|
||||||
<h1><Trans i18nKey="admin_settings.current"/></h1>
|
const { t } = useTranslation();
|
||||||
<textarea value={settings} className="settings" onChange={v => {
|
const settingsSocket = useStore(state => state.settingsSocket);
|
||||||
useStore.getState().setSettings(v.target.value)
|
const settings = useStore(state => state.settings) ?? '';
|
||||||
}}/>
|
|
||||||
<div className="settings-button-bar">
|
const [mode, setMode] = useState<Mode>('form');
|
||||||
<IconButton className="settingsButton" icon={<Save/>}
|
const [exposeExperimental] = useState(false);
|
||||||
title={<Trans i18nKey="admin_settings.current_save.value"/>} onClick={() => {
|
|
||||||
if (isJSONClean(settings!)) {
|
// Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
|
||||||
// JSON is clean so emit it to the server
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
settingsSocket!.emit('saveSettings', settings!);
|
if (e.key !== 'Tab') return;
|
||||||
useStore.getState().setToastState({
|
e.preventDefault();
|
||||||
open: true,
|
const target = e.currentTarget;
|
||||||
title: "Successfully saved settings",
|
const { selectionStart, selectionEnd, value } = target;
|
||||||
success: true
|
const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd);
|
||||||
})
|
useStore.getState().setSettings(next);
|
||||||
} else {
|
requestAnimationFrame(() => {
|
||||||
useStore.getState().setToastState({
|
target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length;
|
||||||
open: true,
|
});
|
||||||
title: "Error saving settings",
|
};
|
||||||
success: false
|
|
||||||
})
|
const showToast = (titleKey: string, success: boolean) => {
|
||||||
}
|
useStore.getState().setToastState({ open: true, title: t(titleKey), success });
|
||||||
}}/>
|
};
|
||||||
<IconButton className="settingsButton" icon={<RotateCw/>}
|
|
||||||
title={<Trans i18nKey="admin_settings.current_restart.value"/>} onClick={() => {
|
const testJSON = () => {
|
||||||
settingsSocket!.emit('restartServer');
|
if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true);
|
||||||
}}/>
|
else showToast('admin_settings.toast.validation_failed', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prettifyJSON = () => {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(cleanComments(settings) ?? '');
|
||||||
|
if (window.confirm(t('admin_settings.prettify_confirm'))) {
|
||||||
|
useStore.getState().setSettings(JSON.stringify(obj, null, 2));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('admin_settings.toast.prettify_failed', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false);
|
||||||
|
if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false);
|
||||||
|
// Toast is shown by the saveprogress socket listener in App.tsx on server ack.
|
||||||
|
settingsSocket.emit('saveSettings', settings);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<h1><Trans i18nKey="admin_settings.current" /></h1>
|
||||||
|
|
||||||
|
<ModeToggle mode={mode} onChange={setMode} />
|
||||||
|
|
||||||
|
{mode === 'form'
|
||||||
|
? <FormView onSwitchToRaw={() => setMode('raw')} />
|
||||||
|
: (
|
||||||
|
<textarea
|
||||||
|
value={settings}
|
||||||
|
className="settings"
|
||||||
|
data-testid="settings-raw-textarea"
|
||||||
|
spellCheck={false}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onChange={v => useStore.getState().setSettings(v.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className="settings-button-bar">
|
||||||
|
<IconButton
|
||||||
|
className="settingsButton"
|
||||||
|
data-testid="save-settings-button"
|
||||||
|
icon={<Save />}
|
||||||
|
title={<Trans i18nKey="admin_settings.current_save.value" />}
|
||||||
|
onClick={handleSave}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
className="settingsButton"
|
||||||
|
data-testid="test-settings-button"
|
||||||
|
icon={<ShieldCheck />}
|
||||||
|
title={<Trans i18nKey="admin_settings.current_test.value" />}
|
||||||
|
onClick={testJSON}
|
||||||
|
/>
|
||||||
|
{exposeExperimental && (
|
||||||
|
<IconButton
|
||||||
|
className="settingsButton"
|
||||||
|
data-testid="prettify-settings-button"
|
||||||
|
icon={<AlignLeft />}
|
||||||
|
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
|
||||||
|
onClick={prettifyJSON}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
className="settingsButton"
|
||||||
|
data-testid="restart-etherpad-button"
|
||||||
|
icon={<RotateCw />}
|
||||||
|
title={<Trans i18nKey="admin_settings.current_restart.value" />}
|
||||||
|
onClick={() => settingsSocket?.emit('restartServer')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-links">
|
||||||
|
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Production-Settings.JSON">
|
||||||
|
<Trans i18nKey="admin_settings.current_example-prod" />
|
||||||
|
</a>
|
||||||
|
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Development-Settings.JSON">
|
||||||
|
<Trans i18nKey="admin_settings.current_example-devel" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="separator"/>
|
);
|
||||||
<div className="settings-button-bar">
|
};
|
||||||
<a rel="noopener noreferrer" target="_blank"
|
|
||||||
href="https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON"><Trans
|
|
||||||
i18nKey="admin_settings.current_example-prod"/></a>
|
|
||||||
<a rel="noopener noreferrer" target="_blank"
|
|
||||||
href="https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON"><Trans
|
|
||||||
i18nKey="admin_settings.current_example-devel"/></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {SendHorizonal} from 'lucide-react'
|
||||||
import {useStore} from "../store/store.ts";
|
import {useStore} from "../store/store.ts";
|
||||||
import * as Switch from '@radix-ui/react-switch';
|
import * as Switch from '@radix-ui/react-switch';
|
||||||
import {ShoutType} from "../components/ShoutType.ts";
|
import {ShoutType} from "../components/ShoutType.ts";
|
||||||
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
|
|
||||||
export const ShoutPage = ()=>{
|
export const ShoutPage = ()=>{
|
||||||
const [totalUsers, setTotalUsers] = useState(0);
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
|
|
@ -11,6 +12,7 @@ export const ShoutPage = ()=>{
|
||||||
const socket = useStore(state => state.settingsSocket);
|
const socket = useStore(state => state.settingsSocket);
|
||||||
const pluginSocket = useStore(state => state.pluginsSocket);
|
const pluginSocket = useStore(state => state.pluginsSocket);
|
||||||
const [shouts, setShouts] = useState<ShoutType[]>([]);
|
const [shouts, setShouts] = useState<ShoutType[]>([]);
|
||||||
|
const {t} = useTranslation()
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -42,8 +44,8 @@ export const ShoutPage = ()=>{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Communication</h1>
|
<h1><Trans i18nKey="admin.shout"/></h1>
|
||||||
{totalUsers > 0 && <p>There {totalUsers>1?"are":"is"} currently {totalUsers} user{totalUsers>1?"s":""} online</p>}
|
{totalUsers > 0 && <p>{t('admin_shout.online', {count: totalUsers})}</p>}
|
||||||
<div style={{height: '80vh', display: 'flex', flexDirection: 'column'}}>
|
<div style={{height: '80vh', display: 'flex', flexDirection: 'column'}}>
|
||||||
<div style={{flexGrow: 1, backgroundColor: 'white', overflowY: "auto"}}>
|
<div style={{flexGrow: 1, backgroundColor: 'white', overflowY: "auto"}}>
|
||||||
{
|
{
|
||||||
|
|
@ -66,7 +68,7 @@ export const ShoutPage = ()=>{
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
sendMessage()
|
sendMessage()
|
||||||
}} className="send-message search-field" style={{display: 'flex', gap: '10px'}}>
|
}} className="send-message search-field" style={{display: 'flex', gap: '10px'}}>
|
||||||
<Switch.Root title="Change sticky message" className="SwitchRoot" checked={sticky}
|
<Switch.Root title={t('admin_shout.sticky_toggle')} className="SwitchRoot" checked={sticky}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
setSticky(!sticky);
|
setSticky(!sticky);
|
||||||
}}>
|
}}>
|
||||||
|
|
|
||||||
|
|
@ -9,37 +9,98 @@ type FetchState =
|
||||||
| {kind: 'error', status: number}
|
| {kind: 'error', status: number}
|
||||||
| {kind: 'ok'};
|
| {kind: 'ok'};
|
||||||
|
|
||||||
|
const IN_FLIGHT_STATUSES = ['preflight', 'draining', 'executing', 'rolling-back'];
|
||||||
|
|
||||||
|
const fmtRemaining = (ms: number): string => {
|
||||||
|
if (ms <= 0) return '0s';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
|
||||||
|
};
|
||||||
|
|
||||||
export const UpdatePage = () => {
|
export const UpdatePage = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const us = useStore((s) => s.updateStatus);
|
const us = useStore((s) => s.updateStatus);
|
||||||
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
|
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
|
||||||
|
const log = useStore((s) => s.updateLog);
|
||||||
|
const setLog = useStore((s) => s.setUpdateLog);
|
||||||
// Self-fetch so the page renders an explicit state even if UpdateBanner's
|
// Self-fetch so the page renders an explicit state even if UpdateBanner's
|
||||||
// best-effort fetch never landed (route returns 404 when tier=off, 401/403
|
// best-effort fetch never landed (route returns 404 when tier=off, 401/403
|
||||||
// if requireAdminForStatus is set, or a transient network error).
|
// if requireAdminForStatus is set, or a transient network error).
|
||||||
const [fetchState, setFetchState] = useState<FetchState>(us ? {kind: 'ok'} : {kind: 'loading'});
|
const [fetchState, setFetchState] = useState<FetchState>(us ? {kind: 'ok'} : {kind: 'loading'});
|
||||||
|
const [actionInFlight, setActionInFlight] = useState(false);
|
||||||
|
|
||||||
|
const refreshStatus = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/admin/update/status', {credentials: 'same-origin'});
|
||||||
|
if (r.ok) {
|
||||||
|
const data = await r.json();
|
||||||
|
setUpdateStatus(data);
|
||||||
|
setFetchState({kind: 'ok'});
|
||||||
|
} else if (r.status === 404) {
|
||||||
|
setFetchState({kind: 'disabled'});
|
||||||
|
} else if (r.status === 401 || r.status === 403) {
|
||||||
|
setFetchState({kind: 'unauthorized'});
|
||||||
|
} else {
|
||||||
|
setFetchState({kind: 'error', status: r.status});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFetchState({kind: 'error', status: 0});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch('/admin/update/status', {credentials: 'same-origin'})
|
void refreshStatus().then(() => { if (cancelled) return; });
|
||||||
.then(async (r) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (r.ok) {
|
|
||||||
const data = await r.json();
|
|
||||||
setUpdateStatus(data);
|
|
||||||
setFetchState({kind: 'ok'});
|
|
||||||
} else if (r.status === 404) {
|
|
||||||
setFetchState({kind: 'disabled'});
|
|
||||||
} else if (r.status === 401 || r.status === 403) {
|
|
||||||
setFetchState({kind: 'unauthorized'});
|
|
||||||
} else {
|
|
||||||
setFetchState({kind: 'error', status: r.status});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setFetchState({kind: 'error', status: 0});
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [setUpdateStatus]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Poll log + status while the executor is in flight, then stop.
|
||||||
|
const status = us?.execution?.status ?? 'idle';
|
||||||
|
const inFlight = IN_FLIGHT_STATUSES.includes(status);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inFlight) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const lr = await fetch('/admin/update/log', {credentials: 'same-origin'});
|
||||||
|
if (lr.ok) setLog(await lr.text());
|
||||||
|
} catch {/* noop */}
|
||||||
|
await refreshStatus();
|
||||||
|
if (!cancelled) setTimeout(tick, 1000);
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [inFlight]);
|
||||||
|
|
||||||
|
const post = async (path: string) => {
|
||||||
|
setActionInFlight(true);
|
||||||
|
try {
|
||||||
|
await fetch(path, {method: 'POST', credentials: 'same-origin'});
|
||||||
|
await refreshStatus();
|
||||||
|
} finally {
|
||||||
|
setActionInFlight(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tier 3 countdown — derive scheduledFor outside the conditional returns so
|
||||||
|
// the hook order is stable on every render.
|
||||||
|
const scheduledFor = us?.execution?.status === 'scheduled'
|
||||||
|
? (us.execution as {scheduledFor: string}).scheduledFor
|
||||||
|
: null;
|
||||||
|
const [remainingMs, setRemainingMs] = useState<number>(() =>
|
||||||
|
scheduledFor ? Math.max(0, new Date(scheduledFor).getTime() - Date.now()) : 0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scheduledFor) return;
|
||||||
|
const target = new Date(scheduledFor).getTime();
|
||||||
|
setRemainingMs(Math.max(0, target - Date.now()));
|
||||||
|
const id = setInterval(() => setRemainingMs(Math.max(0, target - Date.now())), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [scheduledFor]);
|
||||||
|
|
||||||
if (fetchState.kind === 'loading') {
|
if (fetchState.kind === 'loading') {
|
||||||
return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>;
|
return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>;
|
||||||
|
|
@ -61,16 +122,30 @@ export const UpdatePage = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fetchState.kind === 'error' || !us) {
|
if (fetchState.kind === 'error' || !us) {
|
||||||
const status = fetchState.kind === 'error' ? fetchState.status : 0;
|
const stat = fetchState.kind === 'error' ? fetchState.status : 0;
|
||||||
return (
|
return (
|
||||||
<div className="update-page">
|
<div className="update-page">
|
||||||
<h1><Trans i18nKey="update.page.title"/></h1>
|
<h1><Trans i18nKey="update.page.title"/></h1>
|
||||||
<p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status})}</p>
|
<p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status: stat})}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upToDate = !us.latest || us.currentVersion === us.latest.version;
|
const upToDate = !us.latest || us.currentVersion === us.latest.version;
|
||||||
|
const showApply = !!us.policy?.canManual
|
||||||
|
&& (status === 'idle' || status === 'verified' || status === 'scheduled')
|
||||||
|
&& !us.lockHeld
|
||||||
|
&& !upToDate;
|
||||||
|
const showCancel = status === 'preflight' || status === 'draining' || status === 'scheduled';
|
||||||
|
const showAcknowledge = status === 'preflight-failed' || status === 'rolled-back' || status === 'rollback-failed';
|
||||||
|
|
||||||
|
// Optional-chain the execution lookup: some integration-test stubs of
|
||||||
|
// /admin/update/status omit Tier 2/3 fields entirely (see
|
||||||
|
// update-banner.spec.ts), and accessing `.status` on an undefined
|
||||||
|
// execution would crash the whole page before the h1 renders.
|
||||||
|
const scheduled = us.execution?.status === 'scheduled'
|
||||||
|
? us.execution as {targetTag: string; scheduledFor: string}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="update-page">
|
<div className="update-page">
|
||||||
|
|
@ -86,7 +161,67 @@ export const UpdatePage = () => {
|
||||||
<dd>{us.installMethod}</dd>
|
<dd>{us.installMethod}</dd>
|
||||||
<dt><Trans i18nKey="update.page.tier"/></dt>
|
<dt><Trans i18nKey="update.page.tier"/></dt>
|
||||||
<dd>{us.tier}</dd>
|
<dd>{us.tier}</dd>
|
||||||
|
<dt><Trans i18nKey="update.page.execution"/></dt>
|
||||||
|
<dd>{t(`update.execution.${status}`, {defaultValue: status})}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{us.lastResult && (
|
||||||
|
<p className={`last-result last-result-${us.lastResult.outcome}`}>
|
||||||
|
<Trans
|
||||||
|
i18nKey={`update.page.last_result.${us.lastResult.outcome}`}
|
||||||
|
values={{tag: us.lastResult.targetTag, reason: us.lastResult.reason ?? ''}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{us.policy && !us.policy.canManual && !upToDate && (
|
||||||
|
<p className="policy-deny">
|
||||||
|
<Trans
|
||||||
|
i18nKey={`update.page.policy.${us.policy.reason}`}
|
||||||
|
defaults={us.policy.reason}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scheduled && (
|
||||||
|
<section className="update-scheduled" aria-live="polite">
|
||||||
|
<h2><Trans i18nKey="update.page.scheduled.title"/></h2>
|
||||||
|
<p>
|
||||||
|
<Trans
|
||||||
|
i18nKey="update.page.scheduled.countdown"
|
||||||
|
values={{tag: scheduled.targetTag, remaining: fmtRemaining(remainingMs)}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="update-actions">
|
||||||
|
{showApply && (
|
||||||
|
<button onClick={() => post('/admin/update/apply')} disabled={actionInFlight}>
|
||||||
|
{status === 'scheduled'
|
||||||
|
? t('update.page.scheduled.apply_now')
|
||||||
|
: t('update.page.apply')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showCancel && (
|
||||||
|
<button onClick={() => post('/admin/update/cancel')} disabled={actionInFlight}>
|
||||||
|
{t('update.page.cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showAcknowledge && (
|
||||||
|
<button onClick={() => post('/admin/update/acknowledge')} disabled={actionInFlight}>
|
||||||
|
{t('update.page.acknowledge')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inFlight && (
|
||||||
|
<section className="update-log">
|
||||||
|
<h2><Trans i18nKey="update.page.log"/></h2>
|
||||||
|
<pre style={{whiteSpace: 'pre-wrap', maxHeight: '320px', overflow: 'auto'}}>{log}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{upToDate ? (
|
{upToDate ? (
|
||||||
<p><Trans i18nKey="update.page.up_to_date"/></p>
|
<p><Trans i18nKey="update.page.up_to_date"/></p>
|
||||||
) : us.latest ? (
|
) : us.latest ? (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,30 @@
|
||||||
import {create} from "zustand";
|
import {create} from "zustand";
|
||||||
import {Socket} from "socket.io-client";
|
import {Socket} from "socket.io-client";
|
||||||
import {PadSearchResult} from "../utils/PadSearch.ts";
|
import {PadSearchResult} from "../utils/PadSearch.ts";
|
||||||
|
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
|
||||||
import {InstalledPlugin} from "../pages/Plugin.ts";
|
import {InstalledPlugin} from "../pages/Plugin.ts";
|
||||||
|
|
||||||
|
export type Execution =
|
||||||
|
| {status: 'idle'}
|
||||||
|
| {status: 'scheduled'; targetTag: string; scheduledFor: string; startedAt: string}
|
||||||
|
| {status: 'preflight'; targetTag: string; startedAt: string}
|
||||||
|
| {status: 'preflight-failed'; targetTag: string; reason: string; at: string}
|
||||||
|
| {status: 'draining'; targetTag: string; drainEndsAt: string; startedAt: string}
|
||||||
|
| {status: 'executing'; targetTag: string; fromSha: string; startedAt: string}
|
||||||
|
| {status: 'pending-verification'; targetTag: string; fromSha: string; deadlineAt: string}
|
||||||
|
| {status: 'verified'; targetTag: string; verifiedAt: string}
|
||||||
|
| {status: 'rolling-back'; reason: string; targetTag: string; fromSha: string; at: string}
|
||||||
|
| {status: 'rolled-back'; reason: string; targetTag: string; restoredSha: string; at: string}
|
||||||
|
| {status: 'rollback-failed'; reason: string; targetTag: string; fromSha: string; at: string};
|
||||||
|
|
||||||
|
export type LastResult = null | {
|
||||||
|
targetTag: string;
|
||||||
|
fromSha: string;
|
||||||
|
outcome: 'verified' | 'rolled-back' | 'rollback-failed' | 'preflight-failed' | 'cancelled';
|
||||||
|
reason: string | null;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface UpdateStatusPayload {
|
export interface UpdateStatusPayload {
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
latest: null | {
|
latest: null | {
|
||||||
|
|
@ -18,6 +40,10 @@ export interface UpdateStatusPayload {
|
||||||
tier: string;
|
tier: string;
|
||||||
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
|
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
|
||||||
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
|
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
|
||||||
|
// Tier 2 additions:
|
||||||
|
execution: Execution;
|
||||||
|
lastResult: LastResult;
|
||||||
|
lockHeld: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToastState = {
|
type ToastState = {
|
||||||
|
|
@ -45,6 +71,12 @@ type StoreState = {
|
||||||
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
|
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
|
||||||
updateStatus: UpdateStatusPayload | null,
|
updateStatus: UpdateStatusPayload | null,
|
||||||
setUpdateStatus: (s: UpdateStatusPayload) => void,
|
setUpdateStatus: (s: UpdateStatusPayload) => void,
|
||||||
|
updateLog: string,
|
||||||
|
setUpdateLog: (log: string) => void,
|
||||||
|
authors: AuthorSearchResult|undefined,
|
||||||
|
setAuthors: (authors: AuthorSearchResult)=>void,
|
||||||
|
gdprAuthorErasureEnabled: boolean,
|
||||||
|
setGdprAuthorErasureEnabled: (enabled: boolean)=>void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,4 +102,10 @@ export const useStore = create<StoreState>()((set) => ({
|
||||||
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
|
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
|
||||||
updateStatus: null,
|
updateStatus: null,
|
||||||
setUpdateStatus: (s) => set({updateStatus: s}),
|
setUpdateStatus: (s) => set({updateStatus: s}),
|
||||||
|
updateLog: '',
|
||||||
|
setUpdateLog: (log) => set({updateLog: log}),
|
||||||
|
authors: undefined,
|
||||||
|
setAuthors: (authors)=>set({authors}),
|
||||||
|
gdprAuthorErasureEnabled: false,
|
||||||
|
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
45
admin/src/utils/AuthorSearch.ts
Normal file
45
admin/src/utils/AuthorSearch.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
export type AuthorSortBy = 'name' | 'lastSeen';
|
||||||
|
|
||||||
|
export type AuthorSearchQuery = {
|
||||||
|
pattern: string;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
sortBy: AuthorSortBy;
|
||||||
|
ascending: boolean;
|
||||||
|
includeErased: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthorRow = {
|
||||||
|
authorID: string;
|
||||||
|
name: string | null;
|
||||||
|
colorId: string | number | null;
|
||||||
|
mapper: string[];
|
||||||
|
lastSeen: number | null;
|
||||||
|
erased: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthorSearchResult = {
|
||||||
|
total: number;
|
||||||
|
cappedAt?: number;
|
||||||
|
results: AuthorRow[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnonymizePreview = {
|
||||||
|
authorID: string;
|
||||||
|
name: string | null;
|
||||||
|
affectedPads: number;
|
||||||
|
removedTokenMappings: number;
|
||||||
|
removedExternalMappings: number;
|
||||||
|
clearedChatMessages: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnonymizeResult = {
|
||||||
|
authorID: string;
|
||||||
|
affectedPads?: number;
|
||||||
|
removedTokenMappings?: number;
|
||||||
|
removedExternalMappings?: number;
|
||||||
|
clearedChatMessages?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
@ -21,5 +21,6 @@
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/__tests__/**"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
// Inline `settings.json.template` at config time so the bundle has the
|
||||||
|
// per-key documentation without expanding the dev server's filesystem
|
||||||
|
// allowlist (which would otherwise serve every file in the repo root,
|
||||||
|
// including settings.json and credentials.json, to anything that can
|
||||||
|
// reach the dev server).
|
||||||
|
const settingsTemplate = readFileSync(
|
||||||
|
resolve(__dirname, '..', 'settings.json.template'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|
@ -11,6 +23,9 @@ export default defineConfig({
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
base: '/admin',
|
base: '/admin',
|
||||||
|
define: {
|
||||||
|
__SETTINGS_TEMPLATE__: JSON.stringify(settingsTemplate),
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../src/templates/admin',
|
outDir: '../src/templates/admin',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|
|
||||||
328
bin/compactStalePads.ts
Normal file
328
bin/compactStalePads.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compact every pad on the instance that has not been edited recently.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node bin/compactStalePads.js --older-than 90 # collapse history on pads not edited in 90 days
|
||||||
|
* node bin/compactStalePads.js --older-than 90 --keep 50 # keep last 50 revisions
|
||||||
|
* node bin/compactStalePads.js --older-than 90 --dry-run # list, don't write
|
||||||
|
*
|
||||||
|
* Composes `listAllPads` → `getLastEdited` → `compactPad`. Same shape as
|
||||||
|
* `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but
|
||||||
|
* filters by edit-recency before touching anything. Targeting which pads
|
||||||
|
* to compact is deliberately a CLI concern and not a `compactPad` API
|
||||||
|
* param — staleness changes from one run to the next, the compaction
|
||||||
|
* primitive does not.
|
||||||
|
*
|
||||||
|
* Destructive — `getEtherpad`-export anything you can't afford to lose
|
||||||
|
* before running.
|
||||||
|
*
|
||||||
|
* Issue #7642: long-lived instances accumulate cold pads whose history
|
||||||
|
* nobody is navigating any more. Hot pads should be left alone; this
|
||||||
|
* tool is the brick for reclaiming space on the cold tail.
|
||||||
|
*/
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
export type CompactStaleOpts = {
|
||||||
|
olderThanDays: number;
|
||||||
|
keepRevisions: number | null;
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal interface mirroring the API endpoints the script needs. Tests
|
||||||
|
// substitute their own implementation that goes through supertest+JWT
|
||||||
|
// instead of fetch+APIKEY, so the loop logic is exercised against a real
|
||||||
|
// running server without dragging in apikey-file or fetch setup.
|
||||||
|
export type CompactStaleApi = {
|
||||||
|
listAllPads(): Promise<string[]>;
|
||||||
|
getLastEdited(padId: string): Promise<number>;
|
||||||
|
getRevisionsCount(padId: string): Promise<number>;
|
||||||
|
compactPad(padId: string, keepRevisions: number | null): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompactStaleReport = {
|
||||||
|
total: number;
|
||||||
|
stale: number;
|
||||||
|
ok: number;
|
||||||
|
failed: number;
|
||||||
|
skippedFresh: number;
|
||||||
|
totalRevsBefore: number;
|
||||||
|
totalRevsAfter: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompactStaleLogger = {
|
||||||
|
info(msg: string): void;
|
||||||
|
error(msg: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultLogger: CompactStaleLogger = {
|
||||||
|
info: (m) => console.log(m),
|
||||||
|
error: (m) => console.error(m),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Pure-ish core: compose listAllPads → getLastEdited → compactPad with
|
||||||
|
// the same per-pad error tolerance + dry-run + tally as compactAllPads.
|
||||||
|
// `now` is injected so tests can pin the wall clock.
|
||||||
|
export const runCompactStale = async (
|
||||||
|
api: CompactStaleApi, opts: CompactStaleOpts,
|
||||||
|
logger: CompactStaleLogger = defaultLogger,
|
||||||
|
now: () => number = Date.now,
|
||||||
|
): Promise<CompactStaleReport> => {
|
||||||
|
const cutoff = now() - opts.olderThanDays * DAY_MS;
|
||||||
|
|
||||||
|
let padIds: string[];
|
||||||
|
try {
|
||||||
|
padIds = await api.listAllPads();
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`listAllPads failed: ${e.message ?? e}`);
|
||||||
|
return {
|
||||||
|
total: 0, stale: 0, ok: 0, failed: 1, skippedFresh: 0,
|
||||||
|
totalRevsBefore: 0, totalRevsAfter: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (padIds.length === 0) {
|
||||||
|
logger.info('No pads on this instance.');
|
||||||
|
return {
|
||||||
|
total: 0, stale: 0, ok: 0, failed: 0, skippedFresh: 0,
|
||||||
|
totalRevsBefore: 0, totalRevsAfter: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = opts.keepRevisions == null
|
||||||
|
? 'collapse all history'
|
||||||
|
: `keep last ${opts.keepRevisions} revisions`;
|
||||||
|
logger.info(
|
||||||
|
`Found ${padIds.length} pad(s). Filter: not edited in ` +
|
||||||
|
`${opts.olderThanDays} day(s). Strategy: ${strategy}` +
|
||||||
|
`${opts.dryRun ? ' (dry run — no writes)' : ''}.`);
|
||||||
|
|
||||||
|
const report: CompactStaleReport = {
|
||||||
|
total: padIds.length, stale: 0, ok: 0, failed: 0, skippedFresh: 0,
|
||||||
|
totalRevsBefore: 0, totalRevsAfter: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First pass: figure out which pads are actually stale. A getLastEdited
|
||||||
|
// failure on a pad is counted as a failure (we can't decide), but does
|
||||||
|
// not stop the run.
|
||||||
|
const stalePads: string[] = [];
|
||||||
|
for (const padId of padIds) {
|
||||||
|
let lastEdited: number;
|
||||||
|
try {
|
||||||
|
lastEdited = await api.getLastEdited(padId);
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`${padId}: getLastEdited failed: ${e.message ?? e}`);
|
||||||
|
report.failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lastEdited > cutoff) {
|
||||||
|
report.skippedFresh++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stalePads.push(padId);
|
||||||
|
}
|
||||||
|
report.stale = stalePads.length;
|
||||||
|
|
||||||
|
if (stalePads.length === 0) {
|
||||||
|
logger.info(
|
||||||
|
`No stale pads (${report.skippedFresh} fresh, ${report.failed} unreadable).`);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`${stalePads.length} stale pad(s) to process ` +
|
||||||
|
`(${report.skippedFresh} fresh skipped).`);
|
||||||
|
|
||||||
|
for (let i = 0; i < stalePads.length; i++) {
|
||||||
|
const padId = stalePads[i];
|
||||||
|
const idx = `[${i + 1}/${stalePads.length}]`;
|
||||||
|
|
||||||
|
let before: number;
|
||||||
|
try {
|
||||||
|
before = await api.getRevisionsCount(padId);
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`${idx} ${padId}: getRevisionsCount failed: ${e.message ?? e}`);
|
||||||
|
report.failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
logger.info(`${idx} ${padId}: ${before + 1} revision(s) — would compact`);
|
||||||
|
report.totalRevsBefore += before + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check staleness right before compacting. Without this the
|
||||||
|
// first-pass selection is a TOCTOU window: on a long bulk run a
|
||||||
|
// pad can become active between selection and compaction, and
|
||||||
|
// compactPad would then kick those sessions. Re-checking here
|
||||||
|
// shrinks the window to one round-trip and treats the pad as
|
||||||
|
// freshened (skipped, not failed).
|
||||||
|
let lastEditedNow: number;
|
||||||
|
try {
|
||||||
|
lastEditedNow = await api.getLastEdited(padId);
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`${idx} ${padId}: getLastEdited recheck failed: ${e.message ?? e}`);
|
||||||
|
report.failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lastEditedNow > cutoff) {
|
||||||
|
logger.info(`${idx} ${padId}: edited during run — skipping (now fresh)`);
|
||||||
|
report.skippedFresh++;
|
||||||
|
report.stale--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.compactPad(padId, opts.keepRevisions);
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`${idx} ${padId}: compactPad failed: ${e.message ?? e}`);
|
||||||
|
report.failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let after: number | undefined;
|
||||||
|
try { after = await api.getRevisionsCount(padId); }
|
||||||
|
catch { /* main op already succeeded; post-count is informational */ }
|
||||||
|
|
||||||
|
if (after != null) {
|
||||||
|
logger.info(`${idx} ${padId}: ${before + 1} → ${after + 1} revision(s)`);
|
||||||
|
report.totalRevsBefore += before + 1;
|
||||||
|
report.totalRevsAfter += after + 1;
|
||||||
|
} else {
|
||||||
|
logger.info(`${idx} ${padId}: compacted (post-count unavailable)`);
|
||||||
|
}
|
||||||
|
report.ok++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
logger.info('');
|
||||||
|
logger.info(
|
||||||
|
`Dry run complete. ${stalePads.length} stale pad(s), ` +
|
||||||
|
`${report.totalRevsBefore} total revision(s) — re-run ` +
|
||||||
|
'without --dry-run to compact.');
|
||||||
|
} else {
|
||||||
|
logger.info('');
|
||||||
|
logger.info(
|
||||||
|
`Done. ${report.ok} pad(s) compacted, ${report.failed} failed, ` +
|
||||||
|
`${report.skippedFresh} fresh skipped. ` +
|
||||||
|
`Revisions: ${report.totalRevsBefore} → ${report.totalRevsAfter} ` +
|
||||||
|
`(reclaimed ${report.totalRevsBefore - report.totalRevsAfter}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseArgs = (argv: string[]): CompactStaleOpts | null => {
|
||||||
|
const opts: CompactStaleOpts = {
|
||||||
|
olderThanDays: NaN, keepRevisions: null, dryRun: false,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--dry-run') {
|
||||||
|
opts.dryRun = true;
|
||||||
|
} else if (a === '--older-than') {
|
||||||
|
const v = argv[++i];
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n) || n < 0) {
|
||||||
|
console.error(`--older-than expects a non-negative integer; got ${v}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
opts.olderThanDays = n;
|
||||||
|
} else if (a === '--keep') {
|
||||||
|
const v = argv[++i];
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n) || n < 0) {
|
||||||
|
console.error(`--keep expects a non-negative integer; got ${v}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
opts.keepRevisions = n;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(opts.olderThanDays)) {
|
||||||
|
console.error('--older-than is required');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const usage = () => {
|
||||||
|
console.error('Usage:');
|
||||||
|
console.error(' node bin/compactStalePads.js --older-than <days>');
|
||||||
|
console.error(' node bin/compactStalePads.js --older-than <days> --keep <N>');
|
||||||
|
console.error(' node bin/compactStalePads.js --older-than <days> --dry-run');
|
||||||
|
process.exit(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMain = require.main === module;
|
||||||
|
if (isMain) {
|
||||||
|
process.on('unhandledRejection', (err) => { throw err; });
|
||||||
|
|
||||||
|
const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();
|
||||||
|
const baseURL = `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`;
|
||||||
|
|
||||||
|
const apiGet = async (p: string): Promise<any> => {
|
||||||
|
const r = await fetch(baseURL + p);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
const apiPost = async (p: string): Promise<any> => {
|
||||||
|
const r = await fetch(baseURL + p, {method: 'POST'});
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const opts = parseArgs(process.argv.slice(2));
|
||||||
|
if (!opts) usage();
|
||||||
|
|
||||||
|
const apikey = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../APIKEY.txt'), {encoding: 'utf-8'}).trim();
|
||||||
|
|
||||||
|
// Bind the abstract API to fetch + APIKEY auth for the CLI shell.
|
||||||
|
const cliApi: CompactStaleApi = {
|
||||||
|
async listAllPads() {
|
||||||
|
const apiInfo = await apiGet('/api/');
|
||||||
|
const apiVersion: string | undefined = apiInfo.currentVersion;
|
||||||
|
if (!apiVersion) throw new Error('No version set in API');
|
||||||
|
(cliApi as any)._apiVersion = apiVersion;
|
||||||
|
const r = await apiGet(`/api/${apiVersion}/listAllPads?apikey=${apikey}`);
|
||||||
|
if (r.code !== 0) throw new Error(JSON.stringify(r));
|
||||||
|
return r.data.padIDs ?? [];
|
||||||
|
},
|
||||||
|
async getLastEdited(padId: string) {
|
||||||
|
const v = (cliApi as any)._apiVersion;
|
||||||
|
const r = await apiGet(
|
||||||
|
`/api/${v}/getLastEdited?apikey=${apikey}` +
|
||||||
|
`&padID=${encodeURIComponent(padId)}`);
|
||||||
|
if (r.code !== 0) throw new Error(JSON.stringify(r));
|
||||||
|
return r.data.lastEdited;
|
||||||
|
},
|
||||||
|
async getRevisionsCount(padId: string) {
|
||||||
|
const v = (cliApi as any)._apiVersion;
|
||||||
|
const r = await apiGet(
|
||||||
|
`/api/${v}/getRevisionsCount?apikey=${apikey}` +
|
||||||
|
`&padID=${encodeURIComponent(padId)}`);
|
||||||
|
if (r.code !== 0) throw new Error(JSON.stringify(r));
|
||||||
|
return r.data.revisions;
|
||||||
|
},
|
||||||
|
async compactPad(padId: string, keepRevisions: number | null) {
|
||||||
|
const v = (cliApi as any)._apiVersion;
|
||||||
|
const params = new URLSearchParams({apikey, padID: padId});
|
||||||
|
if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions));
|
||||||
|
const r = await apiPost(`/api/${v}/compactPad?${params.toString()}`);
|
||||||
|
if (r.code !== 0) throw new Error(JSON.stringify(r));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const report = await runCompactStale(cliApi, opts!);
|
||||||
|
if (report.failed > 0) process.exit(1);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# minimum required node version
|
# minimum required node version
|
||||||
REQUIRED_NODE_MAJOR=22
|
REQUIRED_NODE_MAJOR=24
|
||||||
REQUIRED_NODE_MINOR=0
|
REQUIRED_NODE_MINOR=0
|
||||||
|
|
||||||
# minimum required npm version
|
# minimum required npm version
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ function Test-Cmd([string]$name) {
|
||||||
$EtherpadDir = if ($env:ETHERPAD_DIR) { $env:ETHERPAD_DIR } else { 'etherpad-lite' }
|
$EtherpadDir = if ($env:ETHERPAD_DIR) { $env:ETHERPAD_DIR } else { 'etherpad-lite' }
|
||||||
$EtherpadBranch = if ($env:ETHERPAD_BRANCH) { $env:ETHERPAD_BRANCH } else { 'master' }
|
$EtherpadBranch = if ($env:ETHERPAD_BRANCH) { $env:ETHERPAD_BRANCH } else { 'master' }
|
||||||
$EtherpadRepo = if ($env:ETHERPAD_REPO) { $env:ETHERPAD_REPO } else { 'https://github.com/ether/etherpad.git' }
|
$EtherpadRepo = if ($env:ETHERPAD_REPO) { $env:ETHERPAD_REPO } else { 'https://github.com/ether/etherpad.git' }
|
||||||
$RequiredNodeMajor = 22
|
$RequiredNodeMajor = 24
|
||||||
|
|
||||||
Write-Step 'Etherpad installer'
|
Write-Step 'Etherpad installer'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ is_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||||
ETHERPAD_DIR="${ETHERPAD_DIR:-etherpad-lite}"
|
ETHERPAD_DIR="${ETHERPAD_DIR:-etherpad-lite}"
|
||||||
ETHERPAD_BRANCH="${ETHERPAD_BRANCH:-master}"
|
ETHERPAD_BRANCH="${ETHERPAD_BRANCH:-master}"
|
||||||
ETHERPAD_REPO="${ETHERPAD_REPO:-https://github.com/ether/etherpad.git}"
|
ETHERPAD_REPO="${ETHERPAD_REPO:-https://github.com/ether/etherpad.git}"
|
||||||
REQUIRED_NODE_MAJOR=22
|
REQUIRED_NODE_MAJOR=24
|
||||||
|
|
||||||
step "Etherpad installer"
|
step "Etherpad installer"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bin",
|
"name": "bin",
|
||||||
"version": "2.7.3",
|
"version": "3.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "checkAllPads.js",
|
"main": "checkAllPads.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|
@ -9,12 +9,12 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ep_etherpad-lite": "workspace:../src",
|
"ep_etherpad-lite": "workspace:../src",
|
||||||
"log4js": "^6.9.1",
|
"log4js": "^6.9.1",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.8.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.22.0",
|
||||||
"ueberdb2": "^5.0.48"
|
"ueberdb2": "^6.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.8.0",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ jobs:
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
||||||
# Node >= 22.9.0. setup-node's `22` resolves to the latest
|
# Node >= 22.9.0. Use Node 24 to match the rest of CI and the
|
||||||
# 22.x, which satisfies that.
|
# Etherpad core minimum.
|
||||||
node-version: 22
|
node-version: 24
|
||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
||||||
run: npm install -g npm@latest
|
run: npm install -g npm@latest
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,17 @@
|
||||||
// Returns a list of stale plugins and their authors email
|
// Returns a list of stale plugins and their authors email
|
||||||
|
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import settings from "../../src/node/utils/Settings";
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const resp = await fetch('https://static.etherpad.org/plugins.full.json');
|
if (!settings.privacy.pluginCatalog) {
|
||||||
|
console.info(
|
||||||
|
'stalePlugins: plugin catalog disabled by privacy.pluginCatalog=false; exiting'
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const resp = await fetch(`${settings.updateServer}/plugins.full.json`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||||
const data: any = await resp.json();
|
const data: any = await resp.json();
|
||||||
for (const plugin of Object.keys(data)) {
|
for (const plugin of Object.keys(data)) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
# Etherpad updates
|
# Etherpad updates
|
||||||
|
|
||||||
Etherpad ships with a built-in update subsystem. **Tier 1 (notify)** is enabled by default: a banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No automatic execution happens at this tier — admins are simply informed.
|
Etherpad ships with a built-in update subsystem.
|
||||||
|
|
||||||
Tiers 2 (manual click), 3 (auto with grace window), and 4 (autonomous in maintenance window) are designed but not yet implemented. They will land in subsequent releases.
|
- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No execution.
|
||||||
|
- **Tier 2 (manual click)** — admins on a git install can click "Apply update" at `/admin/update`. Etherpad drains active sessions, runs `git fetch / checkout / pnpm install / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure.
|
||||||
|
- **Tier 3 (auto with grace window)** — opt-in. On a git install, a newly detected release transitions execution state to `scheduled` and is applied after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons; an admin email (if `adminEmail` is set) fires once per scheduled tag.
|
||||||
|
- **Tier 4 (autonomous in maintenance window)** — designed, not yet implemented.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
|
|
@ -17,7 +20,14 @@ In `settings.json`:
|
||||||
"installMethod": "auto",
|
"installMethod": "auto",
|
||||||
"checkIntervalHours": 6,
|
"checkIntervalHours": 6,
|
||||||
"githubRepo": "ether/etherpad",
|
"githubRepo": "ether/etherpad",
|
||||||
"requireAdminForStatus": false
|
"requireAdminForStatus": false,
|
||||||
|
// Tier 2+ knobs (only meaningful at tier "manual" or higher):
|
||||||
|
"preApplyGraceMinutes": 0,
|
||||||
|
"drainSeconds": 60,
|
||||||
|
"rollbackHealthCheckSeconds": 60,
|
||||||
|
"diskSpaceMinMB": 500,
|
||||||
|
"requireSignature": false,
|
||||||
|
"trustedKeysPath": null
|
||||||
},
|
},
|
||||||
"adminEmail": null
|
"adminEmail": null
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +42,12 @@ In `settings.json`:
|
||||||
| `updates.checkIntervalHours` | `6` | How often to poll GitHub Releases. |
|
| `updates.checkIntervalHours` | `6` | How often to poll GitHub Releases. |
|
||||||
| `updates.githubRepo` | `"ether/etherpad"` | Override for forks. |
|
| `updates.githubRepo` | `"ether/etherpad"` | Override for forks. |
|
||||||
| `updates.requireAdminForStatus` | `false` | Lock the `/admin/update/status` endpoint to authenticated admin sessions. Default `false` matches existing Etherpad behavior — `/health` already exposes `releaseId` publicly, and changelog data comes from a public GitHub release. Set `true` to hide the full update payload from non-admins without disabling the updater (`tier: "off"` is the heavier opt-out that removes the endpoints entirely). |
|
| `updates.requireAdminForStatus` | `false` | Lock the `/admin/update/status` endpoint to authenticated admin sessions. Default `false` matches existing Etherpad behavior — `/health` already exposes `releaseId` publicly, and changelog data comes from a public GitHub release. Set `true` to hide the full update payload from non-admins without disabling the updater (`tier: "off"` is the heavier opt-out that removes the endpoints entirely). |
|
||||||
|
| `updates.preApplyGraceMinutes` | `0` | **Tier 3 only.** Wait this many minutes between detecting a new release and starting the drain so the admin can cancel via `/admin/update`. `0` applies immediately when allowed. Clamped to `[0, 7*24*60]` (one week). Has no effect at tier `"manual"`. |
|
||||||
|
| `updates.drainSeconds` | `60` | How long to broadcast "restart imminent" announcements to active pads before exiting. T-60 / T-30 / T-10 broadcasts fire automatically at the matching offsets within this window. |
|
||||||
|
| `updates.rollbackHealthCheckSeconds` | `60` | After a fresh boot post-update, give `/health` this long to come up. If it doesn't, RollbackHandler restores the previous SHA. |
|
||||||
|
| `updates.diskSpaceMinMB` | `500` | Pre-flight refuses to start an update unless the install volume has at least this many MB free. |
|
||||||
|
| `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag <tag>` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. |
|
||||||
|
| `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. |
|
||||||
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
|
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
|
||||||
|
|
||||||
## What "outdated" means
|
## What "outdated" means
|
||||||
|
|
@ -81,3 +97,98 @@ The version check sends no telemetry. Etherpad fetches the public GitHub Release
|
||||||
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
|
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
|
||||||
|
|
||||||
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
|
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
|
||||||
|
|
||||||
|
## Tier 2 — manual click
|
||||||
|
|
||||||
|
Tier 2 is opt-in. To enable: set `updates.tier: "manual"` and ensure your install was deployed via git (not docker / npm / managed package).
|
||||||
|
|
||||||
|
### Process supervisor is required
|
||||||
|
|
||||||
|
Etherpad applies an update by **exiting with code 75** so a process supervisor restarts it. Without a supervisor the instance simply exits and stays down. Common supervisor setups:
|
||||||
|
|
||||||
|
- **systemd:** add `Restart=on-failure` + `RestartSec=5` to your unit file.
|
||||||
|
- **pm2:** the default behaviour restarts on exit.
|
||||||
|
- **docker:** add `--restart=unless-stopped` (Tier 2 itself is not supported on docker installs anyway, but if you wrap your own image around a git checkout this applies).
|
||||||
|
|
||||||
|
### What clicking "Apply update" does
|
||||||
|
|
||||||
|
1. **Lock acquire** — `var/update.lock` (PID-based, stale locks reaped automatically).
|
||||||
|
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, target tag exists at the configured remote, signature verifies (if `requireSignature: true`). On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
|
||||||
|
3. **Drain** — `drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly.
|
||||||
|
4. **Execute** — `git fetch --tags origin`, `git checkout <tag>`, `pnpm install --frozen-lockfile`, `pnpm run build:ui`. Output streams to `var/log/update.log` (rotated 10 MB × 5).
|
||||||
|
5. **Exit 75** — the supervisor restarts on the new version.
|
||||||
|
6. **Health check** — RollbackHandler arms a `rollbackHealthCheckSeconds` timer at boot. When `/health` responds 200 (i.e., Etherpad reaches the `RUNNING` state) the timer cancels and the state lands on `verified`.
|
||||||
|
|
||||||
|
### Failure modes
|
||||||
|
|
||||||
|
| What went wrong | Resulting state | Admin action |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). |
|
||||||
|
| `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. |
|
||||||
|
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
|
||||||
|
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
|
||||||
|
| The new version crashes at boot more than twice (`bootCount > 2`) | `rolled-back` | Crash-loop guard kicks in regardless of the health-check timer. |
|
||||||
|
| Rollback itself fails (e.g., `pnpm install` errors restoring old lockfile) | `rollback-failed` | **Manual intervention required.** The admin banner switches to a strong red alert. Restore the install by hand, then click **Acknowledge** to clear the lock and re-allow Tier 2 attempts. |
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
All Tier 2 endpoints require an authenticated admin session (`is_admin: true`) regardless of `requireAdminForStatus`.
|
||||||
|
|
||||||
|
- `POST /admin/update/apply` — start an apply. Returns `202 {accepted, drainEndsAt}` once the drain begins. Body unused.
|
||||||
|
- `POST /admin/update/cancel` — cancel during pre-flight or drain. Returns `409` once the executor has begun mutating the filesystem (state machine guarantees we either complete or roll back from there).
|
||||||
|
- `POST /admin/update/acknowledge` — clear a terminal `preflight-failed` / `rolled-back` / `rollback-failed` state back to `idle`.
|
||||||
|
- `GET /admin/update/log` — tail the last 200 lines of `var/log/update.log`. Plain text. Used by the in-progress UI.
|
||||||
|
|
||||||
|
### Signature verification
|
||||||
|
|
||||||
|
Default off. Etherpad releases are not yet consistently signed; turning verification on by default would block every Tier 2 update. To enable:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"updates": {
|
||||||
|
"requireSignature": true,
|
||||||
|
"trustedKeysPath": "/srv/etherpad/keys" // optional — defaults to the OS user keyring
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The check shells out to `git verify-tag <tag>`. The keyring at `trustedKeysPath` is passed to git via `GNUPGHOME`. If `trustedKeysPath` is `null` (default), the OS user's default keyring is used.
|
||||||
|
|
||||||
|
### Docker-friendly update flows (future work)
|
||||||
|
|
||||||
|
Tier 2 deliberately refuses to apply on `installMethod: "docker"` because in-container `git fetch / pnpm install / build:ui` doesn't survive a container restart — the orchestrator brings the container back up on the same image tag and the work is lost. Docker installs stay on Tier 1 (banner + version status) for now.
|
||||||
|
|
||||||
|
## Tier 3 — auto with grace window
|
||||||
|
|
||||||
|
Tier 3 builds on Tier 2 by scheduling the apply automatically when a new release is detected. The same `git fetch / checkout / pnpm install / build:ui / exit 75` pipeline runs — only the trigger changes.
|
||||||
|
|
||||||
|
To enable, on a git install: set `updates.tier: "auto"` and (optionally) `updates.preApplyGraceMinutes` to the grace duration you want.
|
||||||
|
|
||||||
|
### What happens when a new release lands
|
||||||
|
|
||||||
|
1. The periodic version checker (`updates.checkIntervalHours`) hits GitHub Releases.
|
||||||
|
2. If `policy.canAuto` is true (install is git, no terminal `rollback-failed` state, tier is `"auto"` or `"autonomous"`), the scheduler transitions `execution.status` to `scheduled` with `scheduledFor = now + preApplyGraceMinutes`.
|
||||||
|
3. The schedule is persisted to `var/update-state.json`, so an Etherpad restart inside the grace window rehydrates the timer rather than losing the schedule.
|
||||||
|
4. `/admin/update` shows a live countdown panel plus two buttons:
|
||||||
|
- **Cancel** — `POST /admin/update/cancel` returns the state to `idle` and drops the in-process timer.
|
||||||
|
- **Apply now** — `POST /admin/update/apply` skips the remaining grace; the regular Tier 2 pipeline runs immediately.
|
||||||
|
5. When the timer fires, the scheduler runs the exact same pipeline as a manual Tier 2 click: pre-flight → drain → execute → exit 75.
|
||||||
|
|
||||||
|
### Re-scheduling and stale state
|
||||||
|
|
||||||
|
- If a newer release tag appears while a schedule is pending, the scheduler re-arms the timer for the new tag. The `email.graceStartTag` dedupe field guards against duplicate `grace-start` notifications.
|
||||||
|
- If `updates.tier` is flipped back to `"manual"` or `"notify"` while a schedule is pending, the next periodic check cancels the schedule (state back to `idle`).
|
||||||
|
- `rollback-failed` disables Tier 3 globally. The admin must `POST /admin/update/acknowledge` (or visit `/admin/update` and click Acknowledge) before any further auto-schedules are armed. Tier 2 manual click stays available because the admin click *is* the intervention the terminal state requires.
|
||||||
|
|
||||||
|
### Email (`adminEmail` set)
|
||||||
|
|
||||||
|
A single `grace-start` notification fires per scheduled tag:
|
||||||
|
|
||||||
|
> [Etherpad] Auto-update scheduled for 2.7.2
|
||||||
|
|
||||||
|
with the `scheduledFor` timestamp. Etherpad core does not yet wire SMTP; the message logs as `(would send email)` until a future PR adds a transport. Cadence and dedupe still update correctly.
|
||||||
|
|
||||||
|
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:
|
||||||
|
|
||||||
|
- **Instructions-only.** When the page detects `installMethod: docker` *and* a newer release exists, swap the policy-denial copy for actionable instructions (`docker pull etherpad/etherpad:<tag>` for plain docker; `docker compose pull && docker compose up -d` for compose). Cheap, no new attack surface.
|
||||||
|
- **Deploy webhook.** New setting `updates.dockerWebhook`. When set, the Apply button on a docker install POSTs to the configured URL and trusts the orchestrator (Render / Railway / Fly / Portainer / Coolify / GitHub Actions — they all expose redeploy webhooks) to do the actual pull-and-recreate.
|
||||||
|
|
||||||
|
Direct Docker-socket access (mount `/var/run/docker.sock` into the container) is **out of scope** — anyone who escapes the Etherpad process via that socket gets root on the host. Admins who want fully autonomous docker updates should run [Watchtower](https://containrrr.dev/watchtower/) alongside Etherpad rather than bake equivalent privilege into Etherpad itself.
|
||||||
|
|
|
||||||
39
doc/cli.md
39
doc/cli.md
|
|
@ -27,3 +27,42 @@ In this example we migrate from the old dirty db to the new rustydb engine. So w
|
||||||
|
|
||||||
After that we need to move the data from dirty to rustydb.
|
After that we need to move the data from dirty to rustydb.
|
||||||
Therefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.
|
Therefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.
|
||||||
|
|
||||||
|
## Pad compaction
|
||||||
|
|
||||||
|
Long-lived pads with heavy edit history accumulate revisions in the database. Three CLIs reclaim that space, in increasing scope:
|
||||||
|
|
||||||
|
| Tool | Targets | When to use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `bin/compactPad.js <padID>` | one pad | you know which pad is fat |
|
||||||
|
| `bin/compactAllPads.js` | every pad | bulk reclaim across the whole instance |
|
||||||
|
| `bin/compactStalePads.js --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
|
||||||
|
|
||||||
|
All three are gated on `cleanup.enabled = true` in `settings.json` and are **destructive**: history is collapsed (or trimmed). Export anything you can't afford to lose with `getEtherpad` first.
|
||||||
|
|
||||||
|
Common flags:
|
||||||
|
|
||||||
|
- `--keep N` — retain the last N revisions instead of collapsing all history.
|
||||||
|
- `--dry-run` — list pads and revision counts without writing.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
````
|
||||||
|
# Compact a specific pad, collapsing all history.
|
||||||
|
node bin/compactPad.js my-pad
|
||||||
|
|
||||||
|
# Keep only the last 50 revisions of one pad.
|
||||||
|
node bin/compactPad.js my-pad --keep 50
|
||||||
|
|
||||||
|
# Compact every pad on the instance (per-pad failures don't stop the run).
|
||||||
|
node bin/compactAllPads.js
|
||||||
|
node bin/compactAllPads.js --dry-run
|
||||||
|
|
||||||
|
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions.
|
||||||
|
node bin/compactStalePads.js --older-than 90 --keep 50
|
||||||
|
node bin/compactStalePads.js --older-than 90 --dry-run
|
||||||
|
````
|
||||||
|
|
||||||
|
`bin/compactStalePads.js` is the right tool for periodic operator runs on long-lived instances — hot pads that users are still navigating in timeslider stay untouched, and only the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault) are counted but do not abort the bulk run; the exit code reflects whether anything failed.
|
||||||
|
|
||||||
|
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive over the wire (issues #6194, #7642).
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,4 @@ Etherpad HTTP API clients may make use (if they choose so) to send another cooki
|
||||||
|
|
||||||
| Name | Sample value | Domain | Usage description |
|
| Name | Sample value | Domain | Usage description |
|
||||||
|-----------|------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-----------|------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| sessionID | s.1c70968b333b25476a2c7bdd0e0bed17 | example.org | Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session |
|
| sessionID | s.1c70968b333b25476a2c7bdd0e0bed17 | example.org | Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID is set as a cookie by the integrator and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Since [#7045](https://github.com/ether/etherpad/issues/7045) Etherpad reads this cookie server-side from the socket.io handshake, so integrators **should** set it as `HttpOnly; Secure; SameSite=Lax` to mitigate XSS. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session |
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,17 @@ The variable value has to be a space separated, double quoted list of plugin nam
|
||||||
|
|
||||||
Some plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.
|
Some plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.
|
||||||
|
|
||||||
==== Rebuilding including export functionality for DOC/DOCX/PDF/ODT
|
==== Office-format import/export
|
||||||
|
|
||||||
If you want to be able to export your pads to DOC/DOCX/PDF/ODT files, you can
|
DOCX export, PDF export, and DOCX import work out of the box — Etherpad
|
||||||
install Libreoffice via setting the `INSTALL_SOFFICE` build variable to any
|
ships pure-JS in-process converters and needs no extra dependencies for
|
||||||
value.
|
those three formats.
|
||||||
|
|
||||||
Also, you will need to configure the path to the libreoffice executable
|
DOC/ODT/RTF export and PDF import still require LibreOffice. To enable
|
||||||
via setting the `soffice` property in `<BASEDIR>/settings.json.docker` to
|
them, install LibreOffice via the `INSTALL_SOFFICE` build variable (any
|
||||||
`/usr/bin/soffice` or via setting the environment variable `SOFFICE` to
|
value), and either set the `soffice` property in
|
||||||
`/usr/bin/soffice`.
|
`<BASEDIR>/settings.json.docker` to `/usr/bin/soffice` or set the
|
||||||
|
`SOFFICE` environment variable to `/usr/bin/soffice`.
|
||||||
|
|
||||||
==== Examples
|
==== Examples
|
||||||
|
|
||||||
|
|
@ -452,7 +453,7 @@ For the editor container, you can also make it full width by adding `full-width-
|
||||||
| `21600` (6 hours)
|
| `21600` (6 hours)
|
||||||
|
|
||||||
| `SOFFICE`
|
| `SOFFICE`
|
||||||
| Absolute path to the soffice (LibreOffice) executable. Needed for advanced import/export of pads (docx, pdf, odt). Setting it to null disables LibreOffice and will only allow plain text and HTML import/exports.
|
| Absolute path to the soffice (LibreOffice) executable. When configured, all advanced import/export formats use it (docx, pdf, odt, doc, rtf). Setting it to null falls back to in-process pure-JS converters: docx and pdf export, plus docx import, still work; odt/doc/rtf and pdf import remain unavailable.
|
||||||
| `null`
|
| `null`
|
||||||
|
|
||||||
| `ALLOW_UNKNOWN_FILE_ENDS`
|
| `ALLOW_UNKNOWN_FILE_ENDS`
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,17 @@ The variable value has to be a space separated, double quoted list of plugin nam
|
||||||
|
|
||||||
Some plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.
|
Some plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.
|
||||||
|
|
||||||
### Rebuilding including export functionality for DOC/DOCX/PDF/ODT
|
### Office-format import/export
|
||||||
|
|
||||||
If you want to be able to export your pads to DOC/DOCX/PDF/ODT files, you can
|
DOCX export, PDF export, and DOCX import work out of the box — Etherpad
|
||||||
install Libreoffice via setting the `INSTALL_SOFFICE` build variable to any
|
ships pure-JS in-process converters and needs no extra dependencies for
|
||||||
value.
|
those three formats.
|
||||||
|
|
||||||
Also, you will need to configure the path to the libreoffice executable
|
DOC/ODT/RTF export and PDF import still require LibreOffice. To enable
|
||||||
via setting the `soffice` property in `<BASEDIR>/settings.json.docker` to
|
them, install LibreOffice via the `INSTALL_SOFFICE` build variable (any
|
||||||
`/usr/bin/soffice` or via setting the environment variable `SOFFICE` to
|
value), and either set the `soffice` property in
|
||||||
`/usr/bin/soffice`.
|
`<BASEDIR>/settings.json.docker` to `/usr/bin/soffice` or set the
|
||||||
|
`SOFFICE` environment variable to `/usr/bin/soffice`.
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
|
|
@ -197,7 +198,7 @@ For the editor container, you can also make it full width by adding `full-width-
|
||||||
| `EDIT_ONLY` | Users may edit pads but not create new ones. Pad creation is only via the API. This applies both to group pads and regular pads. | `false` |
|
| `EDIT_ONLY` | Users may edit pads but not create new ones. Pad creation is only via the API. This applies both to group pads and regular pads. | `false` |
|
||||||
| `MINIFY` | If true, all css & js will be minified before sending to the client. This will improve the loading performance massively, but makes it difficult to debug the javascript/css | `true` |
|
| `MINIFY` | If true, all css & js will be minified before sending to the client. This will improve the loading performance massively, but makes it difficult to debug the javascript/css | `true` |
|
||||||
| `MAX_AGE` | How long may clients use served javascript code (in seconds)? Not setting this may cause problems during deployment. Set to 0 to disable caching. | `21600` (6 hours) |
|
| `MAX_AGE` | How long may clients use served javascript code (in seconds)? Not setting this may cause problems during deployment. Set to 0 to disable caching. | `21600` (6 hours) |
|
||||||
| `SOFFICE` | Absolute path to the soffice (LibreOffice) executable. Needed for advanced import/export of pads (docx, pdf, odt). Setting it to null disables LibreOffice and will only allow plain text and HTML import/exports. | `null` |
|
| `SOFFICE` | Absolute path to the soffice (LibreOffice) executable. When configured, all advanced import/export formats use it (docx, pdf, odt, doc, rtf). Setting it to null falls back to in-process pure-JS converters: docx and pdf export, plus docx import, still work; odt/doc/rtf and pdf import remain unavailable. | `null` |
|
||||||
| `ALLOW_UNKNOWN_FILE_ENDS` | Allow import of file types other than the supported ones: txt, doc, docx, rtf, odt, html & htm | `true` |
|
| `ALLOW_UNKNOWN_FILE_ENDS` | Allow import of file types other than the supported ones: txt, doc, docx, rtf, odt, html & htm | `true` |
|
||||||
| `REQUIRE_AUTHENTICATION` | This setting is used if you require authentication of all users. Note: "/admin" always requires authentication. | `false` |
|
| `REQUIRE_AUTHENTICATION` | This setting is used if you require authentication of all users. Note: "/admin" always requires authentication. | `false` |
|
||||||
| `REQUIRE_AUTHORIZATION` | Require authorization by a module, or a user with is_admin set, see below. | `false` |
|
| `REQUIRE_AUTHORIZATION` | Require authorization by a module, or a user with is_admin set, see below. | `false` |
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,9 @@ If a package previously had an `NPM_TOKEN` secret in CI:
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Node.js**: >= 22.12 on the runner. npm 11 requires `>=22.9.0` and
|
- **Node.js**: >= 24 on the runner. `setup-node@v6 with version: 24`
|
||||||
`oxc-minify` (a vitepress peer for the docs build) requires `>=22.12.0`,
|
resolves to the latest 24.x. The project's `engines.node` requires
|
||||||
both of which `setup-node@v6 with version: 22` satisfies (resolves to the
|
`>=24.0.0`.
|
||||||
latest 22.x). The project's `engines.node` requires `>=22.12.0`.
|
|
||||||
- **npm CLI**: >= 11.5.1. The publish workflow runs `npm install -g npm@latest`
|
- **npm CLI**: >= 11.5.1. The publish workflow runs `npm install -g npm@latest`
|
||||||
before publishing so the bundled npm version doesn't matter.
|
before publishing so the bundled npm version doesn't matter.
|
||||||
- **Runner**: must be a GitHub-hosted (cloud) runner. Self-hosted runners are
|
- **Runner**: must be a GitHub-hosted (cloud) runner. Self-hosted runners are
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"oxc-minify": "^0.129.0",
|
"oxc-minify": "^0.131.0",
|
||||||
"vitepress": "^2.0.0-alpha.17"
|
"vitepress": "^2.0.0-alpha.17"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ publish your plugin.
|
||||||
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
|
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
|
||||||
"contributors": [],
|
"contributors": [],
|
||||||
"dependencies": {"MODULE": "0.3.20"},
|
"dependencies": {"MODULE": "0.3.20"},
|
||||||
"engines": {"node": ">=12.17.0"}
|
"engines": {"node": ">=22.0.0"}
|
||||||
}
|
}
|
||||||
----
|
----
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ publish your plugin.
|
||||||
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
|
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
|
||||||
"contributors": [],
|
"contributors": [],
|
||||||
"dependencies": {"MODULE": "0.3.20"},
|
"dependencies": {"MODULE": "0.3.20"},
|
||||||
"engines": {"node": ">=12.17.0"}
|
"engines": {"node": ">=22.0.0"}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -238,6 +238,77 @@ operations in `templates/`, in files of type ".ejs", since Etherpad uses EJS for
|
||||||
HTML templating. See the following link for more information about EJS:
|
HTML templating. See the following link for more information about EJS:
|
||||||
<https://github.com/visionmedia/ejs>.
|
<https://github.com/visionmedia/ejs>.
|
||||||
|
|
||||||
|
## Plugin-namespaced pad-wide options
|
||||||
|
|
||||||
|
Plugins can ride the existing `padoptions` COLLABROOM rail to store
|
||||||
|
pad-wide settings — broadcast to every connected client, persisted with the
|
||||||
|
pad, and honored by `enforceSettings` — instead of inventing their own
|
||||||
|
message type and storage. The model matches how `enablePadWideSettings`
|
||||||
|
works for native toggles like sticky chat or line numbers.
|
||||||
|
|
||||||
|
### Capability detection
|
||||||
|
|
||||||
|
```js
|
||||||
|
let padOptionsPluginPassthrough = false;
|
||||||
|
try {
|
||||||
|
// The require throws on Etherpad versions that predate this capability;
|
||||||
|
// plugins should degrade gracefully (typically falling back to a per-user
|
||||||
|
// cookie toggle) when the flag is missing.
|
||||||
|
padOptionsPluginPassthrough =
|
||||||
|
require('ep_etherpad-lite/node/utils/PluginCapabilities')
|
||||||
|
.padOptionsPluginPassthrough === true;
|
||||||
|
} catch (_e) { /* older core */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
The flag means the core has the passthrough patch *available*. Whether it
|
||||||
|
is actually *enabled* at runtime is a separate per-instance setting — see
|
||||||
|
below.
|
||||||
|
|
||||||
|
### Runtime flag
|
||||||
|
|
||||||
|
The passthrough is gated by `settings.enablePluginPadOptions`, default
|
||||||
|
`false`. Operators must opt in via `settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enablePluginPadOptions": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, the server reflects the value to every client via
|
||||||
|
`clientVars.enablePluginPadOptions` so plugins can detect both *capable*
|
||||||
|
(static) and *active* (per-pad request) at the same point.
|
||||||
|
|
||||||
|
### Key namespace
|
||||||
|
|
||||||
|
Plugins must use keys matching `/^ep_[a-z0-9_]+$/`. The recommended pattern
|
||||||
|
is `ep_<plugin_name>` (e.g. `ep_table_of_contents`); compose multiple
|
||||||
|
pad-wide settings under one key as a plain object:
|
||||||
|
|
||||||
|
```js
|
||||||
|
pad.changePadOption('ep_my_plugin', {enabled: true, depth: 3});
|
||||||
|
```
|
||||||
|
|
||||||
|
The server passes through any matching key on the existing `padoptions`
|
||||||
|
message, persists it with the pad, and broadcasts it to every connected
|
||||||
|
client. `pad.padOptions.ep_my_plugin` reflects the latest value on every
|
||||||
|
client.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Server-side `Pad.normalizePadSettings()` enforces three rules on every
|
||||||
|
plugin-namespaced key:
|
||||||
|
|
||||||
|
- Values must round-trip through `JSON.stringify` (no functions, symbols,
|
||||||
|
BigInt, or circular references).
|
||||||
|
- Each key's serialized payload must fit within **64 KB**.
|
||||||
|
- The combined size of all `ep_*` values per pad must fit within **256 KB**.
|
||||||
|
|
||||||
|
Values that fail any of these rules are dropped with a `console.warn`; the
|
||||||
|
rest of the settings round-trip cleanly. The caps prevent a misbehaving
|
||||||
|
plugin from bloating the persisted pad payload or the COLLABROOM
|
||||||
|
broadcast.
|
||||||
|
|
||||||
## Writing and running front-end tests for your plugin
|
## Writing and running front-end tests for your plugin
|
||||||
|
|
||||||
Etherpad allows you to easily create front-end tests for plugins.
|
Etherpad allows you to easily create front-end tests for plugins.
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,18 @@ A skin is a directory located under `static/skins/<skin_name>`, with the followi
|
||||||
* `index.css`: stylesheet affecting `/`
|
* `index.css`: stylesheet affecting `/`
|
||||||
* `pad.js`: javascript that will be run in `/p/:padid`
|
* `pad.js`: javascript that will be run in `/p/:padid`
|
||||||
* `pad.css`: stylesheet affecting `/p/:padid`
|
* `pad.css`: stylesheet affecting `/p/:padid`
|
||||||
* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`
|
* `timeslider.js`: javascript that will be run in the embedded timeslider iframe
|
||||||
* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`
|
* `timeslider.css`: stylesheet affecting the embedded timeslider iframe
|
||||||
* `favicon.ico`: overrides the default favicon
|
* `favicon.ico`: overrides the default favicon
|
||||||
* `robots.txt`: overrides the default `robots.txt`
|
* `robots.txt`: overrides the default `robots.txt`
|
||||||
|
|
||||||
|
Since Etherpad *2.7*, the timeslider is rendered in-place inside the pad
|
||||||
|
page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to
|
||||||
|
`/p/:padid` so the in-pad `PadModeController` can take over via a `#rev/N`
|
||||||
|
URL hash. The full timeslider HTML is still served at
|
||||||
|
`/p/:padid/timeslider?embed=1` -- that is the URL the in-pad iframe loads,
|
||||||
|
and the URL to use if you embed the timeslider in your own page.
|
||||||
|
|
||||||
You can choose a skin changing the parameter `skinName` in `settings.json`.
|
You can choose a skin changing the parameter `skinName` in `settings.json`.
|
||||||
|
|
||||||
Since Etherpad **1.7.5**, two skins are included:
|
Since Etherpad **1.7.5**, two skins are included:
|
||||||
|
|
|
||||||
11
doc/skins.md
11
doc/skins.md
|
|
@ -6,8 +6,15 @@ A skin is a directory located under `static/skins/<skin_name>`, with the followi
|
||||||
* `index.css`: stylesheet affecting `/`
|
* `index.css`: stylesheet affecting `/`
|
||||||
* `pad.js`: javascript that will be run in `/p/:padid`
|
* `pad.js`: javascript that will be run in `/p/:padid`
|
||||||
* `pad.css`: stylesheet affecting `/p/:padid`
|
* `pad.css`: stylesheet affecting `/p/:padid`
|
||||||
* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`
|
* `timeslider.js`: javascript that will be run in the embedded timeslider iframe
|
||||||
* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`
|
* `timeslider.css`: stylesheet affecting the embedded timeslider iframe
|
||||||
|
|
||||||
|
Since Etherpad **2.7**, the timeslider is rendered in-place inside the pad
|
||||||
|
page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to
|
||||||
|
`/p/:padid` so the in-pad PadModeController can take over via a `#rev/N`
|
||||||
|
URL hash. The full timeslider HTML is still served at
|
||||||
|
`/p/:padid/timeslider?embed=1` — that is the URL the in-pad iframe loads,
|
||||||
|
and the URL to use if you embed the timeslider in your own page.
|
||||||
* `favicon.ico`: overrides the default favicon
|
* `favicon.ico`: overrides the default favicon
|
||||||
* `robots.txt`: overrides the default `robots.txt`
|
* `robots.txt`: overrides the default `robots.txt`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,804 @@
|
||||||
|
# Issue 7638 — Typesafe Admin API Client + TanStack Query Rails Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Lay down the codegen toolchain, runtime client, and TanStack Query provider for the admin UI. No call-site migrations.
|
||||||
|
|
||||||
|
**Architecture:** A small Node script imports the OpenAPI spec builder from `src/node/hooks/express/openapi.ts`, writes the JSON to a temp file, and runs `openapi-typescript` to produce a checked-in `admin/src/api/schema.d.ts`. The runtime exposes a typed `openapi-fetch` client and `openapi-react-query` hooks via `admin/src/api/client.ts`, mounted under a `<QueryProvider>` at the admin root. CI re-runs codegen and fails if the working tree is dirty.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, React 19, Vite (rolldown-vite), `openapi-typescript`, `openapi-fetch`, `openapi-react-query`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `tsx` (devDep, runs the codegen script against TS source).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-01-issue-7638-admin-typesafe-api-design.md`
|
||||||
|
|
||||||
|
**Branch:** `chore/admin-typesafe-api-7638` (already cut off `origin/develop`, design doc committed as `41d2babf4`).
|
||||||
|
|
||||||
|
**Working directory for all commands:** `/home/jose/etherpad/etherpad-lite` unless otherwise stated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `admin/scripts/gen-api.mjs` — orchestrator script. Invokes `tsx` to run a small TS entry that prints the spec JSON, captures stdout to a temp file, then shells out to `openapi-typescript`.
|
||||||
|
- `admin/scripts/dump-spec.ts` — TS entry that imports `generateDefinitionForVersion` from the etherpad source and writes the JSON to stdout.
|
||||||
|
- `admin/src/api/schema.d.ts` — generated. Checked in.
|
||||||
|
- `admin/src/api/client.ts` — `openapi-fetch` + `openapi-react-query` instances.
|
||||||
|
- `admin/src/api/QueryProvider.tsx` — TanStack Query provider, dev-only devtools.
|
||||||
|
- `admin/src/api/__tests__/client.test.ts` — module-load smoke test.
|
||||||
|
- `admin/README.md` — codegen docs (file does not currently exist).
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `src/node/hooks/express/openapi.ts` — add `export { generateDefinitionForVersion }` at the end so external scripts can call the spec builder. Surgical change, no behavior delta.
|
||||||
|
- `admin/package.json` — add deps and `gen:api` script; amend `build` to run `gen:api` first.
|
||||||
|
- `admin/src/main.tsx` — wrap router subtree in `<QueryProvider>`.
|
||||||
|
- `.github/workflows/frontend-admin-tests.yml` — add a freshness-check step before the existing admin build step.
|
||||||
|
|
||||||
|
**Conventions to honor:**
|
||||||
|
- Per project memory, the PR will go to `johnmclear/etherpad-lite`, not `ether/etherpad-lite`.
|
||||||
|
- Commit at the end of each task.
|
||||||
|
- Run `pnpm ts-check` and admin's lint at the end before declaring done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Export the spec builder from `openapi.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/node/hooks/express/openapi.ts:422` (and end of file)
|
||||||
|
|
||||||
|
The script needs to call `generateDefinitionForVersion` from outside the module. It is currently only used within the file. Adding a CommonJS-style export keeps the existing `exports.expressPreSession` style consistent.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current export style at the bottom of the file**
|
||||||
|
|
||||||
|
Run: `grep -n "^exports\." src/node/hooks/express/openapi.ts`
|
||||||
|
Expected output: a line like `578:exports.expressPreSession = async (hookName:string, {app}:any) => {`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the export**
|
||||||
|
|
||||||
|
Append at the end of `src/node/hooks/express/openapi.ts` (after the existing hook export, after line 771):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
exports.generateDefinitionForVersion = generateDefinitionForVersion;
|
||||||
|
exports.APIPathStyle = APIPathStyle;
|
||||||
|
```
|
||||||
|
|
||||||
|
(Both are needed: the script will call `generateDefinitionForVersion(apiHandler.latestApiVersion, APIPathStyle.FLAT)` and we want a single import surface.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify ts-check still passes**
|
||||||
|
|
||||||
|
Run: `pnpm ts-check`
|
||||||
|
Expected: no new errors. (If pre-existing errors are present, confirm none are in `openapi.ts`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/node/hooks/express/openapi.ts
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(api): export generateDefinitionForVersion from openapi hook
|
||||||
|
|
||||||
|
Required by the admin codegen script (#7638) to dump the OpenAPI spec
|
||||||
|
without booting Express. No behavior change for the request hook.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add admin dependencies
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `admin/package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current `admin/package.json`**
|
||||||
|
|
||||||
|
Run: `cat admin/package.json`
|
||||||
|
Expected: confirm there is a `dependencies` block and a `devDependencies` block.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Install runtime deps**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
pnpm --filter admin add @tanstack/react-query @tanstack/react-query-devtools openapi-fetch openapi-react-query
|
||||||
|
```
|
||||||
|
Expected: deps added under `dependencies`. `pnpm-lock.yaml` updated at repo root.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Install dev deps**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
pnpm --filter admin add -D openapi-typescript tsx
|
||||||
|
```
|
||||||
|
Expected: deps added under `devDependencies`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Sanity check the diff**
|
||||||
|
|
||||||
|
Run: `git diff admin/package.json`
|
||||||
|
Expected: six new entries (4 deps, 2 devDeps), no other changes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/package.json pnpm-lock.yaml
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
chore(admin): add OpenAPI codegen + TanStack Query deps (#7638)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Write the spec-dump entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `admin/scripts/dump-spec.ts`
|
||||||
|
|
||||||
|
This file is intentionally tiny. It runs under `tsx` so it can resolve the etherpad-lite TypeScript source directly.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the file**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// admin/scripts/dump-spec.ts
|
||||||
|
//
|
||||||
|
// Imports the OpenAPI spec builder from the etherpad source and writes the
|
||||||
|
// flat-style spec for the latest API version as JSON to stdout. Invoked by
|
||||||
|
// admin/scripts/gen-api.mjs via `tsx`.
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||||
|
|
||||||
|
// `openapi.ts` uses CommonJS-style `exports.*` despite living in an ESM repo,
|
||||||
|
// so we go through createRequire to load it cleanly.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(pathToFileURL(path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts')).toString());
|
||||||
|
|
||||||
|
const apiHandler = require('../../src/node/handler/APIHandler');
|
||||||
|
const { generateDefinitionForVersion, APIPathStyle } =
|
||||||
|
require('../../src/node/hooks/express/openapi') as {
|
||||||
|
generateDefinitionForVersion: (version: string, style?: string) => unknown;
|
||||||
|
APIPathStyle: { FLAT: string; REST: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const spec = generateDefinitionForVersion(apiHandler.latestApiVersion, APIPathStyle.FLAT);
|
||||||
|
process.stdout.write(JSON.stringify(spec, null, 2));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-test the entry**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd admin && pnpm exec tsx scripts/dump-spec.ts > /tmp/etherpad-spec.json
|
||||||
|
echo "exit: $?"
|
||||||
|
head -c 200 /tmp/etherpad-spec.json
|
||||||
|
```
|
||||||
|
Expected: exit 0; the head output starts with `{` and contains `"openapi"` and `"paths"`.
|
||||||
|
|
||||||
|
If the script fails because importing `openapi.ts` triggers errors from `Settings`, debug by running `pnpm exec tsx -e "require('../src/node/hooks/express/openapi.ts')"` from `admin/` to isolate. The most likely fix is to set `EP_LOG_DESTINATION=stderr` or similar; do not refactor `Settings` from this PR — note the issue and ask before expanding scope.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/scripts/dump-spec.ts
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
chore(admin): add OpenAPI spec dump entry (#7638)
|
||||||
|
|
||||||
|
Loaded via tsx by gen-api.mjs in the next commit.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Write the codegen orchestrator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `admin/scripts/gen-api.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the file**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// admin/scripts/gen-api.mjs
|
||||||
|
//
|
||||||
|
// Regenerates admin/src/api/schema.d.ts from the live OpenAPI spec exported
|
||||||
|
// by src/node/hooks/express/openapi.ts. Run via `pnpm --filter admin gen:api`.
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const adminRoot = path.resolve(here, '..');
|
||||||
|
const outFile = path.join(adminRoot, 'src', 'api', 'schema.d.ts');
|
||||||
|
|
||||||
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'etherpad-openapi-'));
|
||||||
|
const specPath = path.join(tmpDir, 'spec.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dump = spawnSync('pnpm', ['exec', 'tsx', 'scripts/dump-spec.ts'], {
|
||||||
|
cwd: adminRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'inherit'],
|
||||||
|
});
|
||||||
|
if (dump.status !== 0) {
|
||||||
|
console.error(`dump-spec.ts failed with exit code ${dump.status}`);
|
||||||
|
process.exit(dump.status ?? 1);
|
||||||
|
}
|
||||||
|
writeFileSync(specPath, dump.stdout, 'utf8');
|
||||||
|
|
||||||
|
const gen = spawnSync(
|
||||||
|
'pnpm',
|
||||||
|
['exec', 'openapi-typescript', specPath, '-o', outFile],
|
||||||
|
{ cwd: adminRoot, stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
if (gen.status !== 0) {
|
||||||
|
console.error(`openapi-typescript failed with exit code ${gen.status}`);
|
||||||
|
process.exit(gen.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
`// GENERATED — do not edit. Run \`pnpm --filter admin gen:api\` to regenerate.\n` +
|
||||||
|
`// Source: src/node/hooks/express/openapi.ts (#7638)\n\n`;
|
||||||
|
const body = readFileSync(outFile, 'utf8');
|
||||||
|
writeFileSync(outFile, header + body, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Wrote ${path.relative(process.cwd(), outFile)}`);
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `gen:api` script and amend `build`**
|
||||||
|
|
||||||
|
In `admin/package.json`, edit the `scripts` block. Before:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
|
||||||
|
"preview": "vite preview"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"gen:api": "node scripts/gen-api.mjs",
|
||||||
|
"build": "pnpm gen:api && tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
|
||||||
|
"preview": "vite preview"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run codegen and confirm output**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
mkdir -p admin/src/api
|
||||||
|
pnpm --filter admin gen:api
|
||||||
|
ls -la admin/src/api/schema.d.ts
|
||||||
|
head -10 admin/src/api/schema.d.ts
|
||||||
|
```
|
||||||
|
Expected:
|
||||||
|
- exit 0
|
||||||
|
- `schema.d.ts` exists, > 1 KB
|
||||||
|
- first two lines are the generated header
|
||||||
|
- subsequent lines contain `export interface paths` and entries like `"/api/{version}/createGroup"`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit script + package.json + generated schema**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/scripts/gen-api.mjs admin/package.json admin/src/api/schema.d.ts
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
chore(admin): wire OpenAPI codegen into build (#7638)
|
||||||
|
|
||||||
|
Adds `gen:api` script and amends `build`/`build-copy` to regenerate
|
||||||
|
admin/src/api/schema.d.ts before compiling. The generated file is
|
||||||
|
checked in so it shows up in PR review and so a fresh checkout doesn't
|
||||||
|
need codegen to typecheck.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Runtime client module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `admin/src/api/client.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the file**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// admin/src/api/client.ts
|
||||||
|
//
|
||||||
|
// Typed HTTP client and TanStack Query hooks derived from the generated
|
||||||
|
// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`.
|
||||||
|
|
||||||
|
import createClient from 'openapi-fetch';
|
||||||
|
import createQueryHooks from 'openapi-react-query';
|
||||||
|
import type { paths } from './schema';
|
||||||
|
|
||||||
|
export const fetchClient = createClient<paths>({ baseUrl: '/' });
|
||||||
|
export const $api = createQueryHooks(fetchClient);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm typecheck passes**
|
||||||
|
|
||||||
|
Run: `pnpm --filter admin exec tsc --noEmit`
|
||||||
|
Expected: no errors. If `paths` is missing from `schema.d.ts`, rerun `pnpm --filter admin gen:api` (it should have produced an `export interface paths` already in Task 4).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/src/api/client.ts
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(admin): typed openapi-fetch + react-query client (#7638)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Query provider with dev-only devtools
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `admin/src/api/QueryProvider.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the file**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// admin/src/api/QueryProvider.tsx
|
||||||
|
//
|
||||||
|
// TanStack Query provider for the admin UI. Devtools are loaded lazily and
|
||||||
|
// only in dev builds so they don't ship to production.
|
||||||
|
|
||||||
|
import { lazy, Suspense, useState, type ReactNode } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const Devtools = import.meta.env.DEV
|
||||||
|
? lazy(() =>
|
||||||
|
import('@tanstack/react-query-devtools').then((m) => ({
|
||||||
|
default: m.ReactQueryDevtools,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export const QueryProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [client] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
{children}
|
||||||
|
{Devtools && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Devtools initialIsOpen={false} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter admin exec tsc --noEmit`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/src/api/QueryProvider.tsx
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(admin): TanStack Query provider, dev-only devtools (#7638)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Mount the provider at the admin root
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `admin/src/main.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the file to confirm current shape**
|
||||||
|
|
||||||
|
Run: `cat admin/src/main.tsx`
|
||||||
|
Expected: matches the structure where `<I18nextProvider>` wraps `<Toast.Provider>` wraps `<RouterProvider>` inside `<React.StrictMode>`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Edit `admin/src/main.tsx`**
|
||||||
|
|
||||||
|
Add the import after the existing imports:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryProvider } from './api/QueryProvider.tsx';
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap the existing `<I18nextProvider>...</I18nextProvider>` subtree in `<QueryProvider>`. The render block becomes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryProvider>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<Toast.Provider>
|
||||||
|
<ToastDialog/>
|
||||||
|
<RouterProvider router={router}/>
|
||||||
|
</Toast.Provider>
|
||||||
|
</I18nextProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(Provider order matters only for context lookups; placing `QueryProvider` outside `I18nextProvider` is fine because it does not consume i18n.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter admin exec tsc --noEmit`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the admin bundle**
|
||||||
|
|
||||||
|
Run: `pnpm --filter admin run build`
|
||||||
|
Expected: build succeeds. Output indicates one bundle (no extra chunk for devtools in production — confirm by grepping the `dist/` for `query-devtools` strings; should be absent).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "ReactQueryDevtools" admin/dist/ 2>/dev/null | head
|
||||||
|
```
|
||||||
|
Expected: no matches (production bundle excludes devtools).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/src/main.tsx
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(admin): mount TanStack Query provider at root (#7638)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Smoke test for the client module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `admin/src/api/__tests__/client.test.ts`
|
||||||
|
|
||||||
|
The admin package does not yet ship a unit test runner. Reuse whatever the rest of admin uses for tests if anything; otherwise, this test runs under `tsx --test` (Node's built-in test runner, no extra deps). Confirm at Step 1.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Detect the test runner**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -E '"(test|vitest|jest)"' admin/package.json
|
||||||
|
ls admin/vitest.config.* admin/jest.config.* 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
If admin has no runner configured, use Node's built-in `node:test` (which `tsx` supports).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the test file**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// admin/src/api/__tests__/client.test.ts
|
||||||
|
//
|
||||||
|
// Smoke test that the OpenAPI client module loads and exposes the expected
|
||||||
|
// surface. Catches toolchain wiring regressions (missing peer deps,
|
||||||
|
// generator output that doesn't export `paths`, etc.).
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
test('client module exports fetchClient and $api', async () => {
|
||||||
|
const mod = await import('../client.ts');
|
||||||
|
assert.ok(mod.fetchClient, 'fetchClient export is present');
|
||||||
|
assert.ok(mod.$api, '$api export is present');
|
||||||
|
assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function');
|
||||||
|
assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add a `test` script to `admin/package.json`** (only if one does not already exist)
|
||||||
|
|
||||||
|
If `admin/package.json` has no `"test"` script, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"test": "tsx --test src/api/__tests__/client.test.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
If admin already has a test runner (e.g. `vitest`), skip the script addition and instead place the test at the location the existing runner picks up (`*.test.ts` is conventional for both vitest and node:test).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test**
|
||||||
|
|
||||||
|
Run: `pnpm --filter admin test`
|
||||||
|
Expected: 1 test passing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/src/api/__tests__/client.test.ts admin/package.json
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
test(admin): smoke test for typed openapi-fetch client (#7638)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: CI freshness check
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/frontend-admin-tests.yml`
|
||||||
|
|
||||||
|
Add a step before the existing `Build admin frontend` step that runs codegen and fails if the working tree changed.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current workflow**
|
||||||
|
|
||||||
|
Run: `grep -n "Build admin frontend" .github/workflows/frontend-admin-tests.yml`
|
||||||
|
Expected: a single match around the build step that runs `pnpm run build` from `working-directory: admin`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insert the freshness check**
|
||||||
|
|
||||||
|
Insert immediately before the `Build admin frontend` step:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Verify admin OpenAPI schema is up to date
|
||||||
|
working-directory: admin
|
||||||
|
run: |
|
||||||
|
pnpm gen:api
|
||||||
|
if ! git diff --exit-code src/api/schema.d.ts; then
|
||||||
|
echo ""
|
||||||
|
echo "::error::admin/src/api/schema.d.ts is out of date."
|
||||||
|
echo "Run \`pnpm --filter admin gen:api\` and commit the result."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lint the YAML**
|
||||||
|
|
||||||
|
Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/frontend-admin-tests.yml'))" && echo OK`
|
||||||
|
Expected: `OK`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/frontend-admin-tests.yml
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
ci(admin): verify generated OpenAPI schema is up to date (#7638)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `admin/README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the file**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Admin UI
|
||||||
|
|
||||||
|
Vite + React 19 single-page app served at `/admin`. Talks to the backend over
|
||||||
|
socket.io for the existing settings / plugins / pads pages, and (when
|
||||||
|
endpoints are added to the OpenAPI spec) over a typed REST client.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
| -------------------- | -------------------------------------------------------- |
|
||||||
|
| `pnpm dev` | Vite dev server. Expects an etherpad backend on :9001. |
|
||||||
|
| `pnpm gen:api` | Regenerates `src/api/schema.d.ts` from the OpenAPI spec. |
|
||||||
|
| `pnpm build` | `gen:api` + `tsc` + `vite build`. |
|
||||||
|
| `pnpm build-copy` | Same, but writes into `../src/templates/admin`. |
|
||||||
|
| `pnpm test` | Smoke tests for the API client wiring. |
|
||||||
|
| `pnpm lint` | ESLint. |
|
||||||
|
|
||||||
|
## Typed API client
|
||||||
|
|
||||||
|
The admin uses [`openapi-typescript`] to generate types from
|
||||||
|
`src/node/hooks/express/openapi.ts`, [`openapi-fetch`] for typed requests, and
|
||||||
|
[`openapi-react-query`] for TanStack Query bindings.
|
||||||
|
|
||||||
|
[`openapi-typescript`]: https://github.com/openapi-ts/openapi-typescript
|
||||||
|
[`openapi-fetch`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch
|
||||||
|
[`openapi-react-query`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-react-query
|
||||||
|
|
||||||
|
### Regenerating the schema
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter admin gen:api
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `admin/scripts/gen-api.mjs`, which loads
|
||||||
|
`src/node/hooks/express/openapi.ts`, calls `generateDefinitionForVersion` for
|
||||||
|
the latest API version, pipes the JSON through `openapi-typescript`, and
|
||||||
|
writes the result to `admin/src/api/schema.d.ts`. The generated file is
|
||||||
|
checked in.
|
||||||
|
|
||||||
|
Run `gen:api` after any change to:
|
||||||
|
|
||||||
|
- `src/node/hooks/express/openapi.ts`
|
||||||
|
- `src/node/handler/APIHandler.ts` (changes to `latestApiVersion`)
|
||||||
|
- the resource definitions referenced by `openapi.ts`
|
||||||
|
|
||||||
|
### CI freshness check
|
||||||
|
|
||||||
|
`.github/workflows/frontend-admin-tests.yml` runs `pnpm gen:api` and fails the
|
||||||
|
build if `admin/src/api/schema.d.ts` is out of date. If you see the failure
|
||||||
|
locally, run `pnpm --filter admin gen:api` and commit the regenerated file.
|
||||||
|
|
||||||
|
### Using the client
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { $api } from './api/client';
|
||||||
|
|
||||||
|
const SettingsPanel = () => {
|
||||||
|
const { data } = $api.useQuery('get', '/admin/settings'); // example
|
||||||
|
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin endpoints are not yet present in the OpenAPI spec — this client is
|
||||||
|
in place to support upcoming work (see issue #7638 follow-up). For now, it is
|
||||||
|
exercised only by the smoke test.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add admin/README.md
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
docs(admin): document OpenAPI codegen workflow (#7638)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Full verification pass
|
||||||
|
|
||||||
|
No new files — this task confirms the work is green end-to-end before pushing.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Clean rebuild**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
pnpm --filter admin gen:api
|
||||||
|
pnpm --filter admin run build
|
||||||
|
```
|
||||||
|
Expected: both succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Repo-wide typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm ts-check`
|
||||||
|
Expected: no new errors versus baseline. If there are pre-existing errors, confirm none are in files this PR touched.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Admin tests**
|
||||||
|
|
||||||
|
Run: `pnpm --filter admin test`
|
||||||
|
Expected: 1 test passing.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Backend unit tests** (sanity — `openapi.ts` change)
|
||||||
|
|
||||||
|
Run: `pnpm test` (or the narrowest available suite covering the API hook; if the full suite is slow, run specs that exercise `openapi.ts` only).
|
||||||
|
Expected: green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Confirm devtools absent from production bundle**
|
||||||
|
|
||||||
|
Run: `grep -rn "ReactQueryDevtools" admin/dist/ 2>/dev/null`
|
||||||
|
Expected: zero matches.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual smoke**
|
||||||
|
|
||||||
|
Per project convention (memory: install plugin/branch for manual test), install this branch on a local etherpad and:
|
||||||
|
- Open `/admin/` in a dev build (`pnpm --filter admin dev`). Confirm the React Query devtools panel button appears in the bottom corner.
|
||||||
|
- Open `/admin/` in the production-built bundle. Confirm devtools panel is absent.
|
||||||
|
- Click through plugins / settings / pads / shout pages and confirm no regression versus pre-PR behavior (existing socket.io flows unchanged).
|
||||||
|
|
||||||
|
Document the smoke results in the PR description.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u fork chore/admin-typesafe-api-7638
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Open PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create \
|
||||||
|
--repo johnmclear/etherpad-lite \
|
||||||
|
--title "chore(admin): typesafe API client + TanStack Query rails (#7638)" \
|
||||||
|
--body "$(cat <<'EOF'
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Lays down the rails for a typesafe, OpenAPI-derived admin API client backed by TanStack Query. Closes #7638.
|
||||||
|
|
||||||
|
- Codegen toolchain (`pnpm --filter admin gen:api`) producing `admin/src/api/schema.d.ts` from `src/node/hooks/express/openapi.ts`.
|
||||||
|
- Runtime client (`openapi-fetch` + `openapi-react-query`).
|
||||||
|
- `<QueryProvider>` mounted at the admin root with dev-only devtools.
|
||||||
|
- CI freshness check on the generated schema.
|
||||||
|
- `admin/README.md` documenting the workflow.
|
||||||
|
|
||||||
|
**No call sites migrated.** Admin endpoints aren't in the OpenAPI spec yet — that gap is filed as a follow-up issue and must land before any migration is useful. #7601 should rebase onto this branch.
|
||||||
|
|
||||||
|
**Semver:** patch — build tooling + currently-unused runtime libs, no observable behavior change.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [x] `pnpm --filter admin gen:api` runs clean
|
||||||
|
- [x] `pnpm --filter admin run build` succeeds
|
||||||
|
- [x] `pnpm --filter admin test` passes (smoke test)
|
||||||
|
- [x] `pnpm ts-check` clean
|
||||||
|
- [x] Production bundle does not contain devtools
|
||||||
|
- [x] Manual smoke: dev build shows devtools, prod build hides them, existing socket.io pages unaffected
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Trigger Qodo review** (per project convention)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr comment <PR-number> --repo johnmclear/etherpad-lite --body "/review"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: File the spec-coverage follow-up issue**
|
||||||
|
|
||||||
|
Create a new issue on `ether/etherpad` titled "Document admin endpoints in the OpenAPI spec" and link from the PR body. The issue should note that 7638 rails are unused until admin endpoints are added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk register (carried from spec)
|
||||||
|
|
||||||
|
- **`openapi.ts` not cleanly importable.** If `dump-spec.ts` fails to import the module due to side effects (Settings, log4js init), pause and ask before refactoring `Settings`. A common workaround is to set `EP_LOG_DESTINATION=stderr` or set `NODE_ENV=production`. Do not silently expand scope.
|
||||||
|
- **Generated schema differs by Node version.** `openapi-typescript` output is deterministic, but if a contributor sees a phantom diff, confirm Node major matches the CI matrix (22/24/25 today; CI uses 24 on PRs).
|
||||||
|
- **Bundle size.** ~12 KB gzipped added to the admin bundle even with no call sites. Acceptable; flagged in the PR body for transparency.
|
||||||
|
|
||||||
|
## Out of scope (do not pull in)
|
||||||
|
|
||||||
|
- Adding admin endpoints to the OpenAPI spec.
|
||||||
|
- Migrating any `fetch()` site in `admin/src/`.
|
||||||
|
- Backend handler changes.
|
||||||
|
- Pad-side frontend changes.
|
||||||
1627
docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md
Normal file
1627
docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md
Normal file
File diff suppressed because it is too large
Load diff
3222
docs/superpowers/plans/2026-05-08-auto-update-pr2-manual-click.md
Normal file
3222
docs/superpowers/plans/2026-05-08-auto-update-pr2-manual-click.md
Normal file
File diff suppressed because it is too large
Load diff
1058
docs/superpowers/plans/2026-05-08-issue-7693-admin-openapi.md
Normal file
1058
docs/superpowers/plans/2026-05-08-issue-7693-admin-openapi.md
Normal file
File diff suppressed because it is too large
Load diff
1543
docs/superpowers/plans/2026-05-08-native-docx-pdf-export-import.md
Normal file
1543
docs/superpowers/plans/2026-05-08-native-docx-pdf-export-import.md
Normal file
File diff suppressed because it is too large
Load diff
1353
docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md
Normal file
1353
docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md
Normal file
File diff suppressed because it is too large
Load diff
1742
docs/superpowers/plans/2026-05-11-auto-update-pr3-tier3-auto.md
Normal file
1742
docs/superpowers/plans/2026-05-11-auto-update-pr3-tier3-auto.md
Normal file
File diff suppressed because it is too large
Load diff
1131
docs/superpowers/plans/2026-05-15-issue-7524-swagger-ui-telemetry.md
Normal file
1131
docs/superpowers/plans/2026-05-15-issue-7524-swagger-ui-telemetry.md
Normal file
File diff suppressed because it is too large
Load diff
246
docs/superpowers/specs/2026-04-25-auto-update-runbook.md
Normal file
246
docs/superpowers/specs/2026-04-25-auto-update-runbook.md
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
# Etherpad Auto-Update — Manual Smoke Runbook
|
||||||
|
|
||||||
|
**Status:** required gate before each tier ships, per `2026-04-25-auto-update-design.md` § "Phased rollout".
|
||||||
|
**Audience:** the engineer cutting a release that includes new updater code.
|
||||||
|
**Time budget:** ~30–40 minutes for the full sweep against a disposable VM.
|
||||||
|
|
||||||
|
This runbook exercises the failure paths that unit and integration tests cannot reach: a real process supervisor, a real `pnpm install` run, real session drain broadcasts to a real pad client. Run it on a throw-away VM you don't mind nuking.
|
||||||
|
|
||||||
|
## 0. Provision a disposable VM
|
||||||
|
|
||||||
|
Anything Linux works; the example below uses Debian/Ubuntu under systemd.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the VM
|
||||||
|
sudo adduser --system --group --home /srv/etherpad --shell /bin/bash etherpad
|
||||||
|
sudo apt update && sudo apt install -y git nodejs ca-certificates
|
||||||
|
# Etherpad's pnpm comes from corepack — Node 22+ ships it.
|
||||||
|
sudo -u etherpad bash -c '
|
||||||
|
cd /srv/etherpad
|
||||||
|
git clone https://github.com/ether/etherpad.git current
|
||||||
|
cd current
|
||||||
|
corepack enable && corepack prepare pnpm@latest-9 --activate
|
||||||
|
pnpm install
|
||||||
|
pnpm run build:ui
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Install Etherpad as a systemd service
|
||||||
|
|
||||||
|
`/etc/systemd/system/etherpad.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Etherpad
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=etherpad
|
||||||
|
WorkingDirectory=/srv/etherpad/current
|
||||||
|
ExecStart=/usr/bin/pnpm run dev
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
SuccessExitStatus=75
|
||||||
|
# Treat exit 75 as "intentional" so systemd doesn't escalate-restart counters.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now etherpad
|
||||||
|
journalctl -u etherpad -f & # tail the log in another terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Configure for Tier 2
|
||||||
|
|
||||||
|
Edit `/srv/etherpad/current/settings.json` and set:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"updates": {
|
||||||
|
"tier": "manual",
|
||||||
|
"checkIntervalHours": 1,
|
||||||
|
"drainSeconds": 30, // shorten the wait during smoke testing
|
||||||
|
"rollbackHealthCheckSeconds": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sudo systemctl restart etherpad`. Visit `http://<vm-ip>:9001/admin/update` and log in as the admin user from `settings.json`.
|
||||||
|
|
||||||
|
## 3. Force "an update is available"
|
||||||
|
|
||||||
|
The simplest way: `git checkout` to a commit *before* a tagged release.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u etherpad bash -c 'cd /srv/etherpad/current && git checkout v2.7.2'
|
||||||
|
sudo systemctl restart etherpad
|
||||||
|
```
|
||||||
|
|
||||||
|
Trigger an immediate version check (or wait an hour):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL http://localhost:9001/admin/update/status | jq .
|
||||||
|
# Expect: latest.version newer than currentVersion, policy.canManual=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin UI banner should now read **"Update available"**, and `/admin/update` should show an **"Apply update"** button.
|
||||||
|
|
||||||
|
## 4. Happy path: apply, drain, restart, verify
|
||||||
|
|
||||||
|
1. Open a pad in another browser tab (`http://<vm-ip>:9001/p/test`).
|
||||||
|
2. Click **Apply update** on `/admin/update`.
|
||||||
|
3. **Within 30 seconds** confirm:
|
||||||
|
- The pad shows a gritter notification "Etherpad will restart in 30 seconds…" (i18n string from `update.drain.t30`), then `update.drain.t10`.
|
||||||
|
- The page polls `/admin/update/log`; the `<pre>` block fills with `git fetch / checkout / pnpm install / pnpm run build:ui` output.
|
||||||
|
4. systemd journal shows `update executed: <fromSha> -> <tag>; exiting 75 for supervisor restart`.
|
||||||
|
5. systemd restarts the unit (~5s under `RestartSec`).
|
||||||
|
6. Reload `/admin/update`. State should be **`verified`** with `lastResult.outcome: "verified"`.
|
||||||
|
|
||||||
|
**Sign-off:** every observable transition matches the state machine in the design spec § "State machine". If any step lingers or the page shows a different status, capture `var/log/update.log` and stop.
|
||||||
|
|
||||||
|
## 5. Rollback path: install failure
|
||||||
|
|
||||||
|
Force a rollback by giving pnpm something it can't resolve.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# As etherpad user, in /srv/etherpad/current:
|
||||||
|
git checkout v2.7.2
|
||||||
|
echo 'lockfileVersion: this-is-not-real-content' >> pnpm-lock.yaml
|
||||||
|
sudo systemctl restart etherpad
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `/admin/update` and click Apply.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- Drain announcement on the pad as before.
|
||||||
|
- Log shows `pnpm install --frozen-lockfile` exiting non-zero.
|
||||||
|
- State goes through `rolling-back` → `rolled-back`.
|
||||||
|
- After supervisor restart, `/admin/update` shows the **rolled-back** banner with `lastResult.reason` describing the install failure.
|
||||||
|
- `git rev-parse HEAD` matches the pre-update SHA.
|
||||||
|
- Click **Acknowledge** to clear the lastResult banner.
|
||||||
|
|
||||||
|
## 6. Rollback path: build failure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout v2.7.2
|
||||||
|
# Break the build by introducing a syntax error:
|
||||||
|
echo 'this is not valid TypeScript' >> src/static/js/pad.ts
|
||||||
|
sudo systemctl restart etherpad # confirm the broken tree still serves; we want apply to fail at build:ui, not at boot
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply, observe `pnpm run build:ui` exit non-zero in the log, observe `rolling-back` → `rolled-back`. Working tree restored.
|
||||||
|
|
||||||
|
Revert the syntax error before continuing.
|
||||||
|
|
||||||
|
## 7. Crash-loop guard
|
||||||
|
|
||||||
|
Force the new version to crash at boot more than twice. Easiest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# As etherpad user:
|
||||||
|
git checkout v2.7.2
|
||||||
|
# Apply to v2.7.3, but during the apply window introduce a startup error:
|
||||||
|
# (Edit src/node/server.ts in the v2.7.3 tag's worktree to throw immediately.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Click Apply. The new boot crashes; systemd restarts; RollbackHandler increments `bootCount`. After three crashes, `bootCount > 2` triggers a forced rollback regardless of the health-check timer.
|
||||||
|
|
||||||
|
Observe state lands on `rolled-back` with `reason: "health-check-failed-or-crash-loop"`. Working tree on the original SHA.
|
||||||
|
|
||||||
|
## 8. Rollback-failed terminal state
|
||||||
|
|
||||||
|
Hardest to set up; force `pnpm install` to fail on the rollback path too.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trigger a normal install-failed rollback (step 5), but BEFORE it runs the
|
||||||
|
# rollback step, corrupt the backup lockfile:
|
||||||
|
echo garbage > /srv/etherpad/current/var/update-backup/pnpm-lock.yaml
|
||||||
|
# … or remove the etherpad user's permission to the install dir mid-flow.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- State lands on **`rollback-failed`**.
|
||||||
|
- `/admin/update` shows the strong red banner (role=alert) with the
|
||||||
|
`update.banner.terminal.rollback-failed` copy.
|
||||||
|
- `policy.canManual` stays true; `policy.canAuto` is false (terminal-blocked).
|
||||||
|
- Manually fix the install (restore the lockfile, fix permissions), then
|
||||||
|
click **Acknowledge**. State returns to `idle` and Apply re-enables.
|
||||||
|
|
||||||
|
## 9. Cancel during drain
|
||||||
|
|
||||||
|
Click Apply. Within 30s, click Cancel.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- Drain timers stop firing immediately.
|
||||||
|
- State returns to `idle`.
|
||||||
|
- `lastResult.outcome: "cancelled"`.
|
||||||
|
- `var/update.lock` is gone.
|
||||||
|
- No exit; systemd doesn't restart.
|
||||||
|
|
||||||
|
## 10. Sign-off checklist
|
||||||
|
|
||||||
|
Tick every line before approving the release that introduces this code:
|
||||||
|
|
||||||
|
- [ ] Happy path lands on `verified` with the working tree on the new tag.
|
||||||
|
- [ ] Install-fail and build-fail rollbacks restore the previous SHA.
|
||||||
|
- [ ] Crash-loop guard forces rollback at `bootCount > 2`.
|
||||||
|
- [ ] `rollback-failed` shows the strong banner and Acknowledge clears it.
|
||||||
|
- [ ] Cancel during drain leaves no lock, returns to `idle`.
|
||||||
|
- [ ] Pad client renders the localised drain announcement (NOT the literal i18n key).
|
||||||
|
- [ ] systemd journal shows no unhandled rejections, no orphaned processes.
|
||||||
|
- [ ] `var/log/update.log` is rotated when it crosses 10 MB (force this by writing >10 MB into the file and triggering an Apply).
|
||||||
|
|
||||||
|
If any line is unticked, do not ship the release.
|
||||||
|
|
||||||
|
## 11. Tier 3 — grace window, scheduled apply, cancel, restart-in-grace
|
||||||
|
|
||||||
|
Configure the VM for tier 3:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"updates": {
|
||||||
|
"tier": "auto",
|
||||||
|
"preApplyGraceMinutes": 2, // short for smoke
|
||||||
|
"drainSeconds": 15,
|
||||||
|
"checkIntervalHours": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. As in §3, `git checkout v2.7.2`. Restart Etherpad. Wait for the immediate first version check (~5s after boot).
|
||||||
|
2. Confirm a schedule was created:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL http://localhost:9001/admin/update/status | jq '.execution'
|
||||||
|
# Expect: {"status":"scheduled","targetTag":"v...","scheduledFor":"...","startedAt":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Visit `/admin/update`. Confirm:
|
||||||
|
- A countdown panel renders with the localised "Etherpad will start updating to vX.Y.Z in Nm Ms." copy.
|
||||||
|
- Two buttons: **Cancel** and **Apply now**.
|
||||||
|
4. **Happy path:** wait for the timer to fire. The same flow as §4 (drain, executor, exit 75) runs. State lands on `verified` after the supervisor restarts on the new version.
|
||||||
|
5. **Cancel path:** repeat steps 1–3 in a fresh setup. Click **Cancel** during the countdown. Expected:
|
||||||
|
- State transitions to `idle`; `lastResult.outcome: "cancelled"`.
|
||||||
|
- `journalctl -u etherpad` shows `cancelled pending schedule (admin-cancellation)`.
|
||||||
|
- The next version check (within `checkIntervalHours`) re-schedules the same release — this is correct: the schedule was cancelled but the policy still wants the update. To opt out completely, set `updates.tier: "notify"` instead.
|
||||||
|
6. **Apply-now path:** repeat steps 1–3. Click **Apply now**. The regular Tier 2 pipeline starts immediately; the previously-armed timer is harmlessly stale (the executor takes over before it fires).
|
||||||
|
7. **Restart-in-grace path:** repeat steps 1–3, then `sudo systemctl restart etherpad` mid-countdown. On boot, the journal logs `updater: rehydrating Tier 3 schedule for vX.Y.Z at ...`. `/admin/update` resumes the countdown from the persisted `scheduledFor` (no re-arming, no re-emailing).
|
||||||
|
8. **Email path** (if `adminEmail` is set): the journal logs `(would send email) ... [Etherpad] Auto-update scheduled for ...` exactly once per scheduled tag. Re-arming for the same tag does not re-email. A different tag arming over the top does.
|
||||||
|
|
||||||
|
If any step diverges, capture `var/log/update.log` and stop. Add to the §10 sign-off checklist:
|
||||||
|
|
||||||
|
- [ ] Tier 3 schedule transitions execution → `scheduled` after the version check.
|
||||||
|
- [ ] Countdown panel renders the localised string (not the i18n key).
|
||||||
|
- [ ] Cancel during scheduled returns to `idle`.
|
||||||
|
- [ ] Apply now during scheduled runs the Tier 2 pipeline immediately.
|
||||||
|
- [ ] Restart-in-grace rehydrates the timer.
|
||||||
|
- [ ] `grace-start` email fires once per tag when `adminEmail` is set.
|
||||||
|
|
@ -144,3 +144,38 @@ The XSS escape test is the security-relevant one: pad IDs are user-controlled
|
||||||
- A `padSocialMetadata` hook that lets plugins override the values.
|
- A `padSocialMetadata` hook that lets plugins override the values.
|
||||||
- Per-pad description (e.g. ep_pad_title integration).
|
- Per-pad description (e.g. ep_pad_title integration).
|
||||||
- Generated preview images (would require a rendering service).
|
- Generated preview images (would require a rendering service).
|
||||||
|
|
||||||
|
## Follow-up (2026-05-07): operator description override
|
||||||
|
|
||||||
|
Issue #7599 follow-up comment from @stffen flagged two gaps in the shipped
|
||||||
|
behaviour:
|
||||||
|
|
||||||
|
1. The default description is in English and there is no obvious place in
|
||||||
|
`settings.json` to change it.
|
||||||
|
2. The visitor's language is negotiated from `Accept-Language`, which most
|
||||||
|
link-preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) do not
|
||||||
|
send — so non-English instances always serve the English fallback to
|
||||||
|
crawlers regardless of which locale files exist.
|
||||||
|
|
||||||
|
Resolution: keep the i18n catalog as the default source (the original Qodo
|
||||||
|
review still stands — translatable strings belong in locale files), but add
|
||||||
|
an explicit `settings.socialMeta.description` override that wins when set:
|
||||||
|
|
||||||
|
- `socialMeta.description: null` (default) → existing behaviour: i18n
|
||||||
|
catalog with `Accept-Language` negotiation, English fallback.
|
||||||
|
- `socialMeta.description: "<text>"` → that string is used verbatim for
|
||||||
|
`og:description` / `twitter:description` regardless of the negotiated
|
||||||
|
language. This is the lever that fixes the crawler-no-Accept-Language
|
||||||
|
case.
|
||||||
|
- Empty / whitespace-only override is treated as unset (would otherwise
|
||||||
|
blank out previews silently — a footgun).
|
||||||
|
- The override is HTML-escaped via the same path as every other
|
||||||
|
interpolated value.
|
||||||
|
- `og:locale` is unaffected; it continues to reflect the negotiated render
|
||||||
|
language. Operators who want fully localised descriptions still use
|
||||||
|
`customLocaleStrings` to override `pad.social.description` per-language.
|
||||||
|
|
||||||
|
Documentation lives next to `publicURL` in both `settings.json.template`
|
||||||
|
and `settings.json.docker` (mirrors how the original feature is
|
||||||
|
configured), and the `customLocaleStrings` example now shows the
|
||||||
|
`pad.social.description` key explicitly so operators can find both routes.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
# Issue 7638 — Typesafe Admin API Client + TanStack Query Rails
|
||||||
|
|
||||||
|
**Status:** design approved 2026-05-01
|
||||||
|
**Issue:** https://github.com/ether/etherpad/issues/7638
|
||||||
|
**Related:** #7601 (introduces new admin REST sites that will adopt these rails)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Lay down the toolchain and runtime rails for a typesafe, OpenAPI-derived admin
|
||||||
|
API client backed by TanStack Query. Do not migrate any existing call sites.
|
||||||
|
|
||||||
|
## Why rails-only
|
||||||
|
|
||||||
|
The issue's framing ("migrate every `useEffect`+`fetch` site") overstates what is
|
||||||
|
actually present in `admin/src/` today.
|
||||||
|
|
||||||
|
- The only REST `fetch()` sites are `App.tsx` and `LoginScreen.tsx` (both POST to
|
||||||
|
`/admin-auth/`) and `i18n.ts` (locale loading).
|
||||||
|
- All admin pages with real data flow (Settings, Plugins, Pads, Shout) run over
|
||||||
|
socket.io + zustand, not REST.
|
||||||
|
- The OpenAPI spec produced by `src/node/hooks/express/openapi.ts` only covers
|
||||||
|
the public Etherpad HTTP API under `/api/{version}/*`. It documents zero admin
|
||||||
|
endpoints — no `/admin-auth/`, no future `/admin/*` REST endpoints from #7601.
|
||||||
|
|
||||||
|
So the generated client has nothing in `admin/src/` to type today. The value of
|
||||||
|
landing this PR now is to get the rails in place so #7601 (and any subsequent
|
||||||
|
admin REST work) can adopt them on day one.
|
||||||
|
|
||||||
|
A separate issue will be filed to add admin endpoint coverage to the OpenAPI
|
||||||
|
spec; until that lands, no migrations are useful.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Admin endpoint coverage in the OpenAPI spec (separate issue).
|
||||||
|
- Migrating any existing `fetch()` call site.
|
||||||
|
- Backend changes.
|
||||||
|
- Pad-side frontend.
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
| Package | Type | Purpose |
|
||||||
|
| -------------------------------- | -------------- | ---------------------------------------- |
|
||||||
|
| `openapi-typescript` | devDependency | Generates `.d.ts` from the OpenAPI spec |
|
||||||
|
| `openapi-fetch` | dependency | Typed `fetch` wrapper |
|
||||||
|
| `openapi-react-query` | dependency | TanStack Query bindings over the client |
|
||||||
|
| `@tanstack/react-query` | dependency | Query runtime |
|
||||||
|
| `@tanstack/react-query-devtools` | dependency | Dev-only devtools panel |
|
||||||
|
|
||||||
|
All added to `admin/package.json`. No version pinning beyond standard caret
|
||||||
|
ranges; pick the latest stable at implementation time.
|
||||||
|
|
||||||
|
## Codegen (option 3, hybrid)
|
||||||
|
|
||||||
|
One checked-in artifact, CI-enforced freshness.
|
||||||
|
|
||||||
|
### Script: `admin/scripts/gen-api.mjs`
|
||||||
|
|
||||||
|
1. Imports the spec-building entry point from
|
||||||
|
`src/node/hooks/express/openapi.ts` (or a thin wrapper module that calls
|
||||||
|
the spec builder without booting Express). Writes the resulting spec JSON
|
||||||
|
to a temp file in `os.tmpdir()`.
|
||||||
|
2. Shells out:
|
||||||
|
`openapi-typescript <tmp> -o admin/src/api/schema.d.ts`.
|
||||||
|
3. Prepends a generated header comment to the output:
|
||||||
|
`// GENERATED — do not edit. Run \`pnpm gen:api\` to regenerate.`
|
||||||
|
4. Removes the temp file.
|
||||||
|
|
||||||
|
If `openapi.ts` cannot be loaded as an ES module without side effects (e.g.
|
||||||
|
because it imports settings or boots an Express app at import time), the
|
||||||
|
implementation must extract the pure spec-builder into a dedicated module so
|
||||||
|
the script can call it cleanly. That refactor is in scope; the touch should be
|
||||||
|
minimal.
|
||||||
|
|
||||||
|
### Wiring
|
||||||
|
|
||||||
|
- `admin/package.json`:
|
||||||
|
- `"scripts": { "gen:api": "node scripts/gen-api.mjs", ... }`.
|
||||||
|
- `"build"` is amended to run `gen:api` before `tsc && vite build` so a
|
||||||
|
fresh checkout builds without manual steps.
|
||||||
|
- Root `package.json`: existing admin build entry point invokes the same
|
||||||
|
script (or relies on `admin/package.json`'s amended `build`).
|
||||||
|
|
||||||
|
### Generated output
|
||||||
|
|
||||||
|
- Path: `admin/src/api/schema.d.ts`.
|
||||||
|
- Checked in.
|
||||||
|
- First line: generated-header comment.
|
||||||
|
|
||||||
|
### CI freshness check
|
||||||
|
|
||||||
|
A CI job (folded into the existing admin lint workflow if practical, otherwise
|
||||||
|
a new step) runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter admin gen:api
|
||||||
|
git diff --exit-code admin/src/api/schema.d.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
If the diff is non-empty, CI fails with a message instructing the contributor
|
||||||
|
to run `pnpm --filter admin gen:api` and commit the result.
|
||||||
|
|
||||||
|
## Runtime client
|
||||||
|
|
||||||
|
### `admin/src/api/client.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import createClient from "openapi-fetch";
|
||||||
|
import createQueryHooks from "openapi-react-query";
|
||||||
|
import type { paths } from "./schema";
|
||||||
|
|
||||||
|
export const fetchClient = createClient<paths>({ baseUrl: "/" });
|
||||||
|
export const $api = createQueryHooks(fetchClient);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `admin/src/api/QueryProvider.tsx`
|
||||||
|
|
||||||
|
- Wraps children in `QueryClientProvider`.
|
||||||
|
- Single shared `QueryClient` constructed once (module-level or `useState`
|
||||||
|
initializer), with defaults:
|
||||||
|
- `staleTime: 30_000`
|
||||||
|
- `refetchOnWindowFocus: true`
|
||||||
|
- Other defaults left at library defaults.
|
||||||
|
- Mounts `ReactQueryDevtools` only when `import.meta.env.DEV` is true. Use a
|
||||||
|
dynamic `import()` so devtools do not ship in the production bundle.
|
||||||
|
|
||||||
|
### `admin/src/main.tsx`
|
||||||
|
|
||||||
|
Wrap `<App />` in `<QueryProvider>`. No other changes.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
`admin/README.md` (create or extend) documents:
|
||||||
|
|
||||||
|
- How to regenerate: `pnpm --filter admin gen:api`.
|
||||||
|
- When to regenerate: after any change to `src/node/hooks/express/openapi.ts`
|
||||||
|
or anything that affects the spec it builds.
|
||||||
|
- What gets regenerated: `admin/src/api/schema.d.ts` only.
|
||||||
|
- The CI freshness check and how to recover from a failing check.
|
||||||
|
- A short "how to use the client" snippet showing
|
||||||
|
`$api.useQuery("get", "/some/path")` once admin endpoints are in the spec.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Module-load smoke test** (`admin/src/api/__tests__/client.test.ts` or
|
||||||
|
similar, matching whatever test infra `admin/` already uses): imports
|
||||||
|
`$api` and `fetchClient`, asserts both are defined. This catches toolchain
|
||||||
|
wiring breakage (missing peer deps, bad export shape, etc.).
|
||||||
|
- **CI freshness check** (above) is the test for spec/schema sync.
|
||||||
|
- **Manual smoke after PR install:** install the branch on the local
|
||||||
|
Etherpad, open `/admin`, confirm:
|
||||||
|
- Existing socket.io flows (settings, plugins, pads) still work — no
|
||||||
|
regressions from the `<QueryProvider>` wrap.
|
||||||
|
- React Query devtools panel appears in a dev build (`pnpm --filter admin
|
||||||
|
dev`) and is absent from a production build.
|
||||||
|
|
||||||
|
Note: per project convention, the user expects automated tests before manual
|
||||||
|
verification, but the manual smoke is unavoidable here because devtools
|
||||||
|
visibility and provider wrap are runtime concerns. The smoke check is a
|
||||||
|
secondary safety net, not the primary test strategy.
|
||||||
|
|
||||||
|
## Branch / PR plan
|
||||||
|
|
||||||
|
- Fork: `johnmclear/etherpad-lite` (per project convention; never commit
|
||||||
|
directly to `ether/etherpad-lite`).
|
||||||
|
- Branch: `chore/admin-typesafe-api-7638`.
|
||||||
|
- Base: latest `main` of the fork, after syncing from `ether/etherpad-lite`.
|
||||||
|
- PR title: `chore(admin): typesafe API client + TanStack Query rails`.
|
||||||
|
- PR body declares semver: **patch** (build tooling + unused runtime libs;
|
||||||
|
no observable behavior change).
|
||||||
|
- PR body links #7638 and notes:
|
||||||
|
- Rails-only — no call site migrations.
|
||||||
|
- Separate spec-coverage issue to follow.
|
||||||
|
- #7601 should rebase onto this branch once merged.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`openapi.ts` not cleanly importable.** If pulling the spec builder out
|
||||||
|
requires touching production paths, that risk needs a small refactor PR
|
||||||
|
first. Mitigation: keep the extraction surgical; if it grows, split into
|
||||||
|
its own PR and rebase 7638 on top.
|
||||||
|
- **Bundle size.** TanStack Query + react-query bindings add ~12 KB gzipped
|
||||||
|
to the admin bundle even with no call sites using it. Acceptable for an
|
||||||
|
internal admin UI; flag in PR body for transparency.
|
||||||
|
- **Provider wrap regressions.** `<QueryProvider>` wrapping `<App />` should
|
||||||
|
be inert for socket.io paths but the manual smoke confirms.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
- `pnpm --filter admin gen:api` runs cleanly on a fresh checkout.
|
||||||
|
- `pnpm --filter admin build` succeeds.
|
||||||
|
- `admin/src/api/schema.d.ts` is checked in with the generated header.
|
||||||
|
- `<QueryProvider>` wraps `<App />`; devtools visible in dev, absent in
|
||||||
|
production build.
|
||||||
|
- CI freshness check is wired and passing.
|
||||||
|
- `admin/README.md` documents the codegen workflow.
|
||||||
|
- Manual smoke confirms no regression in existing socket.io-driven pages.
|
||||||
|
- PR opened against `johnmclear/etherpad-lite`, semver labelled patch,
|
||||||
|
Qodo `/review` triggered after push.
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
# Admin UI for GDPR Art. 17 author erasure
|
||||||
|
|
||||||
|
Follow-up to PR5 of #6701 (`feat-gdpr-author-erasure`, merged via #7550).
|
||||||
|
PR5 shipped the `anonymizeAuthor` capability as a REST endpoint only.
|
||||||
|
This spec adds an in-product surface so an operator can find and erase
|
||||||
|
an author from `/admin` without crafting a `curl`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
After PR5, erasing an author requires:
|
||||||
|
|
||||||
|
1. Knowing the opaque `authorID` (e.g. `a.XXXXXXXXXXXXXXXX`).
|
||||||
|
2. Holding admin credentials (apikey / JWT).
|
||||||
|
3. Running a `curl` against `/api/1.3.1/anonymizeAuthor` with the
|
||||||
|
correct settings flag enabled.
|
||||||
|
|
||||||
|
For instances handling real GDPR Art. 17 requests this is too much
|
||||||
|
friction and too easy to mis-target (the only check before destruction
|
||||||
|
is "did you paste the right ID?"). Operators have asked for the same
|
||||||
|
"search → click → confirm" flow they already have for pads.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Admins can locate an author by display name **or** by external
|
||||||
|
mapper (SSO subject, token-binding key) — the two identifiers a
|
||||||
|
GDPR request typically arrives carrying.
|
||||||
|
2. Before the irreversible erasure runs, the admin sees a server-side
|
||||||
|
preview of what will be touched (mappings, chat messages, affected
|
||||||
|
pads).
|
||||||
|
3. The page itself is discoverable even when the feature flag is off,
|
||||||
|
so admins know the capability exists and where to enable it.
|
||||||
|
4. No new public API surface; the public REST endpoint is unchanged
|
||||||
|
and its single feature flag (`gdprAuthorErasure.enabled`) keeps its
|
||||||
|
existing meaning.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **Pad-context discovery** (drilling from a pad to its contributors).
|
||||||
|
Possible follow-up; not in this spec.
|
||||||
|
- **Bulk erase / multi-select.** GDPR requests are per-subject.
|
||||||
|
- **Audit-log export of erasures.** Operators already have log4js +
|
||||||
|
the existing `anonymizeAuthor` log line.
|
||||||
|
- **Undo / recovery.** Erasure is irreversible by design.
|
||||||
|
- **Refactoring `PadPage.tsx`** into a shared list-page component.
|
||||||
|
After this lands there will be two real consumers; the abstraction
|
||||||
|
comes then, not before.
|
||||||
|
- **Backfill migration for the new `lastSeen` field.** New-on-touch
|
||||||
|
only; pre-existing records show `—` until they are touched again.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
A new admin page at `/admin/authors`, sidebar entry between Pads and
|
||||||
|
Shout (icon: `Users` from lucide).
|
||||||
|
|
||||||
|
Layout mirrors `PadPage.tsx`:
|
||||||
|
|
||||||
|
- Search field — substring match on `name` OR `mapper`.
|
||||||
|
- Toggle "Show erased authors" (off by default).
|
||||||
|
- Sortable table:
|
||||||
|
| Color | Name | Mapper | Last seen | Author ID | Actions |
|
||||||
|
- Color renders as an inline `<span>` with `background-color`.
|
||||||
|
- Author ID column shows the full ID (copyable).
|
||||||
|
- Mapper column renders the first mapper string; if an author has
|
||||||
|
more than one (multi-SSO accounts, rare), append `+N` and show
|
||||||
|
the full list in a `title` tooltip.
|
||||||
|
- Actions column has a single `Trash2` "Erase" button per row.
|
||||||
|
- Pagination — 12 rows per page (matches Pads).
|
||||||
|
- Cap warning — when the server reports `cappedAt`, render a banner
|
||||||
|
"Showing first 1000 authors. Narrow your search to see more."
|
||||||
|
|
||||||
|
### Erasure flow (two-step)
|
||||||
|
|
||||||
|
Clicking "Erase" opens a Radix `Dialog.Root` with two phases held in
|
||||||
|
local state (`'preview' | 'committing' | 'closed'`):
|
||||||
|
|
||||||
|
1. **Preview** — open emits `anonymizeAuthorPreview`. While waiting
|
||||||
|
the modal shows a spinner. On `results:anonymizeAuthorPreview`,
|
||||||
|
counters render:
|
||||||
|
> About to erase author **`<name>`** (`a.XXXX`).
|
||||||
|
> Will clear: **N** token mappings, **M** mapper bindings, **K**
|
||||||
|
> chat messages, across **P** pads.
|
||||||
|
> **This cannot be undone.**
|
||||||
|
|
||||||
|
Buttons: Cancel · Continue.
|
||||||
|
|
||||||
|
2. **Commit** — Continue emits `anonymizeAuthor`. On
|
||||||
|
`results:anonymizeAuthor` the modal closes, a success toast
|
||||||
|
renders, and the row is replaced in-place with a greyed
|
||||||
|
"(erased)" stub.
|
||||||
|
|
||||||
|
If `results:anonymizeAuthor` carries `error`, the modal stays open
|
||||||
|
and surfaces the error inline (no destructive close-on-error).
|
||||||
|
|
||||||
|
### Disabled-flag UX
|
||||||
|
|
||||||
|
When `gdprAuthorErasure.enabled = false`:
|
||||||
|
|
||||||
|
- The page renders normally — table, search, sort and pagination
|
||||||
|
all work (read-only browse is harmless).
|
||||||
|
- A persistent banner at the top reads:
|
||||||
|
> Author erasure is disabled. Set `"gdprAuthorErasure": {"enabled":
|
||||||
|
> true}` in `settings.json` to enable.
|
||||||
|
- Every Erase button is disabled with the same message as a
|
||||||
|
`title` tooltip.
|
||||||
|
- The dry-run preview event remains usable from the admin socket
|
||||||
|
(it is read-only and admin-authed) — but the UI does not invoke it
|
||||||
|
while the live action is disabled, to avoid implying an action is
|
||||||
|
about to happen.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Three new admin-socket events on the existing `settings_admin` socket
|
||||||
|
(parallel to `deletePad` / `cleanupPadRevisions`). **Not REST.**
|
||||||
|
Rationale: matches the existing admin pattern, reuses the admin-auth
|
||||||
|
middleware, and keeps the public REST surface unchanged so
|
||||||
|
`gdprAuthorErasure.enabled` keeps its single meaning ("expose the
|
||||||
|
public REST endpoint").
|
||||||
|
|
||||||
|
| Event in | Payload | Event out | Result shape |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `authorLoad` | `{offset, limit, pattern, sortBy, ascending, includeErased}` | `results:authorLoad` | `{total, cappedAt?, results: [{authorID, name, colorId, mapper, lastSeen, erased}]}` |
|
||||||
|
| `anonymizeAuthorPreview` | `{authorID}` | `results:anonymizeAuthorPreview` | `{authorID, name, removedTokenMappings, removedExternalMappings, clearedChatMessages, affectedPads}` |
|
||||||
|
| `anonymizeAuthor` | `{authorID}` | `results:anonymizeAuthor` | `{authorID, ...counters} \| {authorID, error: 'disabled' \| 'unknown' \| <message>}` |
|
||||||
|
|
||||||
|
### `authorManager.searchAuthors(query)`
|
||||||
|
|
||||||
|
New helper. Algorithm:
|
||||||
|
|
||||||
|
1. `keys = await db.findKeys('globalAuthor:*', null)`.
|
||||||
|
2. `mapperIndex = Map<authorID, mapper[]>` built once via
|
||||||
|
`db.findKeys('mapper2author:*', null)` + a single batch read of
|
||||||
|
the values. (`mapper2author:<mapper>` → `{authorID}`.)
|
||||||
|
3. For each `globalAuthor:<id>` record:
|
||||||
|
- read the record;
|
||||||
|
- skip if `erased` and `!includeErased`;
|
||||||
|
- filter on `pattern` (substring match on `name` OR any mapper in
|
||||||
|
`mapperIndex.get(authorID) ?? []`);
|
||||||
|
- emit `{authorID, name, colorId, mapper: mapperIndex.get(...) ??
|
||||||
|
[], lastSeen, erased}`.
|
||||||
|
4. Sort the in-memory list by `sortBy` (`name` | `lastSeen`),
|
||||||
|
ascending or descending.
|
||||||
|
5. If pre-pagination length > 1000, slice to 1000 and set `cappedAt:
|
||||||
|
1000`.
|
||||||
|
6. Apply `offset`/`limit` for pagination; return `{total, cappedAt?,
|
||||||
|
results}` where `total` is the post-filter, post-cap count.
|
||||||
|
|
||||||
|
Performance is acceptable for the typical instance size and is bounded
|
||||||
|
by the cap. A proper indexed scan can replace this if anyone hits the
|
||||||
|
cap regularly — explicit follow-up, not now.
|
||||||
|
|
||||||
|
### `lastSeen` field
|
||||||
|
|
||||||
|
Added to `globalAuthor:<id>`. Set to `Date.now()` on the existing
|
||||||
|
write paths in `AuthorManager` that already touch the record
|
||||||
|
(`setAuthorName`, `setAuthorColorId`, `createAuthor*`) — i.e. when
|
||||||
|
an author actively does something the system records. Read paths are
|
||||||
|
not modified to avoid an extra write per page load. New-on-touch
|
||||||
|
only; no migration sweep. Surfaced as ISO-8601 in the search result
|
||||||
|
and rendered as `toLocaleString()` in the UI. Records without
|
||||||
|
`lastSeen` render as `—`.
|
||||||
|
|
||||||
|
### Dry-run plumbing
|
||||||
|
|
||||||
|
`authorManager.anonymizeAuthor(authorID, {dryRun: true})` returns the
|
||||||
|
same counter shape without writing. Implementation: walk the same
|
||||||
|
loops, count, return — no `db.set` / `db.remove`. Same admin-auth gate
|
||||||
|
on the socket layer. The `gdprAuthorErasure.enabled` flag does NOT
|
||||||
|
gate the dry-run path (read-only, admin-authed); it only gates the
|
||||||
|
live `anonymizeAuthor` socket event (matching the public REST
|
||||||
|
endpoint's behaviour).
|
||||||
|
|
||||||
|
### Settings flag delivery to client
|
||||||
|
|
||||||
|
The `settingsSocket` already streams an `init` payload to the admin
|
||||||
|
on connect. Add `gdprAuthorErasure: settings.gdprAuthorErasure` to it
|
||||||
|
and have `App.tsx` populate `gdprAuthorErasureEnabled` in the store
|
||||||
|
once on connect. The page renders the disabled banner when false.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `admin/src/pages/AuthorPage.tsx` — page component, mirrors
|
||||||
|
`PadPage.tsx` shape.
|
||||||
|
- `admin/src/utils/AuthorSearch.ts` — `AuthorSearchQuery`,
|
||||||
|
`AuthorSearchResult`, `AuthorRow` types.
|
||||||
|
- `admin/src/components/ColorSwatch.tsx` — small `<span>` with inline
|
||||||
|
`background-color`. Reusable.
|
||||||
|
|
||||||
|
### Edited files
|
||||||
|
|
||||||
|
- `admin/src/store/store.ts` — `authors`, `setAuthors`,
|
||||||
|
`gdprAuthorErasureEnabled`.
|
||||||
|
- `admin/src/main.tsx` — register `<Route path="/authors"
|
||||||
|
element={<AuthorPage/>}/>`.
|
||||||
|
- `admin/src/App.tsx` (or whichever file owns the sidebar) — new
|
||||||
|
"Authors" link between Pads and Shout.
|
||||||
|
- `admin/src/localization/locales/en.json` — see i18n keys below.
|
||||||
|
- `src/node/hooks/express/admin.ts` — extend the `init` payload with
|
||||||
|
`gdprAuthorErasure`.
|
||||||
|
- `src/node/hooks/express/settings_admin.ts` (or equivalent) — wire
|
||||||
|
the three new socket events.
|
||||||
|
|
||||||
|
### i18n keys
|
||||||
|
|
||||||
|
All user-visible strings go through `Trans` / `t()` per the project's
|
||||||
|
i18n rule. New keys:
|
||||||
|
|
||||||
|
- `ep_admin_authors:title`
|
||||||
|
- `ep_admin_authors:search-placeholder`
|
||||||
|
- `ep_admin_authors:column.color`
|
||||||
|
- `ep_admin_authors:column.name`
|
||||||
|
- `ep_admin_authors:column.mapper`
|
||||||
|
- `ep_admin_authors:column.last-seen`
|
||||||
|
- `ep_admin_authors:column.author-id`
|
||||||
|
- `ep_admin_authors:column.actions`
|
||||||
|
- `ep_admin_authors:show-erased`
|
||||||
|
- `ep_admin_authors:erase`
|
||||||
|
- `ep_admin_authors:erased-stub`
|
||||||
|
- `ep_admin_authors:cap-warning`
|
||||||
|
- `ep_admin_authors:feature-disabled-banner`
|
||||||
|
- `ep_admin_authors:confirm-preview-title`
|
||||||
|
- `ep_admin_authors:confirm-preview-counters`
|
||||||
|
- `ep_admin_authors:confirm-irreversible`
|
||||||
|
- `ep_admin_authors:cancel`
|
||||||
|
- `ep_admin_authors:continue`
|
||||||
|
- `ep_admin_authors:erase-success-toast`
|
||||||
|
- `ep_admin_authors:erase-error-toast`
|
||||||
|
|
||||||
|
Other locales fall back to English until translated.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Per the project rule, both backend and frontend suites ship with the
|
||||||
|
PR.
|
||||||
|
|
||||||
|
### Backend (`mocha --import=tsx`)
|
||||||
|
|
||||||
|
- **`src/tests/backend/specs/admin/authorSearch.ts`** — covers
|
||||||
|
`authorManager.searchAuthors`:
|
||||||
|
- empty store → `{total: 0, results: []}`
|
||||||
|
- 3 authors, no filter → all 3, sorted by name asc
|
||||||
|
- search by name substring matches
|
||||||
|
- search by mapper substring matches (joins `mapper2author`)
|
||||||
|
- `includeErased: false` (default) hides erased; `true` includes
|
||||||
|
- sort by `lastSeen` asc / desc
|
||||||
|
- cap-at-1000: insert 1100, assert `results.length === 1000` and
|
||||||
|
`cappedAt === 1000`.
|
||||||
|
- **`src/tests/backend/specs/anonymizeAuthor.ts`** (extend existing):
|
||||||
|
- dry-run returns the same counter shape as the live path without
|
||||||
|
mutating `globalAuthor:<id>`
|
||||||
|
- dry-run on an unknown authorID returns zeros without throwing.
|
||||||
|
- **`src/tests/backend/specs/admin/anonymizeAuthorSocket.ts`** —
|
||||||
|
admin-socket integration:
|
||||||
|
- opens `settings_admin` with admin creds;
|
||||||
|
- `authorLoad` round-trip;
|
||||||
|
- `anonymizeAuthorPreview` round-trip; asserts `erased` is NOT
|
||||||
|
flipped on the record;
|
||||||
|
- live `anonymizeAuthor` round-trip when flag enabled;
|
||||||
|
- live `anonymizeAuthor` returns `{error: 'disabled'}` when flag
|
||||||
|
off;
|
||||||
|
- dry-run preview still works when flag off.
|
||||||
|
|
||||||
|
### Frontend (Playwright, `src/tests/frontend-new/specs/`)
|
||||||
|
|
||||||
|
- **`admin_authors_page.spec.ts`**:
|
||||||
|
- navigates to `/admin/authors` via the existing admin auth
|
||||||
|
fixture;
|
||||||
|
- seeds two authors via the existing API helpers;
|
||||||
|
- asserts the localized header string (`t('ep_admin_authors:title')`)
|
||||||
|
renders — not just element presence (per project rule);
|
||||||
|
- search by name filters the table to one row;
|
||||||
|
- clicking Erase opens the modal; preview counters render;
|
||||||
|
Continue commits; row shows the localized "(erased)" stub;
|
||||||
|
success toast text matches the localized string;
|
||||||
|
- with the feature flag toggled off via the test settings hook,
|
||||||
|
the localized banner renders and the Erase button is disabled.
|
||||||
|
|
||||||
|
## Backwards compatibility
|
||||||
|
|
||||||
|
- The admin socket gains three new events; absent admin builds
|
||||||
|
ignore them.
|
||||||
|
- The public REST endpoint and its flag are unchanged.
|
||||||
|
- Adding `lastSeen` to `globalAuthor` is additive — older record
|
||||||
|
readers ignore unknown fields.
|
||||||
|
- No DB migration required.
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
# Issue 7693 — Document admin endpoints in the OpenAPI spec
|
||||||
|
|
||||||
|
**Status:** design approved 2026-05-08
|
||||||
|
**Issue:** https://github.com/ether/etherpad/issues/7693
|
||||||
|
**Stacks on:** PR #7695 (`chore/admin-typesafe-api-7638-upstream`) — codegen rails
|
||||||
|
**Related:** #7601 (introduced `/admin/update/status`); #7607 (Tier 2 update endpoints, in-flight)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add OpenAPI definitions for the admin endpoints currently consumed by the admin
|
||||||
|
UI so the typed client generated by PR #7695 (`admin/src/api/schema.d.ts`)
|
||||||
|
gains admin call-sites the day it lands.
|
||||||
|
|
||||||
|
This PR adds the schema only. **No call-sites migrate** — that is the explicit
|
||||||
|
follow-up named in #7693.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In:
|
||||||
|
|
||||||
|
- `POST /admin-auth/` — login + session check (consumed by `LoginScreen.tsx`
|
||||||
|
and `App.tsx`).
|
||||||
|
- `GET /admin/update/status` — Tier 1 update banner data (consumed by
|
||||||
|
`UpdateBanner.tsx` and `UpdatePage.tsx`; introduced by #7601, merged on
|
||||||
|
develop).
|
||||||
|
|
||||||
|
Out:
|
||||||
|
|
||||||
|
- `/admin/update/{apply,cancel,acknowledge,log}` — Tier 2 endpoints from the
|
||||||
|
in-flight `feat/7607-auto-update-tier2-manual-click` branch. That PR amends
|
||||||
|
`openapi-admin.ts` when it lands.
|
||||||
|
- The admin SPA static-file route (`/admin/{*filename}`) — not an API.
|
||||||
|
- `/admin/socket.io/*` — websocket; out of OpenAPI scope.
|
||||||
|
- `/api/version-status` — already public, belongs in the public spec, not the
|
||||||
|
admin spec.
|
||||||
|
- Migrating any of the four admin `fetch()` call-sites to `$api`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### File layout (new files marked NEW)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/node/hooks/express/
|
||||||
|
├── openapi.ts unchanged — APIHandler-driven public spec
|
||||||
|
└── openapi-admin.ts NEW — hand-authored OpenAPI 3.0 doc for admin routes
|
||||||
|
|
||||||
|
src/tests/backend/specs/
|
||||||
|
└── openapi-admin.ts NEW — Mocha specs asserting document shape
|
||||||
|
|
||||||
|
admin/scripts/
|
||||||
|
├── dump-spec.ts MODIFIED — also import generateAdminDefinition,
|
||||||
|
│ deep-merge into one document, write merged JSON
|
||||||
|
├── merge-openapi.mjs NEW — focused deep-merge with collision detection
|
||||||
|
├── __tests__/
|
||||||
|
│ └── merge-openapi.test.mjs NEW — node --test unit specs for the merge
|
||||||
|
└── gen-api.mjs unchanged — still calls dump-spec.ts then
|
||||||
|
openapi-typescript on the resulting JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
`openapi-admin.ts` is a **static OpenAPI document** (no APIHandler reflection).
|
||||||
|
Hand-authored because admin routes aren't registered through APIHandler — they
|
||||||
|
are plain Express handlers. This keeps `openapi.ts`'s 771-line generator
|
||||||
|
untouched and avoids tangling two different generation strategies in one
|
||||||
|
module.
|
||||||
|
|
||||||
|
### Why merge in `dump-spec.ts` rather than at `openapi-typescript` time
|
||||||
|
|
||||||
|
`openapi-typescript` only accepts one input. We could run it twice and emit
|
||||||
|
two `.d.ts` files, but the chosen design (see "Codegen merge" below) is a
|
||||||
|
single merged `schema.d.ts`. The merge therefore happens at JSON-dump time,
|
||||||
|
before `openapi-typescript` runs.
|
||||||
|
|
||||||
|
### Two clients, one schema
|
||||||
|
|
||||||
|
The merged schema covers two surfaces with different baseUrls (public API
|
||||||
|
under `/api/<version>/`, admin endpoints at root). A single runtime client
|
||||||
|
with one `baseUrl` cannot target both correctly. `admin/src/api/client.ts`
|
||||||
|
therefore narrows the generated `paths` interface by URL prefix and exports
|
||||||
|
two clients:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AdminPath = Extract<keyof paths, `/admin${string}`>;
|
||||||
|
type PublicPath = Exclude<keyof paths, AdminPath>;
|
||||||
|
export const fetchClient = createClient<Pick<paths, PublicPath>>({ baseUrl: API_BASE_URL });
|
||||||
|
export const adminFetchClient = createClient<Pick<paths, AdminPath>>({ baseUrl: '/' });
|
||||||
|
export const $api = createQueryHooks(fetchClient);
|
||||||
|
export const $adminApi = createQueryHooks(adminFetchClient);
|
||||||
|
```
|
||||||
|
|
||||||
|
Narrowing at the type level means TypeScript rejects calling an admin path
|
||||||
|
on `fetchClient` (or vice versa) at compile time — the runtime baseUrl
|
||||||
|
mismatch is unrepresentable.
|
||||||
|
|
||||||
|
## OpenAPI document contents
|
||||||
|
|
||||||
|
### Info & security schemes
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
openapi: 3.0.2
|
||||||
|
info:
|
||||||
|
title: Etherpad Admin API
|
||||||
|
version: <getEpVersion()>
|
||||||
|
description: |
|
||||||
|
Authenticated administrative endpoints consumed by the Etherpad admin UI.
|
||||||
|
Distinct from the public /api/{version}/* surface served by openapi.json.
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
basicAuth:
|
||||||
|
type: http
|
||||||
|
scheme: basic
|
||||||
|
sessionCookie:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: express_sid
|
||||||
|
```
|
||||||
|
|
||||||
|
`basicAuth` covers the login POST to `/admin-auth/`. `sessionCookie` covers
|
||||||
|
post-login admin sessions established by `express-session` (cookie name
|
||||||
|
`express_sid` is the Etherpad default; if a deployment overrides it the spec
|
||||||
|
remains structurally correct — only the documented cookie name shifts).
|
||||||
|
|
||||||
|
The two schemes coexist on `/admin-auth/`; only `sessionCookie` applies on
|
||||||
|
`/admin/update/status`.
|
||||||
|
|
||||||
|
### Paths
|
||||||
|
|
||||||
|
#### `POST /admin-auth/` — `verifyAdminAccess`
|
||||||
|
|
||||||
|
- **Security:** `[{ basicAuth: [] }, { sessionCookie: [] }, {}]` — Basic *or*
|
||||||
|
session cookie *or* none. The empty object documents that the server
|
||||||
|
accepts the request without auth and replies `401`.
|
||||||
|
- **Responses:**
|
||||||
|
- `200` — admin verified (Basic logged in, or session cookie was valid for
|
||||||
|
an admin user). Empty body.
|
||||||
|
- `401` — no auth presented and no session. Empty body.
|
||||||
|
- `403` — auth presented or session present, but the user is not an admin.
|
||||||
|
Empty body.
|
||||||
|
- **Description:** notes that POST with `Authorization: Basic …` establishes
|
||||||
|
an admin session; POST with no auth header verifies an existing one.
|
||||||
|
|
||||||
|
This single-operation modeling matches reality: the route is one
|
||||||
|
middleware-terminated path that branches on what the client sends. Two
|
||||||
|
operations on the same path would imply different server behavior the
|
||||||
|
admin UI does not actually depend on.
|
||||||
|
|
||||||
|
#### `GET /admin/update/status` — `getUpdateStatus`
|
||||||
|
|
||||||
|
- **Security:** `[{ sessionCookie: [] }, {}]` — cookie when
|
||||||
|
`updates.requireAdminForStatus=true`, otherwise anonymous OK. The
|
||||||
|
conditional is documented in the description; clients that depend on
|
||||||
|
receiving the full diagnostic payload should send the session cookie.
|
||||||
|
- **Responses:**
|
||||||
|
- `200` — JSON body matching the `UpdateStatus` schema below.
|
||||||
|
- `401` / `403` — only emitted when `updates.requireAdminForStatus=true`.
|
||||||
|
|
||||||
|
Response schema `UpdateStatus` mirrors the runtime shape returned by
|
||||||
|
`src/node/hooks/express/updateStatus.ts:res.json({...})` on the base branch
|
||||||
|
(`chore/admin-typesafe-api-7638-upstream`, which mirrors develop's Tier 1):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
UpdateStatus:
|
||||||
|
type: object
|
||||||
|
required: [currentVersion, installMethod, tier, vulnerableBelow]
|
||||||
|
properties:
|
||||||
|
currentVersion: { type: string }
|
||||||
|
latest: { $ref: '#/components/schemas/ReleaseInfo', nullable: true }
|
||||||
|
lastCheckAt: { type: string, format: date-time, nullable: true }
|
||||||
|
installMethod: { type: string, enum: [auto, git, docker, npm, managed] }
|
||||||
|
tier: { type: string, enum: [off, notify, manual, auto, autonomous] }
|
||||||
|
policy: { $ref: '#/components/schemas/PolicyResult', nullable: true }
|
||||||
|
vulnerableBelow:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/VulnerableBelowDirective' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sub-schemas (`ReleaseInfo`, `PolicyResult`, `VulnerableBelowDirective`)
|
||||||
|
mirror the exported interfaces in `src/node/updater/types.ts` exactly:
|
||||||
|
|
||||||
|
- `ReleaseInfo`: `version`, `tag`, `body`, `publishedAt`, `prerelease`, `htmlUrl`.
|
||||||
|
- `PolicyResult`: `canNotify`, `canManual`, `canAuto`, `canAutonomous`, `reason`.
|
||||||
|
- `VulnerableBelowDirective`: `announcedBy`, `threshold`.
|
||||||
|
|
||||||
|
The Tier 2 PR (#7607) will amend `UpdateStatus` to add `execution`,
|
||||||
|
`lastResult`, and `lockHeld` (with their corresponding sub-schemas) when it
|
||||||
|
ships its own changes to `updateStatus.ts`. Those fields are out of scope
|
||||||
|
here.
|
||||||
|
|
||||||
|
### Public exposure (runtime)
|
||||||
|
|
||||||
|
`openapi-admin.ts` exports an `expressPreSession` hook that **conditionally**
|
||||||
|
mounts:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/openapi.json (CORS: *)
|
||||||
|
```
|
||||||
|
|
||||||
|
The route is gated by `settings.adminOpenAPI.enabled`, **default `false`**,
|
||||||
|
per the project's "new features behind a flag, off by default" policy
|
||||||
|
(CONTRIBUTING.md, AGENTS.MD, best_practices.md). When the flag is off,
|
||||||
|
`expressPreSession` returns early and the route is dormant.
|
||||||
|
|
||||||
|
When enabled, the route registers in `expressPreSession`, which runs before
|
||||||
|
`expressCreateServer` (where `admin.ts` registers the SPA wildcard
|
||||||
|
`/admin/{*filename}`). The earlier registration ensures
|
||||||
|
`/admin/openapi.json` resolves before the wildcard catches it.
|
||||||
|
|
||||||
|
Codegen does not depend on this route — `dump-spec.ts` calls
|
||||||
|
`generateAdminDefinition()` in-process. The route exists for downstream
|
||||||
|
tooling (Postman, swagger-ui, third-party clients) that operators choose to
|
||||||
|
expose.
|
||||||
|
|
||||||
|
## Codegen merge
|
||||||
|
|
||||||
|
`merge-openapi.mjs` exports one function:
|
||||||
|
|
||||||
|
```js
|
||||||
|
mergeOpenAPI(publicDoc, adminDoc) -> mergedDoc
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
| Section | Rule |
|
||||||
|
| ------------------------------ | ----------------------------------------------------------------- |
|
||||||
|
| `paths` | Union by path key. Collision throws. |
|
||||||
|
| `components.schemas` | Union by name. Collision throws. |
|
||||||
|
| `components.parameters` | Union by name. Collision throws. |
|
||||||
|
| `components.responses` | Union by name. Collision throws. |
|
||||||
|
| `components.securitySchemes` | Union by name. Collision throws. |
|
||||||
|
| `security` (root) | Public spec's root `security` is preserved; admin paths declare their own per-operation security so admin requirements never apply to public paths. |
|
||||||
|
| `info`, `servers` | Public spec wins. |
|
||||||
|
|
||||||
|
Throwing on collision is intentional: silent overwrite is a footgun, and the
|
||||||
|
backend test below catches collisions before merge runs in CI.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Backend — `src/tests/backend/specs/openapi-admin.ts`
|
||||||
|
|
||||||
|
Mocha specs against `generateAdminDefinition()`. No live HTTP.
|
||||||
|
|
||||||
|
- Document is valid OpenAPI 3.0 (smoke check via `openapi-schema-validation`,
|
||||||
|
already in `node_modules`).
|
||||||
|
- `paths['/admin-auth/'].post.operationId === 'verifyAdminAccess'` and
|
||||||
|
declares responses `200`, `401`, `403`.
|
||||||
|
- `paths['/admin/update/status'].get.operationId === 'getUpdateStatus'` and
|
||||||
|
references `#/components/schemas/UpdateStatus`.
|
||||||
|
- `components.securitySchemes` contains `basicAuth` and `sessionCookie`.
|
||||||
|
- `components.schemas.UpdateStatus.properties` contains every property name
|
||||||
|
emitted by `updateStatus.ts:res.json({...})`. Cross-checked by importing
|
||||||
|
the same handler and asserting key parity. This is the regression net for
|
||||||
|
spec/handler drift.
|
||||||
|
- Admin operationIds and admin path keys do not collide with the public
|
||||||
|
spec (cross-loaded via `generateDefinitionForVersion`). Cross-collision
|
||||||
|
is impossible today (admin paths start with `/admin`, public paths are
|
||||||
|
flat or `/createGroup`-style), but the test fails loudly if a future
|
||||||
|
rename breaks the assumption.
|
||||||
|
|
||||||
|
### Codegen merge — `admin/scripts/__tests__/merge-openapi.test.mjs`
|
||||||
|
|
||||||
|
Node `--test` runner (already used by #7695 for `client.test.ts`).
|
||||||
|
|
||||||
|
- Two minimal docs merge into the expected union.
|
||||||
|
- Path collision throws.
|
||||||
|
- Schema-name collision throws.
|
||||||
|
- Public root `security` is preserved when admin doc declares no root
|
||||||
|
security.
|
||||||
|
- Per-operation security on admin paths survives the merge unchanged.
|
||||||
|
|
||||||
|
### No frontend tests this PR
|
||||||
|
|
||||||
|
No call-sites migrate, so there is nothing UI-observable to assert.
|
||||||
|
Migration PRs add Playwright coverage when they touch each fetch.
|
||||||
|
|
||||||
|
## Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `UpdateStatus` schema drifts from `updateStatus.ts` over time | Backend spec cross-checks property names against the handler. Tier 2 PR amends both spec and handler in one change. |
|
||||||
|
| Tier 2 (#7607) rebase conflicts with the new `openapi-admin.ts` | This PR adds only `/admin/update/status`. Tier 2 appends new entries — no conflict on the existing one. |
|
||||||
|
| `merge-openapi.mjs` silently overwrites a duplicate | Throws on collision. Backend spec cross-checks against the public spec. |
|
||||||
|
| `/admin/openapi.json` collides with `/admin/{*filename}` SPA wildcard | `openapi-admin.ts` registers in `expressPreSession`; `admin.ts` registers in `expressCreateServer`. Earlier hook wins. Backend smoke test confirms 200 + JSON content-type. |
|
||||||
|
| #7695 changes shape before it merges, breaking our base | This PR is stacked on #7695's branch. Rebase when #7695 rebases. PR description documents the dependency. |
|
||||||
|
| `express_sid` is not the actual cookie name in some deployments | Documented; spec is structurally correct; deployments that override it can still consume a typed client. |
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Branch `feat/7693-admin-openapi` from `chore/admin-typesafe-api-7638-upstream`.
|
||||||
|
2. Add `openapi-admin.ts`, `merge-openapi.mjs`; modify `dump-spec.ts`.
|
||||||
|
3. Add backend spec and merge unit tests.
|
||||||
|
4. Open PR #7693 as **draft**, base set to `chore/admin-typesafe-api-7638-upstream`.
|
||||||
|
5. When PR #7695 merges to develop, change base to `develop`, rebase, mark
|
||||||
|
ready for review.
|
||||||
|
6. Follow-up PR (separately tracked) migrates the four admin `fetch()`
|
||||||
|
sites: `LoginScreen.tsx`, `App.tsx`, `UpdateBanner.tsx`, `UpdatePage.tsx`.
|
||||||
|
|
||||||
|
## Open question deferred to implementation
|
||||||
|
|
||||||
|
The `express_sid` cookie name is the documented default but Etherpad
|
||||||
|
deployments can override it via settings. Implementation will read the
|
||||||
|
configured name at spec-generation time (or document the override path) so
|
||||||
|
the spec reflects the running configuration. If reading the configured name
|
||||||
|
is awkward at codegen time (it requires booting Settings), the spec keeps
|
||||||
|
the default and notes the override in the description.
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
# Native DOCX + PDF export and DOCX import without LibreOffice
|
||||||
|
|
||||||
|
**Status:** spec — pending implementation
|
||||||
|
**Issue:** #7538
|
||||||
|
**Extending PR:** #7568 (`feat/native-docx-export-7538`)
|
||||||
|
**Date:** 2026-05-08
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Etherpad's import/export pipeline shells out to LibreOffice (`soffice`) for every "office" format — `pdf`, `docx`, `odt`, `doc`, `rtf`. Operators who want any of those formats must install ~500 MB of LibreOffice as a runtime dependency, plus pay subprocess latency on every export. Operators who don't want LibreOffice lose those formats entirely.
|
||||||
|
|
||||||
|
PR #7568 took a first cut at native DOCX export via `html-to-docx`, but:
|
||||||
|
|
||||||
|
- it's flag-gated (`settings.nativeDocxExport`) and falls back to soffice on error, so soffice remains a soft requirement;
|
||||||
|
- the `/export` route guard and pad UI both gate `docx` on `soffice` being configured, so the new path is unreachable in a real no-soffice deployment (Qodo finding #2);
|
||||||
|
- existing tests use `settings.soffice = 'false'` (a non-null string), which sidesteps the route guard and doesn't simulate a real no-soffice deployment (Qodo finding #3);
|
||||||
|
- the `html-to-docx` dependency tree includes `node-fetch` via `image-to-base64`, so plugin-modified HTML can trigger outbound requests from the converter (Qodo finding #4);
|
||||||
|
- nothing addresses PDF, which the issue explicitly scopes alongside DOCX.
|
||||||
|
|
||||||
|
This spec replaces the flag-gated half-measure with a soffice-first selection model, adds a native PDF export path, adds native DOCX import, and hardens both export converters against SSRF.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A deployment with `settings.soffice = null` can:
|
||||||
|
|
||||||
|
- export pads as `html`, `txt`, `etherpad`, `docx`, `pdf` — all in-process, no subprocess, no native binaries.
|
||||||
|
- import `.html`, `.txt`, `.etherpad`, `.docx` files — all in-process.
|
||||||
|
|
||||||
|
A deployment with `settings.soffice` configured retains today's behavior bit-for-bit. There is no flag to flip; the path is chosen automatically based on `sofficeAvailable()`.
|
||||||
|
|
||||||
|
`odt`, `doc`, `rtf` (and `pdf` import) continue to require soffice. The deployment matrix is documented; users get a clear error message instead of a silent failure.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Native ODT export. No mature pure-JS writer; deferred to a follow-up issue.
|
||||||
|
- Native PDF/ODT/DOC/RTF import. No mature pure-JS readers for these in Node. Deferred.
|
||||||
|
- Pixel-perfect PDF fidelity. We target structural fidelity (paragraphs, headings, lists, tables, images, basic styling) — the same bar `html-to-docx` hits for DOCX.
|
||||||
|
- Memory/timeout caps on conversion. Pad size is already gated upstream; we'll add caps if production signal warrants it.
|
||||||
|
|
||||||
|
## Selection model
|
||||||
|
|
||||||
|
A single cascade in `ExportHandler.ts` (and a mirror in `ImportHandler`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
if (sofficeAvailable() === 'yes') {
|
||||||
|
→ existing soffice path (handles all formats)
|
||||||
|
} else if (sofficeAvailable() === 'withoutPDF') {
|
||||||
|
// Windows: soffice present but can't render PDF
|
||||||
|
if (type === 'pdf') → native PDF
|
||||||
|
else → soffice
|
||||||
|
} else { // 'no' — soffice null
|
||||||
|
if (type === 'docx') → native DOCX
|
||||||
|
else if (type === 'pdf') → native PDF
|
||||||
|
else → 4xx "this format requires soffice"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No fallback chain on native error. If the native converter throws, the request returns a 500 with a clear log line. This is deliberate — fallback-to-soffice is the pattern that PR #7568 originally used and that Qodo flagged as defeating the no-soffice goal.
|
||||||
|
|
||||||
|
The `nativeDocxExport` setting introduced by PR #7568 is removed entirely. With it go `NATIVE_DOCX_EXPORT`, the `doc/docker.md` row, and the new entries in `settings.json.template` / `settings.json.docker`. Native is built-in; the only thing that varies behavior is whether `soffice` is configured.
|
||||||
|
|
||||||
|
## Route guard and UI capability
|
||||||
|
|
||||||
|
`src/node/hooks/express/importexport.ts` currently rejects all of `['odt','pdf','doc','docx']` when `exportAvailable() === 'no'`. Tighten that list:
|
||||||
|
|
||||||
|
```text
|
||||||
|
if (exportAvailable() === 'no' && ['odt','doc'].includes(req.params.type)) {
|
||||||
|
→ existing "this export is not enabled" message
|
||||||
|
}
|
||||||
|
// pdf and docx fall through to ExportHandler, which dispatches per the cascade above
|
||||||
|
```
|
||||||
|
|
||||||
|
Same shape on the import endpoint: `pdf`, `odt`, `doc`, `rtf` blocked when soffice is null; `docx` (plus the pre-existing `etherpad`/`html`/`txt`) goes through.
|
||||||
|
|
||||||
|
UI side — `src/static/js/pad_impexp.ts:147-166` currently hides DOCX/PDF/ODT export links when `clientVars.exportAvailable === 'no'`. Update so:
|
||||||
|
|
||||||
|
- ODT link: visible iff `exportAvailable === 'yes'` (effectively unchanged)
|
||||||
|
- DOCX, PDF links: always visible
|
||||||
|
|
||||||
|
No new clientVars flags. The "always visible" rule reflects reality — those paths are built into core.
|
||||||
|
|
||||||
|
## Native PDF export
|
||||||
|
|
||||||
|
Module: `src/node/utils/ExportPdfNative.ts`. Single export `htmlToPdfBuffer(html: string): Promise<Buffer>`.
|
||||||
|
|
||||||
|
Approach: **`pdfkit` + `htmlparser2` + a small walker we own.** Pure JS, no jsdom, ~3 MB install footprint. We control the renderer end-to-end, so there is no SSRF surface from the converter.
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
|
|
||||||
|
1. `htmlparser2` parses the input HTML into a SAX-style event stream.
|
||||||
|
2. A walker maintains a `pdfkit` document and a stack of inline-style state. Tag handling:
|
||||||
|
- `<p>`, `<h1..h6>` — block break + font sizing
|
||||||
|
- `<strong>`/`<b>`, `<em>`/`<i>`, `<u>`, `<s>` — toggle inline style
|
||||||
|
- `<ul>`/`<ol>`/`<li>` — indent + bullet/number prefix
|
||||||
|
- `<a href="…">` — underlined text + `doc.link()` annotation
|
||||||
|
- `<br>` — `doc.moveDown(0.5)`
|
||||||
|
- `<table>`/`<tr>`/`<td>` — best-effort grid via computed x/y on `doc.text()`. Pad HTML emits real tables only via plugins; we render what we can.
|
||||||
|
- `<img src="data:…">` — embed via `doc.image(buffer)` after decoding the data URI
|
||||||
|
- `<img src="…">` (any non-data URL) — replaced with the `alt=` text or skipped. **No fetch.** This is an explicit SSRF guard at the converter; the upstream sanitizer (next section) handles it too, this is defense-in-depth.
|
||||||
|
- Unknown tags — recurse into children, ignore the wrapper
|
||||||
|
3. Buffer the PDF in memory via a `PassThrough` stream → resolve with the concatenated `Buffer`.
|
||||||
|
|
||||||
|
### Bail-out criterion
|
||||||
|
|
||||||
|
The walker is a pragmatic bet — pad HTML is constrained enough that a small walker should cover it. **If, during implementation, the walker exceeds ~500 lines of code or hits a class of pad/plugin HTML it cannot reasonably render**, switch to **Approach A**: `pdfmake` + `html-to-pdfmake` + `jsdom`. That swap keeps the same `ExportHandler.ts` integration shape — only `ExportPdfNative.ts` changes — and adds ~15–20 MB of pure-JS deps.
|
||||||
|
|
||||||
|
The plan that follows this spec must call out this decision point so the implementer doesn't grind on a dying walker.
|
||||||
|
|
||||||
|
## HTML sanitization (defense-in-depth)
|
||||||
|
|
||||||
|
New module: `src/node/utils/ExportSanitizeHtml.ts`. Single export `stripRemoteImages(html: string): string`.
|
||||||
|
|
||||||
|
Walks the HTML once with `htmlparser2`, drops any `<img>` whose `src` is not `data:` or a same-origin/relative URL. Replaces with the original `alt=` text (empty string if absent). Pure string-in/string-out, ~50 lines + a unit test.
|
||||||
|
|
||||||
|
Both export branches call this *before* handing HTML to their respective converters:
|
||||||
|
|
||||||
|
```text
|
||||||
|
const safeHtml = stripRemoteImages(html);
|
||||||
|
buffer = (type === 'docx') ? await htmlToDocx(safeHtml) : await htmlToPdfBuffer(safeHtml);
|
||||||
|
```
|
||||||
|
|
||||||
|
This addresses Qodo finding #4 against the existing DOCX path (which was always present, not introduced here) and prevents the equivalent SSRF on the PDF path.
|
||||||
|
|
||||||
|
## Native DOCX import
|
||||||
|
|
||||||
|
Module: `src/node/utils/ImportDocxNative.ts`. Single export `docxBufferToHtml(buf: Buffer): Promise<string>`.
|
||||||
|
|
||||||
|
Wraps `mammoth.convertToHtml({buffer: buf})` and returns `result.value`. Mammoth is pure JS, ~3 MB, embeds images as data URLs by default — no fetches, no SSRF surface. We pass `convertImage: mammoth.images.imgElement(...)` configured to emit data URLs only, as belt-and-braces.
|
||||||
|
|
||||||
|
Dispatch in `src/node/handler/ImportHandler.ts` mirrors the export cascade:
|
||||||
|
|
||||||
|
```text
|
||||||
|
if (sofficeAvailable() === 'yes') {
|
||||||
|
→ existing soffice path
|
||||||
|
} else if (extension === '.docx') {
|
||||||
|
const html = await docxBufferToHtml(buffer);
|
||||||
|
→ hand to existing HTML import pipeline
|
||||||
|
} else if (['.pdf','.odt','.doc','.rtf'].includes(extension)) {
|
||||||
|
→ 4xx "this format requires soffice"
|
||||||
|
}
|
||||||
|
// .etherpad / .html / .txt unchanged on both branches
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTML import pipeline already handles whatever `mammoth` emits (semantic HTML with paragraphs, lists, headings, links, inline styles, embedded images).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Native conversion errors surface to the client as 5xx with a logged error line that includes the pad id and format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`native ${type} export failed for pad "${padId}":`, err);
|
||||||
|
res.status(500).send(`Failed to export pad as ${type}.`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No fallback chain. No silent retries.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`src/tests/backend/specs/export.ts` — revise existing native-DOCX tests:
|
||||||
|
|
||||||
|
- Set `settings.soffice = null` (was `'false'` — fixes Qodo #3)
|
||||||
|
- Assert response is a ZIP-signature DOCX with the correct content-type
|
||||||
|
- Keep the `require.resolve('html-to-docx')` describe-skip guard for the `upgrade-from-latest-release` CI job
|
||||||
|
|
||||||
|
`src/tests/backend/specs/export.ts` — add native-PDF tests:
|
||||||
|
|
||||||
|
- With `settings.soffice = null`, GET `/p/<pad>/export/pdf` → 200, `Content-Type: application/pdf`, body starts with `%PDF-`
|
||||||
|
- Same describe-skip guard for `pdfkit` and `htmlparser2`
|
||||||
|
|
||||||
|
Negative test: with `settings.soffice = null`, GET `/p/<pad>/export/odt` still returns the "not enabled" message (proves we tightened the right gate).
|
||||||
|
|
||||||
|
`src/tests/backend/specs/export.ts` (or a sibling file) — unit test for `stripRemoteImages`:
|
||||||
|
|
||||||
|
- `<img src="https://evil/x.png">` → dropped
|
||||||
|
- `<img src="data:image/png;base64,…">` → kept
|
||||||
|
- `<img src="/local/x.png">` → kept (same-origin/relative)
|
||||||
|
|
||||||
|
A new import test file (e.g. `src/tests/backend/specs/import.ts` — there is no existing import-flow file in `src/tests/backend/specs/`, only `ImportEtherpad.ts`) for native DOCX import:
|
||||||
|
|
||||||
|
- Fixture: a small known `.docx` with a heading, a paragraph, and a bullet list, committed under `src/tests/backend/specs/fixtures/`
|
||||||
|
- With `settings.soffice = null`, POST `/p/<pad>/import` with the fixture → assert pad atext/HTML contains the expected structure
|
||||||
|
- Negative: rename fixture to `.odt` extension, POST → still rejected with the "requires soffice" message
|
||||||
|
|
||||||
|
`exportHTMLSend` plugin hook: verify by reading the code whether the hook fires on the native paths (currently it's only invoked on the `type === 'html'` branch). If a small move is needed to keep the hook contract intact across native DOCX/PDF, include it. If the existing behavior is "hook only fires for html export", document that and don't change it — out of scope for this spec.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/node/handler/ExportHandler.ts` | Replace flag-gated branch with soffice-first cascade; call sanitizer; native PDF + DOCX dispatch |
|
||||||
|
| `src/node/handler/ImportHandler.ts` | Soffice-first cascade; native DOCX import dispatch |
|
||||||
|
| `src/node/utils/ExportPdfNative.ts` | **new** — pdfkit walker, ≤500 lines (bail-out criterion) |
|
||||||
|
| `src/node/utils/ExportSanitizeHtml.ts` | **new** — `stripRemoteImages`, ~50 lines |
|
||||||
|
| `src/node/utils/ImportDocxNative.ts` | **new** — mammoth wrapper, ~30 lines |
|
||||||
|
| `src/node/hooks/express/importexport.ts` | Tighten export and import route guards to `['odt','doc','pdf','rtf']`-as-appropriate |
|
||||||
|
| `src/node/utils/Settings.ts` | **revert** `nativeDocxExport` field (introduced by PR #7568) |
|
||||||
|
| `src/static/js/pad_impexp.ts` | Always show DOCX + PDF export links; ODT link still gated on `exportAvailable` |
|
||||||
|
| `src/package.json` | Add `pdfkit`, `htmlparser2`, `mammoth`. Keep `html-to-docx`. Drop nothing. |
|
||||||
|
| `pnpm-lock.yaml` | Lockfile regen |
|
||||||
|
| `settings.json.template`, `settings.json.docker` | **revert** `nativeDocxExport` entries |
|
||||||
|
| `doc/docker.md` | **revert** `NATIVE_DOCX_EXPORT` row |
|
||||||
|
| `src/tests/backend/specs/export.ts` | Revise DOCX tests (`soffice=null`); add PDF tests; add negative ODT; add unit test for sanitizer |
|
||||||
|
| `src/tests/backend/specs/import.ts` | Add native DOCX import tests; add negative ODT |
|
||||||
|
| `src/tests/backend/specs/fixtures/<file>.docx` | **new** — small DOCX fixture |
|
||||||
|
|
||||||
|
## Open questions handled in implementation, not spec
|
||||||
|
|
||||||
|
- Exact error response shape for the route-guard-rejected formats — match whatever the existing soffice-disabled path uses, no fresh design.
|
||||||
|
- Whether `exportHTMLSend` needs to fire on the native paths — covered in the test plan; verify against current behavior, don't expand scope.
|
||||||
|
- Image MIME sniffing for `data:` URLs in the PDF walker — `pdfkit` accepts PNG/JPEG buffers; we'll decode the base64 and let pdfkit reject unsupported types, surfacing as a converter error.
|
||||||
|
|
||||||
|
## Dependencies summary
|
||||||
|
|
||||||
|
| Package | Purpose | Approx install size |
|
||||||
|
|---|---|---|
|
||||||
|
| `html-to-docx` | DOCX export (pre-existing in PR #7568) | ~5 MB |
|
||||||
|
| `pdfkit` | PDF export rendering | ~2 MB |
|
||||||
|
| `htmlparser2` | HTML SAX parser used by walker + sanitizer | <1 MB |
|
||||||
|
| `mammoth` | DOCX → HTML import | ~3 MB |
|
||||||
|
|
||||||
|
Total added install: roughly 11 MB across all four. Compared against ~500 MB for LibreOffice and ~200 MB for puppeteer (the alternative considered and rejected in #7538), this is the right tradeoff for the structural-fidelity bar.
|
||||||
|
|
||||||
|
## Out of scope (deferred to follow-ups)
|
||||||
|
|
||||||
|
- Native ODT export — file follow-up issue.
|
||||||
|
- Native PDF/ODT/DOC/RTF import — file follow-up issue, document why they were rejected (no mature pure-JS readers).
|
||||||
|
- Memory/timeout caps on conversion — add when production signal warrants.
|
||||||
|
- Plugin hook coverage on native paths — beyond the `exportHTMLSend` check above.
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue