mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 04:04:07 +00:00
Compare commits
269 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74864f2543 | |||
| 178df0fbe3 | |||
| 51af575c9a | |||
| 3e90ece087 | |||
| 65e5d4ab72 | |||
| 154620b99a | |||
| adbe3a40f6 | |||
| 44c6fbf372 | |||
| 45690ecd14 | |||
| c6149cd64d | |||
| 4587cf2099 | |||
| 4585988dfd | |||
| a37a9c0fa8 | |||
| 0cde099c7f | |||
| e798eb712e | |||
| 0dae908e05 | |||
| 01c7bc9de2 | |||
| 2e683f8609 | |||
| f8e51cb83d | |||
| eedfb08597 | |||
| 0aee0a019e | |||
| 91bc1944dc | |||
| f99b40cd81 | |||
| 59e68d81b2 | |||
| 86513fda0d | |||
| 61378559ca | |||
| 84df69c18a | |||
| 6194a658d9 | |||
| 97188c16d1 | |||
| 247d5fc519 | |||
| 8659262e59 | |||
| 327ebc354f | |||
| 1625a10424 | |||
| 94c2ef35b5 | |||
| bf8e04741d | |||
| 0b598680ec | |||
| 13332cf06b | |||
| e6d52cb061 | |||
| bef8fc20f1 | |||
| 9d2ffc91af | |||
| 10ad64d170 | |||
| 1c6a175ebc | |||
| 0a25de2c6d | |||
| 59a6d92cea | |||
| e0982e708f | |||
| d17457de56 | |||
| 790fccb92c | |||
| e59992d56d | |||
| 382797febc | |||
| a1d9cdc8f2 | |||
| b31666d1c9 | |||
| 19e4f0dadf | |||
| 8fde643347 | |||
| f44dd92ee0 | |||
| db0fbc1ccd | |||
| 095bed2db8 | |||
| fb2429ebb2 | |||
| 29298dcfe5 | |||
| 630f15b5ea | |||
| 04debce79e | |||
| 4003769a5c | |||
| 7faf771f9c | |||
| 51b991f851 | |||
| 8692b8790c | |||
| 1b6a156969 | |||
| f076cb82da | |||
| 073db83421 | |||
| cafc8dd46e | |||
| dc36c21c08 | |||
| 175413b3c1 | |||
| 030918cfa9 | |||
| 75d65ccb1b | |||
| def8b8a24d | |||
| f4e524f3c3 | |||
| 682ae19143 | |||
| 255607b750 | |||
| ce7437a46f | |||
| 36d8cc2e7b | |||
| dc76efc418 | |||
| 314b087f98 | |||
| 2f689ea9ca | |||
| 41edbcf67a | |||
| 6945b53c10 | |||
| 7ade99c8ce | |||
| 593df53993 | |||
| aa41c67a20 | |||
| c10a698b9f | |||
| e2756dab22 | |||
| 330644e88b | |||
| 0b86a13b51 | |||
| 8d8629516b | |||
| 5822910f40 | |||
| ce5ec7132f | |||
| 7f2a3b6039 | |||
| 3e860dd9ca | |||
| c28604b891 | |||
| aba329b1b1 | |||
| 04458bee12 | |||
| f01ffc2c67 | |||
| 5cfe925b78 | |||
| a2414685ad | |||
| a1ca1aae44 | |||
| 56536ae803 | |||
| 59154b34c9 | |||
| b64f4535fd | |||
| ad2916903c | |||
| 71cd6c2de4 | |||
| 3f2dec6c47 | |||
| 668df8c62e | |||
| 294ba0a4fe | |||
| 36c1599a09 | |||
| 65abea6910 | |||
| 18dc121132 | |||
| 013aa3954d | |||
| 182ed56fa4 | |||
| f56f6ae11e | |||
| c94ff7421a | |||
| 2dbcc39832 | |||
| 47dad114df | |||
| 7277e35a96 | |||
| 70f27085b9 | |||
| 156dc4a6c2 | |||
| d159aec78b | |||
| 547f0b84b0 | |||
| 8d10bff616 | |||
| e5048643f8 | |||
| a156f5be32 | |||
| 431122d253 | |||
| bbc8f141e3 | |||
| 01681f57c7 | |||
| 553808eb83 | |||
| 324ee69151 | |||
| c4c63ffd62 | |||
| 209f8fe5cc | |||
| 9f155a3191 | |||
| 067ab89430 | |||
| 019c668744 | |||
| b953ece4b3 | |||
| 94a9cbf070 | |||
| 8eebcea9f5 | |||
| 5e00282309 | |||
| 3b675eed60 | |||
| 51bbd337c9 | |||
| 4203dcd91f | |||
| 1e5314d847 | |||
| b98e530ffc | |||
| deb125bedc | |||
| c508bb9758 | |||
| a55d4e786f | |||
| 5a270abc05 | |||
| f50a1dabe5 | |||
| 1058849388 | |||
| 299beb65b1 | |||
| 23b7f6d71c | |||
| f85b9dc3ff | |||
| 3c38a87271 | |||
| 12e22c258a | |||
| cd5ce4b1d9 | |||
| 6d245ab4da | |||
| 3ca73d432b | |||
| e6c8fee3e5 | |||
| 2dbaf6b029 | |||
| c14b572038 | |||
| c0e951fc8e | |||
| c71ecc8f76 | |||
| a1c7ea209b | |||
| fe24dd03c5 | |||
| 66bff5c342 | |||
| 55b11f792a | |||
| e8fbba236d | |||
| 97455da6ca | |||
| 2a51a1a842 | |||
| 724857fc73 | |||
| 28a577df5e | |||
| 64f370fd0e | |||
| 00ac889139 | |||
| 9ab2fc30ff | |||
| 3238fbc517 | |||
| c03c40d287 | |||
| b06e3e6266 | |||
| 7d669cb5c3 | |||
| 81b3657528 | |||
| 6f4d7d9066 | |||
| 00684f4202 | |||
| c4a39171f2 | |||
| 10e0e59c3f | |||
| e3a8c6fb8a | |||
| 7dc313c567 | |||
| e3f542c8b7 | |||
| ba6428d7f1 | |||
| 29dc967d29 | |||
| 8fd025e003 | |||
| ec8df01f85 | |||
| 79260ad737 | |||
| 0426ce7f01 | |||
| f76b8d5d9c | |||
| 8c11833fb1 | |||
| 45727b3d3b | |||
| 686592437a | |||
| 1da7d0627a | |||
| bc62eb0d6f | |||
| 091ebed8e4 | |||
| 80183bc986 | |||
| 250a7d4eb7 | |||
| 256f8c08e2 | |||
| 4ab3244f8f | |||
| cb546e4a72 | |||
| eeae904867 | |||
| 414a7ad98f | |||
| 58e006cfd9 | |||
| 476dbef7dc | |||
| b53751344b | |||
| 01c0f8b64c | |||
| 44add57109 | |||
| afacf00c9f | |||
| baf69da27f | |||
| 01ae91c939 | |||
| f19f0a33b2 | |||
| e5fc735e13 | |||
| d2ec9eabde | |||
| 9128039c38 | |||
| dd6f41f768 | |||
| 1a73b22810 | |||
| c1d413f31a | |||
| 8316119293 | |||
| 74abaa9d7c | |||
| 091505d17c | |||
| aee275e2b0 | |||
| ef86bf2660 | |||
| 0139c46fb4 | |||
| a0a7fcdbb9 | |||
| 186dd94b61 | |||
| ad721a1ac6 | |||
| 87441f269d | |||
| a0943f8226 | |||
| 6741e8ca0d | |||
| c14f179e39 | |||
| 3bfef73a46 | |||
| 7e790c3fb7 | |||
| 228db8541c | |||
| a729a6935f | |||
| 6c0e39e287 | |||
| 22574a2270 | |||
| 1d880cb881 | |||
| 3d076704f9 | |||
| 1b0f043506 | |||
| c383b11648 | |||
| fed564e7c0 | |||
| 9e216f5ae4 | |||
| 0f0232491b | |||
| 35fb59e64e | |||
| ee3fff1dab | |||
| 3bc75d2b1c | |||
| 094426debb | |||
| 313198ba63 | |||
| e677d58f91 | |||
| bc60aea839 | |||
| b5cd951bd2 | |||
| fd818050bd | |||
| 0313597b10 | |||
| 2c867673bf | |||
| 39ebf0976c | |||
| 1ecc449f3e | |||
| b8990782cc | |||
| 8ba7add541 | |||
| a3664e7117 | |||
| 64ed2175a5 | |||
| 447ae54b93 | |||
| 0067b0ff19 |
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
client/node_modules
|
||||
server/node_modules
|
||||
shared/node_modules
|
||||
client/dist
|
||||
shared/dist
|
||||
data
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const badTitles = [
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
|
||||
@@ -5,6 +5,13 @@ name: Build & Push Docker Image (Prerelease)
|
||||
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
|
||||
# release it refuses a commit whose ci-ok is not green. docker.yml with
|
||||
# bump=auto later finalizes the line once main carries it.
|
||||
#
|
||||
# A prerelease asks the TREK services issuer for a registration dialect of its
|
||||
# own -pre.N version (registration-dialect.yml, as the stable release does)
|
||||
# and builds it into the image; nothing of it is committed, dev keeps the
|
||||
# module of the last stable release, which it gets when main is merged back
|
||||
# into dev. Without one the image carries dev's module, with a warning when
|
||||
# that is the original one or old.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -89,10 +96,23 @@ jobs:
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION"
|
||||
|
||||
# The registration dialect of this prerelease, fetched and checked in a job
|
||||
# without write access. It never fails the build.
|
||||
dialect:
|
||||
needs: version-bump
|
||||
uses: ./.github/workflows/registration-dialect.yml
|
||||
with:
|
||||
version: ${{ needs.version-bump.outputs.version }}
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
secrets:
|
||||
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
needs: [version-bump, dialect]
|
||||
# Whatever became of the dialect job: without its artifact the image carries dev's module.
|
||||
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -111,9 +131,51 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
|
||||
- name: Take over the prerelease's dialect
|
||||
id: take
|
||||
uses: actions/download-artifact@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: registration-dialect
|
||||
path: ${{ runner.temp }}/dialect
|
||||
|
||||
# Into the build context only; the commit stays as it is. Without a
|
||||
# dialect of its own the image speaks dev's module, which the issuer
|
||||
# takes for new installations for 180 days after it was made (longer
|
||||
# while it is the newest stable release's). That is warned about, never
|
||||
# stopped on, and a date that cannot be read only warns as well.
|
||||
- name: Place the dialect
|
||||
env:
|
||||
VERSION: ${{ needs.version-bump.outputs.version }}
|
||||
TAKEN: ${{ steps.take.outcome }}
|
||||
REPORT: ${{ matrix.platform == 'linux/amd64' }}
|
||||
run: |
|
||||
set -u
|
||||
target=server/src/nest/service-identity/registration-dialect.ts
|
||||
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
|
||||
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
|
||||
cp "$fetched" "$target"
|
||||
echo "$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target") of its own."
|
||||
exit 0
|
||||
fi
|
||||
[ "$REPORT" = true ] || exit 0
|
||||
id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")"
|
||||
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
|
||||
if [ "$id" = 0 ]; then
|
||||
message="$VERSION got no dialect of its own and registers in the original one, which the issuer accepts only for a transition period."
|
||||
elif made="$(date -u -d "$created" +%s 2>/dev/null)"; then
|
||||
age=$(( ($(date -u +%s) - made) / 86400 ))
|
||||
message="$VERSION got no dialect of its own and registers in dev's dialect $id from $created, $age days old."
|
||||
[ "$age" -gt 150 ] || { echo "$message"; exit 0; }
|
||||
else
|
||||
message="$VERSION got no dialect of its own, and the date of dev's dialect cannot be read from $target."
|
||||
fi
|
||||
echo "::warning::$message"
|
||||
printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -156,6 +218,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
needs: [version-bump, build]
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -173,7 +236,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
+182
-23
@@ -2,17 +2,29 @@ name: Build & Push Docker Image
|
||||
|
||||
# The stable release. Dispatched by hand on main, it:
|
||||
# 1. refuses to run unless ci-ok passed on the main commit it releases,
|
||||
# 2. works out the version from the tags and the bump input, commits the
|
||||
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
|
||||
# 3. builds both platforms from exactly that bump commit (never from
|
||||
# 2. works out the version from the tags and the bump input,
|
||||
# 3. asks the TREK services issuer for the registration dialect of that
|
||||
# version and checks it, in a job without write access
|
||||
# (registration-dialect.yml); without one the release ships the dialect
|
||||
# already on main, but only while that one still has 90 days to run,
|
||||
# 4. commits the bump to package.json, the lockfile, Chart.yaml and the
|
||||
# dialect, tags it and pushes,
|
||||
# 5. builds both platforms from exactly that bump commit (never from
|
||||
# whatever main points to by then) and publishes :VERSION,
|
||||
# :MAJOR.MINOR, :MAJOR and :latest,
|
||||
# 4. publishes the Helm chart from the same commit.
|
||||
# 6. publishes the Helm chart from the same commit.
|
||||
#
|
||||
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
|
||||
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
|
||||
# Without a prerelease in flight there is no default: the run stops until
|
||||
# someone picks patch, minor or major.
|
||||
#
|
||||
# Each stable release registers new installations in a dialect of its own,
|
||||
# which the issuer accepts for 180 days, and for as long as it is the newest
|
||||
# release's; renewing a key needs none. dev gets the module when main is merged
|
||||
# back into dev after the release; a prerelease asks the issuer for one of its
|
||||
# own (docker-dev.yml). The issuer's side, and what to set up before the first
|
||||
# release with a dialect, is in the operations guide of the TREK services.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -26,6 +38,14 @@ on:
|
||||
description: "Type MAJOR (all caps) to confirm a major release"
|
||||
type: string
|
||||
default: ''
|
||||
ci_from:
|
||||
description: 'Take ci-ok from this earlier commit instead of the one on main, when main differs from it only under .github/ (a fix to a release workflow needs no second test run)'
|
||||
type: string
|
||||
default: ''
|
||||
allow_stale_dialect:
|
||||
description: 'Ship without a dialect of its own even if the one on main is the original one or has under 90 days left'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -36,13 +56,16 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
version:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
sha: ${{ steps.bump.outputs.SHA }}
|
||||
base: ${{ steps.base.outputs.SHA }}
|
||||
steps:
|
||||
- name: Release from main only
|
||||
if: github.ref != 'refs/heads/main'
|
||||
@@ -50,17 +73,17 @@ jobs:
|
||||
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
|
||||
exit 1
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
# The bump job checks out exactly this commit, so what ci-ok was checked
|
||||
# on is what gets released, however long the dialect takes.
|
||||
- name: Note the commit being released
|
||||
id: base
|
||||
run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The dev push, the main push and any manual run each leave a ci-ok on
|
||||
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
|
||||
@@ -68,6 +91,7 @@ jobs:
|
||||
# finished last to be green, so a later green run supersedes a flaky or
|
||||
# cancelled one. It prints the link of every run it counted.
|
||||
- name: Require a green ci-ok on the commit being released
|
||||
if: inputs.ci_from == ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -77,14 +101,43 @@ jobs:
|
||||
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
||||
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
||||
|
||||
- name: Determine bump type and update version
|
||||
# ci_from: main carries only workflow changes on top of a commit that
|
||||
# already passed. That commit must be an ancestor of main, everything
|
||||
# outside .github/ must be identical, and one of its ci-ok runs must be
|
||||
# green (its dev push, typically).
|
||||
- name: Require a green ci-ok on the commit main's code comes from
|
||||
if: inputs.ci_from != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_FROM: ${{ inputs.ci_from }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
from="$(git rev-parse --verify "$CI_FROM^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$from" HEAD; then
|
||||
echo "::error::$from is not an ancestor of main."
|
||||
exit 1
|
||||
fi
|
||||
outside="$(git diff --name-only "$from" HEAD -- . ':(exclude).github/**')"
|
||||
if [ -n "$outside" ]; then
|
||||
echo "::error::main differs from $from outside .github/:"
|
||||
echo "$outside"
|
||||
exit 1
|
||||
fi
|
||||
green="$(gh api "repos/$GITHUB_REPOSITORY/commits/$from/check-runs?check_name=ci-ok&filter=all&per_page=100" \
|
||||
--jq '[.check_runs[] | select(.status == "completed" and .conclusion == "success")] | length')"
|
||||
if [ "$green" -lt 1 ]; then
|
||||
echo "::error::$from has no green ci-ok."
|
||||
exit 1
|
||||
fi
|
||||
echo "main differs from $from only under .github/, and $from has $green green ci-ok run(s)."
|
||||
|
||||
- name: Determine bump type and version
|
||||
id: bump
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
|
||||
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --tags
|
||||
|
||||
# Derive version from git tags, no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
@@ -143,6 +196,107 @@ jobs:
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION ($BUMP)"
|
||||
|
||||
# The registration dialect of this version, fetched and checked in a job
|
||||
# without write access. It never fails; without an artifact from it the
|
||||
# bump decides below whether the dialect on main may ship.
|
||||
dialect:
|
||||
needs: version
|
||||
uses: ./.github/workflows/registration-dialect.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
ref: ${{ needs.version.outputs.base }}
|
||||
secrets:
|
||||
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
|
||||
|
||||
# Runs whatever became of the dialect job, a timeout or a lost runner
|
||||
# included: no artifact means the dialect on main, if it may still ship.
|
||||
version-bump:
|
||||
needs: [version, dialect]
|
||||
if: ${{ !cancelled() && needs.version.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.SHA }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.version.outputs.base }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Take over the checked dialect
|
||||
id: take
|
||||
uses: actions/download-artifact@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: registration-dialect
|
||||
path: ${{ runner.temp }}/dialect
|
||||
|
||||
# Without a dialect of its own the release ships the one on main, but
|
||||
# only while the issuer takes new registrations in it for 90 more days:
|
||||
# dialect 0 closes on the issuer's transition date, and a dialect with
|
||||
# little left would shut out new installations of this release soon after
|
||||
# it is out. Then this stops before anything is committed, unless
|
||||
# allow_stale_dialect says to ship anyway. The issuer keeps the newest
|
||||
# release's dialect open past its date, so the date here is the least it
|
||||
# has, not the most.
|
||||
- name: Place the dialect
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
TAKEN: ${{ steps.take.outcome }}
|
||||
ALLOW_STALE: ${{ inputs.allow_stale_dialect }}
|
||||
run: |
|
||||
set -u
|
||||
target=server/src/nest/service-identity/registration-dialect.ts
|
||||
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
|
||||
dialect_id() { sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$1"; }
|
||||
report() {
|
||||
echo "$2$1"
|
||||
printf '### Registration dialect\n%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
|
||||
cp "$fetched" "$target"
|
||||
report "$VERSION registers in dialect $(dialect_id "$target"), written by the issuer for it." ""
|
||||
exit 0
|
||||
fi
|
||||
id="$(dialect_id "$target")"
|
||||
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
|
||||
left=""
|
||||
if [ -n "$id" ] && [ "$id" != 0 ] && made="$(date -u -d "$created" +%s 2>/dev/null)"; then
|
||||
left=$(( (made + 180 * 86400 - $(date -u +%s)) / 86400 ))
|
||||
fi
|
||||
if [ -n "$left" ] && [ "$left" -ge 90 ]; then
|
||||
report "$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer takes for new installations for $left more days at least. Its operator can write one by hand for the next patch." "::warning::"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$id" = 0 ]; then
|
||||
what="the original registration, which the issuer accepts only until the end of its transition period"
|
||||
elif [ -n "$left" ]; then
|
||||
what="dialect $id from $created, which has $left days left"
|
||||
else
|
||||
what="a dialect whose id or date cannot be read from $target"
|
||||
fi
|
||||
if [ "$ALLOW_STALE" = true ]; then
|
||||
report "$VERSION ships without a dialect of its own and registers in $what, as allow_stale_dialect asked." "::warning::"
|
||||
exit 0
|
||||
fi
|
||||
report "$VERSION got no dialect of its own and would register in $what. Nothing was committed or pushed. Make sure the issuer runs with its dialect seed and release token and that TREK_DIALECT_TOKEN is set, then run the release again, or tick allow_stale_dialect to ship anyway." "::error::"
|
||||
exit 1
|
||||
|
||||
- name: Update the version, commit, tag and push
|
||||
id: commit
|
||||
env:
|
||||
NEW_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||
@@ -153,7 +307,8 @@ jobs:
|
||||
# landed on main in the meantime.
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json \
|
||||
charts/trek/Chart.yaml server/src/nest/service-identity/registration-dialect.ts
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git push origin HEAD:main --follow-tags
|
||||
@@ -164,7 +319,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
needs: [version, version-bump]
|
||||
# version-bump may run after a failed dialect job; that alone must not skip what follows it.
|
||||
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -185,7 +342,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -205,7 +362,7 @@ jobs:
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
APP_VERSION=${{ needs.version.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
|
||||
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
|
||||
@@ -227,7 +384,8 @@ jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
needs: [version-bump, build]
|
||||
needs: [version, version-bump, build]
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -243,7 +401,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -252,7 +410,7 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
|
||||
# the conservative pin for an install that wants to wait out a
|
||||
@@ -292,6 +450,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Flag or clear wrong base branch
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
name: Registration dialect
|
||||
|
||||
# Asks the TREK services issuer for the registration dialect of one version and
|
||||
# checks it, for docker.yml (a stable release commits it with the version bump)
|
||||
# and docker-dev.yml (a prerelease only builds it into its image).
|
||||
#
|
||||
# The issuer's answer runs here, in a job that holds no write token and keeps
|
||||
# no git credentials; the token for the issuer is in the environment of the
|
||||
# one step that fetches. The answer is held to what a dialect is
|
||||
# (scripts/ci/check-registration-dialect.mjs), then type-checked and linted
|
||||
# the way TREK's CI would see it once it is on main and dev. Only a module that
|
||||
# passes all of it goes on, as the artifact `registration-dialect`; without
|
||||
# one the caller decides what ships. This workflow never fails its caller.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version the dialect is for, X.Y.Z or X.Y.Z-pre.N'
|
||||
type: string
|
||||
required: true
|
||||
ref:
|
||||
description: 'The commit whose checker, types and lint config the dialect is checked with'
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
TREK_DIALECT_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dialect:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
# A failed or timed-out run leaves no artifact, which the caller already
|
||||
# handles; it must not fail the release around it.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# The only step that sees the token. The URL is fixed; the answer is
|
||||
# bounded in time and size and goes to a file nothing runs yet.
|
||||
- name: Fetch the dialect from the issuer
|
||||
id: fetch
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
out="$RUNNER_TEMP/registration-dialect.ts"
|
||||
if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then
|
||||
echo "::warning::TREK_DIALECT_TOKEN is not set; the issuer was not asked for a dialect for $VERSION."
|
||||
exit 0
|
||||
fi
|
||||
status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \
|
||||
--max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \
|
||||
--data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \
|
||||
|| status=000
|
||||
if [ "$status" != 200 ] && [ "$status" != 201 ]; then
|
||||
echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status)."
|
||||
rm -f "$out"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$(wc -c < "$out")" -gt 262144 ]; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect."
|
||||
rm -f "$out"
|
||||
exit 0
|
||||
fi
|
||||
echo "file=$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A module that computes for long is refused by the clock as well.
|
||||
- name: Check the dialect
|
||||
id: check
|
||||
if: steps.fetch.outputs.file != ''
|
||||
timeout-minutes: 2
|
||||
env:
|
||||
FILE: ${{ steps.fetch.outputs.file }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION failed its check."
|
||||
exit 0
|
||||
fi
|
||||
echo "What changes against the dialect in the tree:"
|
||||
git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install the toolchain
|
||||
if: steps.check.outputs.ok == 'true'
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
npm run build --workspace=shared
|
||||
|
||||
# The bump commit skips CI, so this is where the module meets the strict
|
||||
# config (lint:strict lets a new file have no strict errors) and ESLint
|
||||
# with --max-warnings 0 (lint:warnings starts a new file at none) before
|
||||
# it lands on main.
|
||||
- name: Type-check and lint the dialect
|
||||
id: types
|
||||
if: steps.check.outputs.ok == 'true'
|
||||
timeout-minutes: 5
|
||||
working-directory: server
|
||||
env:
|
||||
FILE: ${{ steps.fetch.outputs.file }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
cp "$FILE" src/nest/service-identity/registration-dialect.ts
|
||||
# TypeScript 6 includes no @types by default and the dialect imports nothing
|
||||
# from node, so checked on its own it would not know Buffer: name node here.
|
||||
printf '%s\n' '{"extends": "./tsconfig.strict.json", "compilerOptions": {"types": ["node"]}, "include": [],' \
|
||||
'"files": ["src/nest/service-identity/registration-dialect.ts"]}' > tsconfig.dialect-check.json
|
||||
if ! npx tsc -p tsconfig.dialect-check.json --noEmit --pretty false; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION does not type-check under the strict config."
|
||||
exit 0
|
||||
fi
|
||||
if ! npx eslint --max-warnings 0 src/nest/service-identity/registration-dialect.ts; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION does not pass ESLint."
|
||||
exit 0
|
||||
fi
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Hand the dialect on
|
||||
if: steps.types.outputs.ok == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: registration-dialect
|
||||
path: ${{ steps.fetch.outputs.file }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -74,12 +74,12 @@ jobs:
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
|
||||
- uses: docker/scout-action@e7e2a72bf1485d10a58f30696b6c513b261ce4fa # v1.29.0
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
path: plugin-sdk/coverage/
|
||||
|
||||
- name: Scan
|
||||
uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0
|
||||
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
|
||||
env:
|
||||
# SonarQube Cloud is the scan action's default host, so no
|
||||
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
|
||||
@@ -1202,7 +1202,7 @@ jobs:
|
||||
docker stop -t 30 trek-ci >/dev/null
|
||||
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
|
||||
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
|
||||
docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
|
||||
docker logs trek-ci 2>&1 | grep 'SIGTERM received' >/dev/null \
|
||||
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
|
||||
|
||||
- name: Container log
|
||||
@@ -1263,11 +1263,39 @@ jobs:
|
||||
--set 'extraEnv[0].name=SESSION_DURATION' \
|
||||
--set 'extraEnv[0].value=7d' \
|
||||
--set 'extraEnvFrom[0].secretRef.name=trek-smtp' \
|
||||
--set 'extraVolumes[0].name=kitinerary-extractors' \
|
||||
--set 'extraVolumes[0].configMap.name=trek-kitinerary-extractors' \
|
||||
--set 'extraVolumeMounts[0].name=kitinerary-extractors' \
|
||||
--set 'extraVolumeMounts[0].mountPath=/usr/local/share/kitinerary/extractors' \
|
||||
--set 'extraVolumeMounts[0].readOnly=true' \
|
||||
--set probes.liveness=null \
|
||||
> "$out/variants.yaml"
|
||||
helm template trek charts/trek \
|
||||
--set httpRoute.enabled=true \
|
||||
--set 'httpRoute.parentRefs[0].name=gateway' \
|
||||
> "$out/http-route.yaml"
|
||||
helm template trek charts/trek \
|
||||
--set podSecurityContext.runAsUser=1000 \
|
||||
--set podSecurityContext.runAsGroup=1000 \
|
||||
--set podSecurityContext.runAsNonRoot=true \
|
||||
--set podSecurityContext.seccompProfile.type=RuntimeDefault \
|
||||
--set securityContext.allowPrivilegeEscalation=false \
|
||||
--set 'securityContext.capabilities.drop[0]=ALL' \
|
||||
> "$out/non-root.yaml"
|
||||
helm template trek charts/trek \
|
||||
--set ingress.enabled=true \
|
||||
--set fullnameOverride=custom-trek \
|
||||
--set service.port=8080 \
|
||||
--set-json 'httpRoute={"enabled":true,"parentRefs":[{"name":"gateway","namespace":"gateway-system","sectionName":"https"}],"hostnames":["trek.example.com"],"paths":["/","/api"],"annotations":{"example.com/note":"custom route"},"timeouts":{"request":"0s","backendRequest":"0s"}}' \
|
||||
> "$out/http-route-variants.yaml"
|
||||
|
||||
- name: Check the manifests against the Kubernetes schemas
|
||||
run: kubeconform -strict -summary "$RUNNER_TEMP"/rendered/*.yaml
|
||||
# The pinned Gateway API schema adds HTTPRoute validation without skipping unknown resources.
|
||||
run: >-
|
||||
kubeconform -strict -summary
|
||||
-schema-location default
|
||||
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/041baf4c1d3740ca21461cab765e7432ecd073ef/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
|
||||
"$RUNNER_TEMP"/rendered/*.yaml
|
||||
|
||||
- name: Check every compose file
|
||||
run: |
|
||||
|
||||
+2
-1
@@ -91,7 +91,8 @@ ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# transport search) and atlas/*.geojson.gz + atlas/admin1/*.geojson.gz (Atlas
|
||||
# country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
|
||||
|
||||
@@ -5,8 +5,9 @@ TREK bundles and uses third-party data that requires attribution.
|
||||
## geoBoundaries — country & sub-national boundaries
|
||||
|
||||
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
||||
`server/assets/atlas/admin1.geojson.gz` and generated by
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz`,
|
||||
`server/assets/atlas/admin1.geojson.gz` and `server/assets/atlas/admin1/<ISO_A2>.geojson.gz`
|
||||
(the same regions, one file per country) and generated by
|
||||
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
||||
|
||||
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
||||
|
||||
@@ -6,14 +6,9 @@
|
||||
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<br />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
|
||||
</picture>
|
||||
### Every part of your trip. One app you own.
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
TREK has grown from a travel planner into a whole ecosystem for trips: plan together in real time on a live map, build hikes, tours and road trips, keep bookings and shared costs in order, pack, write the journal, lay out the photo book and hand the busy work to the built-in AI. Self-hosted, on the web, on your phone and offline.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -32,11 +27,16 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-AGPL_v3-6B7280?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=for-the-badge&label=Release&color=6B7280" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=for-the-badge&label=Docker%20pulls&color=6B7280" /></a>
|
||||
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=for-the-badge&label=Stars&color=6B7280" /></a>
|
||||
|
||||
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -200,7 +200,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
|
||||
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
|
||||
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **28 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), he (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
|
||||
|
||||
</td>
|
||||
@@ -214,7 +214,39 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## Powered by [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek)
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
|
||||
<img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="60" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
**Every TREK installation in the world searches through servers that [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) sponsors.**
|
||||
|
||||
The place index with over 88 million places, the address search in every TREK language and the planet-wide routing all run on [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) in Frankfurt, Germany, free of charge for everyone who self-hosts TREK. Without this sponsorship, these services would not exist in this form.
|
||||
|
||||
Thank you, [dataforest](https://cloud.dataforest.net/en?mtm_campaign=trek), for backing open source this generously. 💚
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
|
||||
|
||||
Looking for a server for your own TREK? [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) is where TREK's own services live.
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
@@ -244,6 +276,9 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
> [!TIP]
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a>
|
||||
@@ -398,16 +433,6 @@ Caddy handles TLS and WebSockets automatically.
|
||||
Every variable, its default and what it does: see
|
||||
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Data sources
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
|
||||
+6
-3
@@ -5,9 +5,10 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
## Features
|
||||
- Deploys the TREK container
|
||||
- Exposes port 3000 via Service
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`, plus extra volumes and mounts
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Optional Gateway API HTTPRoute support
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
@@ -27,7 +28,7 @@ helm install trek trek/trek
|
||||
Or install directly from the local chart:
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
helm install trek ./charts/trek \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=yourdomain.com
|
||||
```
|
||||
@@ -47,7 +48,7 @@ See `values.yaml` for more options.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- If exposing TREK through an Ingress or HTTPRoute, set `env.ALLOWED_ORIGINS` and `env.APP_URL` to the public URL. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
@@ -55,3 +56,5 @@ See `values.yaml` for more options.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
|
||||
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.0.0
|
||||
version: 5.1.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.0.0"
|
||||
appVersion: "5.1.0"
|
||||
|
||||
@@ -31,3 +31,12 @@
|
||||
The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
|
||||
when the pod restarts. Use only for testing.
|
||||
{{- if .Values.httpRoute.enabled }}
|
||||
|
||||
6. HTTPRoute:
|
||||
- The chart creates an HTTPRoute v1 to the existing TREK Service. The Gateway, GatewayClass,
|
||||
listeners, TLS certificates and allowedRoutes are managed by your cluster operator.
|
||||
- Ingress and HTTPRoute may be enabled together.
|
||||
- Timeout fields are Standard Channel in Gateway API v1.2, but controller support varies. Omit
|
||||
them to use controller defaults; `0s` disables the corresponding timeout where supported.
|
||||
{{- end }}
|
||||
|
||||
@@ -112,6 +112,21 @@ data:
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_PLACES_CONTRIBUTE }}
|
||||
TREK_PLACES_CONTRIBUTE: {{ .Values.env.TREK_PLACES_CONTRIBUTE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_SERVICES }}
|
||||
TREK_SERVICES: {{ .Values.env.TREK_SERVICES | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_SERVICES_HOSTS }}
|
||||
TREK_SERVICES_HOSTS: {{ .Values.env.TREK_SERVICES_HOSTS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_PHOTON_URL }}
|
||||
TREK_PHOTON_URL: {{ .Values.env.TREK_PHOTON_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_VALHALLA_URL }}
|
||||
TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
|
||||
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -29,12 +29,18 @@ spec:
|
||||
- name: {{ .name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- with .Values.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
@@ -85,6 +91,12 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: PLACES_API_KEY
|
||||
optional: true
|
||||
- name: TREK_SERVICES_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: TREK_SERVICES_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -135,6 +147,9 @@ spec:
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with .Values.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).startup }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
@@ -162,3 +177,6 @@ spec:
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.extraVolumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{{- if .Values.httpRoute.enabled -}}
|
||||
{{- if not .Values.httpRoute.parentRefs }}
|
||||
{{- fail "httpRoute.parentRefs must include at least one parent" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.httpRoute.paths }}
|
||||
{{- fail "httpRoute.paths must include at least one path" }}
|
||||
{{- end }}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.httpRoute.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml .Values.httpRoute.parentRefs | nindent 4 }}
|
||||
{{- with .Values.httpRoute.hostnames }}
|
||||
hostnames:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range $path := .Values.httpRoute.paths }}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: {{ $path | quote }}
|
||||
backendRefs:
|
||||
- name: {{ include "trek.fullname" $ }}
|
||||
port: {{ $.Values.service.port }}
|
||||
{{- with $.Values.httpRoute.timeouts }}
|
||||
timeouts:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -23,6 +23,9 @@ data:
|
||||
{{- if .Values.secretEnv.PLACES_API_KEY }}
|
||||
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
|
||||
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
@@ -77,6 +80,9 @@ stringData:
|
||||
{{- if .Values.secretEnv.PLACES_API_KEY }}
|
||||
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
|
||||
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
|
||||
{{- end }}
|
||||
|
||||
+84
-3
@@ -10,6 +10,34 @@ image:
|
||||
# processes on one host, which network filesystems do not provide.
|
||||
updateStrategy: Recreate
|
||||
|
||||
# Pod-level security context. fsGroup gives the node user (uid/gid 1000) group access
|
||||
# to the data and uploads volumes; OnRootMismatch lets the kubelet skip its recursive
|
||||
# permission walk once the volume root already matches, so a big PVC does not hold up
|
||||
# every start.
|
||||
#
|
||||
# By default the container starts as root, hands any files that are not node's yet to
|
||||
# node, and then drops to node itself. To start it as node straight away (needed under
|
||||
# the "restricted" Pod Security Standard), add the user here and lock the container
|
||||
# down below; the image then skips the ownership step and the privilege drop:
|
||||
# podSecurityContext:
|
||||
# fsGroup: 1000
|
||||
# fsGroupChangePolicy: OnRootMismatch
|
||||
# runAsUser: 1000
|
||||
# runAsGroup: 1000
|
||||
# runAsNonRoot: true
|
||||
# seccompProfile:
|
||||
# type: RuntimeDefault
|
||||
# securityContext:
|
||||
# allowPrivilegeEscalation: false
|
||||
# capabilities:
|
||||
# drop: [ALL]
|
||||
podSecurityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
# Security context of the trek container itself. Empty by default.
|
||||
securityContext: {}
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
@@ -36,7 +64,7 @@ env:
|
||||
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# NOTE: If using ingress or HTTPRoute, ensure env.ALLOWED_ORIGINS matches the public domains for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
@@ -92,7 +120,8 @@ env:
|
||||
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
|
||||
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
|
||||
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
|
||||
# way. Defaults to https://nominatim.openstreetmap.org.
|
||||
# way, and search and reverse lookups no longer go to the TREK services' Photon. Defaults to
|
||||
# https://nominatim.openstreetmap.org.
|
||||
# OVERPASS_URL: ""
|
||||
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||
@@ -112,6 +141,24 @@ env:
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
# TREK_PLACES_CONTRIBUTE: "off"
|
||||
# Whether an admin may let this installation send the place details its users typed in (website, phone, e-mail,
|
||||
# opening hours, description, picture) to the TREK Places API. "admin" leaves the choice to the admin panel, where
|
||||
# it stays off until an admin agrees; "off" takes it away. Defaults to admin.
|
||||
# TREK_SERVICES: "off"
|
||||
# Places search identifies this installation with a key it registers by itself on first use and keeps in the
|
||||
# database. "off" sends no key; searches then use the shared pool. A key issued to you goes under
|
||||
# secretEnv.TREK_SERVICES_KEY. Defaults to auto.
|
||||
# TREK_SERVICES_HOSTS: ""
|
||||
# Comma-separated hosts that receive the installation key over https. Defaults to
|
||||
# places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com.
|
||||
# TREK_PHOTON_URL: ""
|
||||
# The address search (Photon) of the TREK services. With a key, search and reverse lookups ask it before
|
||||
# Nominatim and fall back to Nominatim when it does not answer. Never asked when NOMINATIM_URL is set.
|
||||
# Defaults to https://photon.liketrek.com.
|
||||
# TREK_VALHALLA_URL: ""
|
||||
# The Valhalla of the TREK services, which this server relays routing to unless the admin picks another
|
||||
# routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com.
|
||||
# AMAP_API_BASE: ""
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
@@ -164,6 +211,9 @@ secretEnv:
|
||||
# with signing enabled, because such a key rejects every unsigned request. Leave empty
|
||||
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
|
||||
AMAP_API_SECRET: ""
|
||||
# Optional key issued for this installation by the TREK services. Wins over the key TREK
|
||||
# registers by itself and turns that registration off. Leave empty to let TREK register.
|
||||
TREK_SERVICES_KEY: ""
|
||||
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
|
||||
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
|
||||
# push off with an error in the log until it is fixed, and every device stays registered.
|
||||
@@ -179,7 +229,7 @@ generateEncryptionKey: false
|
||||
# If set, the chart renders no Secret of its own and instead reads every secret env
|
||||
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
|
||||
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
|
||||
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
|
||||
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET, TREK_SERVICES_KEY,
|
||||
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
|
||||
# Missing keys are simply left unset (they are optional), so only provide the ones your
|
||||
# setup needs.
|
||||
@@ -242,6 +292,20 @@ extraEnv: []
|
||||
# name: trek-smtp
|
||||
extraEnvFrom: []
|
||||
|
||||
# Further volumes for the pod. The chart does not create the objects they name.
|
||||
# extraVolumes:
|
||||
# - name: kitinerary-extractors
|
||||
# configMap:
|
||||
# name: trek-kitinerary-extractors
|
||||
extraVolumes: []
|
||||
# Where the trek container mounts them. For example, KItinerary
|
||||
# loads extractors for the booking import from this path:
|
||||
# extraVolumeMounts:
|
||||
# - name: kitinerary-extractors
|
||||
# mountPath: /usr/local/share/kitinerary/extractors
|
||||
# readOnly: true
|
||||
extraVolumeMounts: []
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
@@ -262,3 +326,20 @@ ingress:
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
|
||||
httpRoute:
|
||||
enabled: false
|
||||
annotations: {}
|
||||
parentRefs: []
|
||||
# - name: gateway
|
||||
# namespace: gateway-system
|
||||
# sectionName: https
|
||||
hostnames: []
|
||||
# - trek.example.com
|
||||
paths:
|
||||
- /
|
||||
# Timeout fields are Standard Channel in Gateway API v1.2; controller support varies.
|
||||
# Leave empty for controller defaults. Use "0s" to disable the corresponding timeout.
|
||||
timeouts: {}
|
||||
# request: "0s"
|
||||
# backendRequest: "0s"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test, type Locator } from '@playwright/test';
|
||||
|
||||
// A visible DOM node can still be clipped by a panel. Check the whole bar
|
||||
// before interacting: Playwright's automatic scroll-into-view could mask it.
|
||||
async function expectBarInsidePanel(bar: Locator, panel: Locator) {
|
||||
await expect(bar).toBeInViewport({ ratio: 1 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await bar.boundingBox();
|
||||
const parent = await panel.boundingBox();
|
||||
return (
|
||||
!!bounds &&
|
||||
!!parent &&
|
||||
bounds.y >= parent.y &&
|
||||
bounds.y + bounds.height <= parent.y + parent.height + 1 &&
|
||||
bounds.x >= parent.x &&
|
||||
bounds.x + bounds.width <= parent.x + parent.width + 1
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
for (const layout of [
|
||||
{ name: 'desktop', viewport: { width: 1440, height: 900 }, hasTouch: false },
|
||||
{ name: 'tablet', viewport: { width: 834, height: 1112 }, hasTouch: true },
|
||||
{ name: 'short desktop', viewport: { width: 1280, height: 600 }, hasTouch: false },
|
||||
]) {
|
||||
test.describe(layout.name, () => {
|
||||
test.use({
|
||||
viewport: layout.viewport,
|
||||
hasTouch: layout.hasTouch,
|
||||
});
|
||||
|
||||
for (const tours of [false, true]) {
|
||||
for (const count of [10, 27, 190]) {
|
||||
test(`${count} places, Tours ${tours ? 'on' : 'off'}: selection bar stays reachable`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const activate = (button: Locator) => (layout.hasTouch ? button.tap() : button.click());
|
||||
const addonsResponse = await page.request.get('/api/admin/addons');
|
||||
expect(addonsResponse.ok()).toBeTruthy();
|
||||
const { addons } = await addonsResponse.json();
|
||||
const originalTours = addons.find((addon: { id: string }) => addon.id === 'tours');
|
||||
expect(originalTours).toBeDefined();
|
||||
let tripId: number | undefined;
|
||||
|
||||
try {
|
||||
expect((await page.request.put('/api/admin/addons/tours', { data: { enabled: tours } })).ok()).toBeTruthy();
|
||||
const tripResponse = await page.request.post('/api/trips', {
|
||||
data: {
|
||||
title: `Selection bar ${layout.name} ${count}`,
|
||||
start_date: '2027-01-01',
|
||||
end_date: '2027-01-02',
|
||||
},
|
||||
});
|
||||
expect(tripResponse.ok()).toBeTruthy();
|
||||
tripId = (await tripResponse.json()).trip.id;
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const response = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: `Selection place ${i}`, lat: 48.85, lng: 2.35 },
|
||||
});
|
||||
expect(response.ok(), `seed place ${i}`).toBeTruthy();
|
||||
}
|
||||
|
||||
await page.goto(`/trips/${tripId}`);
|
||||
if (layout.name === 'tablet') await page.locator('button[aria-label="Places"]').click();
|
||||
const sidebar = page
|
||||
.locator('[data-touch-drag]')
|
||||
.filter({ has: page.getByRole('button', { name: 'Select', exact: true }) });
|
||||
const panel = sidebar.locator('..');
|
||||
const rows = sidebar.getByRole('option');
|
||||
await expect(rows).toHaveCount(count);
|
||||
await expect(panel.getByRole('tab', { name: 'Tours', exact: true })).toHaveCount(tours ? 1 : 0);
|
||||
await activate(sidebar.getByRole('button', { name: 'Select', exact: true }));
|
||||
const bar = sidebar.getByRole('status').locator('../..');
|
||||
await expectBarInsidePanel(bar, panel);
|
||||
|
||||
const list = sidebar.locator('.trek-stagger');
|
||||
const before = await bar.boundingBox();
|
||||
// Only the list scrolls; the bar must remain at the panel's foot.
|
||||
await list.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
if (count >= 27) await expect.poll(() => list.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
|
||||
await expectBarInsidePanel(bar, panel);
|
||||
expect((await bar.boundingBox())?.y).toBe(before?.y);
|
||||
await activate(sidebar.getByRole('button', { name: 'Select all', exact: true }));
|
||||
await expect(sidebar.getByRole('status')).toHaveText(String(count));
|
||||
await activate(sidebar.getByRole('button', { name: 'Done', exact: true }));
|
||||
await expect(sidebar.getByRole('status')).toHaveCount(0);
|
||||
|
||||
await activate(sidebar.getByRole('button', { name: 'Select', exact: true }));
|
||||
await list.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
await expectBarInsidePanel(bar, panel);
|
||||
await activate(sidebar.getByRole('button', { name: 'Select all', exact: true }));
|
||||
await activate(sidebar.getByRole('button', { name: 'Delete selected', exact: true }));
|
||||
await activate(page.getByRole('button', { name: 'Delete', exact: true }));
|
||||
await expect(rows).toHaveCount(0);
|
||||
} finally {
|
||||
if (tripId !== undefined) expect((await page.request.delete(`/api/trips/${tripId}`)).ok()).toBeTruthy();
|
||||
expect(
|
||||
(await page.request.put('/api/admin/addons/tours', { data: { enabled: !!originalTours.enabled } })).ok()
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.1.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -138,7 +138,7 @@
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.35.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg width="310" height="48" viewBox="0 0 310 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M39.9361 44.5592H37.3496V40.5946H38.7949C39.6016 40.5946 40.1393 40.0905 40.1393 38.9477V32.7663C40.1393 30.1462 41.3157 28.7345 43.4989 28.0302C41.3157 27.3243 40.1393 25.9142 40.1393 23.2941V17.1127C40.1393 15.9699 39.6016 15.4673 38.7949 15.4673H37.3496V11.5027H39.9361C42.8252 11.5027 44.8403 13.2825 44.8403 16.1059V22.5898C44.8403 24.8738 46.2184 26.0166 48.5696 26.0166V30.0484C46.2184 30.0484 44.8403 31.1912 44.8403 33.4752V39.9591C44.8403 42.7809 42.8252 44.5607 39.9361 44.5607V44.5592Z" fill="white"/>
|
||||
<path d="M0 30.0453V26.0135C2.35125 26.0135 3.72931 24.8708 3.72931 22.5867V16.1028C3.72931 13.281 5.74445 11.4996 8.63347 11.4996H11.22V15.4642H9.77472C8.96806 15.4642 8.43028 15.9684 8.43028 17.1096V23.291C8.43028 25.9112 7.25389 27.3228 5.07069 28.0271C7.25389 28.733 8.43028 30.1431 8.43028 32.7633V38.9446C8.43028 40.0874 8.96806 40.5916 9.77472 40.5916H11.22V44.5562H8.63347C5.74445 44.5562 3.72931 42.7763 3.72931 39.9545V33.4706C3.72931 31.1866 2.35125 30.0438 0 30.0438V30.0453Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2855 0.076416L32.7769 8.5678L28.8169 12.5263L24.284 7.99336L19.7511 12.5263L15.7926 8.5678L24.284 0.076416H24.2855Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2855 11.7119L34.4055 21.8319L30.4471 25.7904L24.2855 19.6289L18.124 25.7904L14.1655 21.8319L24.2855 11.7119Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2856 23.3475L36.0342 35.0961L32.0757 39.0561L24.2871 31.2675L16.4985 39.0561L12.54 35.0961L24.2887 23.3475H24.2856Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.4867 47.8958V37.3129H27.086V47.8958H21.4867Z" fill="white"/>
|
||||
<path d="M216.808 36.4329H211.635V23.9357H208.444V19.635H211.635V14.4283H216.808V19.635H220V23.9357H216.808V36.4329Z" fill="white"/>
|
||||
<path d="M192.671 31.1239H197.575C197.643 32.3003 198.516 33.0046 200.095 33.0046C201.673 33.0046 202.446 32.4332 202.446 31.5593C202.446 31.0552 202.177 30.5174 200.933 30.2485L197.843 29.5763C194.752 28.904 193.241 27.5611 193.241 24.6049C193.241 21.1108 196.231 19.0957 200.263 19.0957C204.294 19.0957 206.881 21.3797 206.881 24.7729H201.977C201.977 23.6302 201.271 22.8586 200.029 22.8586C198.853 22.8586 198.115 23.4636 198.115 24.3711C198.115 25.1442 198.753 25.5811 199.928 25.85L203.052 26.5222C205.807 27.1272 207.319 28.4029 207.319 31.0918C207.319 34.6867 204.262 36.8699 199.894 36.8699C195.527 36.8699 192.671 34.5186 192.671 31.1254V31.1239Z" fill="white"/>
|
||||
<path d="M182.351 36.8699C177.279 36.8699 173.65 33.1742 173.65 28.0011C173.65 22.8281 177.178 19.0988 182.25 19.0988C187.322 19.0988 190.816 22.5592 190.816 28.0347V29.3456L178.622 29.4128C178.924 31.6296 180.168 32.6715 182.452 32.6715C184.4 32.6715 185.778 31.9657 186.147 30.7572H190.884C190.279 34.4865 186.953 36.8714 182.351 36.8714V36.8699ZM182.284 23.2971C180.235 23.2971 179.092 24.2046 178.689 26.2197H185.677C185.677 24.4383 184.366 23.2971 182.284 23.2971Z" fill="white"/>
|
||||
<path d="M172.433 19.6351V24.5056H170.787C167.831 24.5056 166.051 25.8164 166.051 29.1424V36.433H160.878V19.6687H165.749L166.017 22.1544C166.723 20.5074 168.201 19.3998 170.453 19.3998C171.058 19.3998 171.728 19.467 172.434 19.6351H172.433Z" fill="white"/>
|
||||
<path d="M139.367 28.0011C139.367 22.6936 143.264 19.1323 148.639 19.1323C154.014 19.1323 157.878 22.6936 157.878 28.0011C157.878 33.3086 153.98 36.8362 148.639 36.8362C143.298 36.8362 139.367 33.3086 139.367 28.0011ZM144.574 27.9675C144.574 30.5204 146.219 32.2009 148.639 32.2009C151.059 32.2009 152.671 30.5219 152.671 27.9675C152.671 25.413 151.025 23.7676 148.639 23.7676C146.253 23.7676 144.574 25.4466 144.574 27.9675Z" fill="white"/>
|
||||
<path d="M126.917 19.635H129.235V18.2906C129.235 13.2856 131.855 11.1008 136.122 11.1008C136.828 11.1008 137.634 11.1681 138.44 11.3025V15.7376H136.994C134.878 15.7376 134.408 16.8468 134.408 18.2906V19.635H138.44V23.9357H134.408V36.4329H129.235V23.9357H126.917V19.635Z" fill="white"/>
|
||||
<path d="M114.606 36.8699C111.045 36.8699 108.828 34.7875 108.828 31.4951C108.828 28.4045 111.011 26.4901 115.043 26.1876L119.746 25.8179V25.549C119.746 23.9021 118.737 23.0297 116.89 23.0297C114.707 23.0297 113.531 23.87 113.531 25.381H109.23C109.23 21.5844 112.354 19.0988 117.158 19.0988C121.961 19.0988 124.783 21.8197 124.783 26.6246V36.4344H120.215L119.879 34.2176C119.341 35.7622 117.124 36.8714 114.605 36.8714L114.606 36.8699ZM116.42 33.0397C118.401 33.0397 119.779 32.065 119.779 30.1507V29.2432L117.159 29.4785C114.909 29.6801 114.102 30.1843 114.102 31.2583C114.102 32.4683 114.842 33.0382 116.42 33.0382V33.0397Z" fill="white"/>
|
||||
<path d="M104.017 36.4329H98.8441V23.9357H95.6526V19.635H98.8441V14.4283H104.017V19.635H107.209V23.9357H104.017V36.4329Z" fill="white"/>
|
||||
<path d="M83.5052 36.8699C79.944 36.8699 77.7272 34.7875 77.7272 31.4951C77.7272 28.4045 79.9104 26.4901 83.9422 26.1876L88.6462 25.8179V25.549C88.6462 23.9021 87.6379 23.0297 85.7908 23.0297C83.6076 23.0297 82.4312 23.87 82.4312 25.381H78.1305C78.1305 21.5844 81.2548 19.0988 86.0581 19.0988C90.8615 19.0988 93.6833 21.8197 93.6833 26.6246V36.4344H89.1137L88.7776 34.2176C88.2398 35.7622 86.023 36.8714 83.5037 36.8714L83.5052 36.8699ZM85.3187 33.0397C87.3002 33.0397 88.6783 32.065 88.6783 30.1507V29.2432L86.0581 29.4785C83.8077 29.6801 83.0011 30.1843 83.0011 31.2583C83.0011 32.4683 83.7405 33.0382 85.3187 33.0382V33.0397Z" fill="white"/>
|
||||
<path d="M63.9986 36.8699C59.1617 36.8699 56.0374 33.4431 56.0374 28.1356C56.0374 22.8281 59.2289 19.0988 64.3347 19.0988C66.4171 19.0988 68.4001 19.9391 69.3733 21.1812V11.1024H74.5464V36.433H69.6758L69.4069 34.2834C68.4994 35.828 66.4171 36.8699 63.9986 36.8699ZM65.2407 32.1323C67.6592 32.1323 69.3397 30.4533 69.3397 27.9324C69.3397 25.4116 67.6607 23.7326 65.2407 23.7326C62.8207 23.7326 61.2425 25.4452 61.2425 27.9324C61.2425 30.4196 62.7871 32.1323 65.2407 32.1323Z" fill="white"/>
|
||||
<path d="M228.377 28.3024C228.377 26.6 228.702 25.1104 229.351 23.8336C230.023 22.5344 230.942 21.5264 232.107 20.8096C233.294 20.0704 234.671 19.7008 236.239 19.7008C238.121 19.7008 239.7 20.2048 240.977 21.2128C242.276 22.2208 243.083 23.5648 243.396 25.2448H241.078C240.764 24.1248 240.171 23.2624 239.297 22.6576C238.423 22.0528 237.415 21.7504 236.273 21.7504C235.198 21.7504 234.235 22.0192 233.383 22.5568C232.532 23.0944 231.86 23.856 231.367 24.8416C230.897 25.8048 230.662 26.9472 230.662 28.2688C230.662 29.568 230.886 30.6992 231.334 31.6624C231.782 32.6256 232.409 33.376 233.215 33.9136C234.044 34.4512 235.007 34.72 236.105 34.72C237.337 34.72 238.401 34.4176 239.297 33.8128C240.193 33.1856 240.798 32.3456 241.111 31.2928H243.463C243.217 32.3904 242.747 33.3536 242.052 34.1824C241.358 35.0112 240.495 35.6608 239.465 36.1312C238.457 36.5792 237.337 36.8032 236.105 36.8032C234.559 36.8032 233.204 36.456 232.039 35.7616C230.897 35.0448 230.001 34.048 229.351 32.7712C228.702 31.4944 228.377 30.0048 228.377 28.3024Z" fill="white"/>
|
||||
<path d="M249.395 36.4H247.076V11.9056H249.395V36.4Z" fill="white"/>
|
||||
<path d="M253.183 28.2688C253.183 26.5888 253.542 25.1104 254.258 23.8336C254.975 22.5568 255.961 21.5488 257.215 20.8096C258.47 20.0704 259.881 19.7008 261.449 19.7008C263.039 19.7008 264.45 20.0704 265.682 20.8096C266.937 21.5488 267.922 22.5568 268.639 23.8336C269.356 25.1104 269.714 26.5888 269.714 28.2688C269.714 29.904 269.356 31.3712 268.639 32.6704C267.922 33.9472 266.937 34.9552 265.682 35.6944C264.45 36.4336 263.039 36.8032 261.449 36.8032C259.881 36.8032 258.47 36.4336 257.215 35.6944C255.961 34.9552 254.975 33.9472 254.258 32.6704C253.542 31.3712 253.183 29.904 253.183 28.2688ZM255.535 28.2352C255.535 29.512 255.782 30.6432 256.274 31.6288C256.79 32.592 257.484 33.3536 258.358 33.9136C259.254 34.4512 260.284 34.72 261.449 34.72C262.614 34.72 263.633 34.4512 264.506 33.9136C265.402 33.3536 266.097 32.592 266.59 31.6288C267.105 30.6432 267.362 29.512 267.362 28.2352C267.362 26.9808 267.105 25.872 266.59 24.9088C266.097 23.9456 265.402 23.184 264.506 22.624C263.633 22.064 262.614 21.784 261.449 21.784C260.284 21.784 259.254 22.064 258.358 22.624C257.484 23.184 256.79 23.9456 256.274 24.9088C255.782 25.872 255.535 26.9808 255.535 28.2352Z" fill="white"/>
|
||||
<path d="M284.453 20.1376H286.771V36.4H284.755L284.453 33.6448C284.005 34.5856 283.254 35.3472 282.202 35.9296C281.171 36.512 280.018 36.8032 278.741 36.8032C276.837 36.8032 275.37 36.1984 274.339 34.9888C273.331 33.7792 272.827 32.1888 272.827 30.2176V20.104H275.179V29.4784C275.179 31.4048 275.571 32.76 276.355 33.544C277.162 34.328 278.203 34.72 279.48 34.72C281.048 34.72 282.269 34.216 283.142 33.208C284.016 32.1776 284.453 30.6992 284.453 28.7728V20.1376Z" fill="white"/>
|
||||
<path d="M297.656 36.8032C296.088 36.8032 294.744 36.4336 293.624 35.6944C292.504 34.9552 291.642 33.9472 291.037 32.6704C290.432 31.3712 290.13 29.9152 290.13 28.3024C290.13 26.6672 290.432 25.2 291.037 23.9008C291.642 22.6016 292.504 21.5824 293.624 20.8432C294.767 20.0816 296.144 19.7008 297.757 19.7008C299.101 19.7008 300.288 19.992 301.319 20.5744C302.372 21.1568 303.178 22.0416 303.738 23.2288V11.9056H306.056V36.4H304.04L303.805 33.0736C303.223 34.328 302.394 35.2688 301.319 35.896C300.244 36.5008 299.023 36.8032 297.656 36.8032ZM298.06 34.6864C299.269 34.6864 300.288 34.4176 301.117 33.88C301.946 33.32 302.584 32.5584 303.032 31.5952C303.48 30.6096 303.704 29.4896 303.704 28.2352C303.704 26.9808 303.48 25.872 303.032 24.9088C302.584 23.9456 301.946 23.184 301.117 22.624C300.288 22.064 299.269 21.784 298.06 21.784C296.895 21.784 295.887 22.064 295.036 22.624C294.207 23.184 293.568 23.9456 293.12 24.9088C292.695 25.872 292.482 26.9808 292.482 28.2352C292.482 29.4896 292.695 30.6096 293.12 31.5952C293.568 32.5584 294.207 33.32 295.036 33.88C295.887 34.4176 296.895 34.6864 298.06 34.6864Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="258" height="40" viewBox="0 0 258 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.28 37.1326H31.1245V33.8288H32.3289C33.0011 33.8288 33.4493 33.4087 33.4493 32.4564V27.3052C33.4493 25.1218 34.4296 23.9454 36.2489 23.3584C34.4296 22.7703 33.4493 21.5951 33.4493 19.4117V14.2605C33.4493 13.3082 33.0011 12.8894 32.3289 12.8894H31.1245V9.58553H33.28C35.6875 9.58553 37.3668 11.0688 37.3668 13.4215V18.8248C37.3668 20.7281 38.5151 21.6804 40.4745 21.6804V25.0403C38.5151 25.0403 37.3668 25.9926 37.3668 27.896V33.2992C37.3668 35.6507 35.6875 37.1339 33.28 37.1339V37.1326Z" fill="#1E302D"/>
|
||||
<path d="M0 25.0377V21.6779C1.95937 21.6779 3.10775 20.7256 3.10775 18.8222V13.419C3.10775 11.0675 4.78704 9.58298 7.19456 9.58298H9.35V12.8868H8.1456C7.47338 12.8868 7.02523 13.3069 7.02523 14.258V19.4091C7.02523 21.5926 6.04491 22.769 4.22558 23.3559C6.04491 23.9441 7.02523 25.1192 7.02523 27.3027V32.4538C7.02523 33.4061 7.47338 33.8263 8.1456 33.8263H9.35V37.1301H7.19456C4.78704 37.1301 3.10775 35.6469 3.10775 33.2954V27.8921C3.10775 25.9888 1.95937 25.0365 0 25.0365V25.0377Z" fill="#1E302D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.2381 0.063694L27.3142 7.13985L24.0142 10.4386L20.2368 6.66115L16.4594 10.4386L13.1606 7.13985L20.2368 0.063694H20.2381Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.238 9.75996L28.6714 18.1933L25.3726 21.492L20.238 16.3574L15.1034 21.492L11.8047 18.1933L20.238 9.75996Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.2382 19.4563L30.0287 29.2468L26.7299 32.5468L20.2394 26.0562L13.7489 32.5468L10.4502 29.2468L20.2407 19.4563H20.2382Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.9058 39.9132V31.0941H22.5718V39.9132H17.9058Z" fill="#1E302D"/>
|
||||
<path d="M180.674 30.3608H176.363V19.9464H173.703V16.3625H176.363V12.0236H180.674V16.3625H183.333V19.9464H180.674V30.3608Z" fill="#1E302D"/>
|
||||
<path d="M160.559 25.9366H164.646C164.702 26.9169 165.43 27.5038 166.745 27.5038C168.06 27.5038 168.705 27.0277 168.705 26.2994C168.705 25.8793 168.481 25.4311 167.444 25.2071L164.869 24.6469C162.293 24.0867 161.034 22.9676 161.034 20.5041C161.034 17.5924 163.526 15.9131 166.885 15.9131C170.245 15.9131 172.401 17.8164 172.401 20.6441H168.314C168.314 19.6918 167.726 19.0488 166.691 19.0488C165.71 19.0488 165.095 19.553 165.095 20.3093C165.095 20.9535 165.627 21.3176 166.607 21.5417L169.21 22.1019C171.506 22.606 172.766 23.6691 172.766 25.9098C172.766 28.9056 170.218 30.7249 166.579 30.7249C162.939 30.7249 160.559 28.7655 160.559 25.9378V25.9366Z" fill="#1E302D"/>
|
||||
<path d="M151.959 30.7249C147.732 30.7249 144.708 27.6451 144.708 23.3343C144.708 19.0234 147.648 15.9156 151.875 15.9156C156.102 15.9156 159.014 18.7993 159.014 23.3623V24.4546L148.851 24.5107C149.103 26.358 150.14 27.2263 152.043 27.2263C153.666 27.2263 154.815 26.6381 155.123 25.631H159.07C158.565 28.7388 155.794 30.7262 151.959 30.7262V30.7249ZM151.903 19.4142C150.196 19.4142 149.243 20.1705 148.907 21.8498H154.731C154.731 20.3653 153.638 19.4142 151.903 19.4142Z" fill="#1E302D"/>
|
||||
<path d="M143.694 16.3625V20.4213H142.323C139.859 20.4213 138.376 21.5137 138.376 24.2853V30.3608H134.065V16.3905H138.124L138.348 18.4619C138.936 17.0895 140.167 16.1664 142.044 16.1664C142.548 16.1664 143.107 16.2225 143.695 16.3625H143.694Z" fill="#1E302D"/>
|
||||
<path d="M116.139 23.3343C116.139 18.9113 119.387 15.9436 123.866 15.9436C128.345 15.9436 131.565 18.9113 131.565 23.3343C131.565 27.7572 128.317 30.6969 123.866 30.6969C119.415 30.6969 116.139 27.7572 116.139 23.3343ZM120.478 23.3062C120.478 25.4337 121.849 26.8341 123.866 26.8341C125.883 26.8341 127.226 25.435 127.226 23.3062C127.226 21.1775 125.855 19.8064 123.866 19.8064C121.877 19.8064 120.478 21.2056 120.478 23.3062Z" fill="#1E302D"/>
|
||||
<path d="M105.764 16.3625H107.696V15.2421C107.696 11.0713 109.879 9.2507 113.435 9.2507C114.023 9.2507 114.695 9.30672 115.366 9.41875V13.1147H114.162C112.399 13.1147 112.006 14.039 112.006 15.2421V16.3625H115.366V19.9464H112.006V30.3608H107.696V19.9464H105.764V16.3625Z" fill="#1E302D"/>
|
||||
<path d="M95.505 30.7249C92.5373 30.7249 90.6899 28.9896 90.6899 26.246C90.6899 23.6704 92.5093 22.0751 95.8691 21.823L99.7879 21.5149V21.2909C99.7879 19.9184 98.9476 19.1914 97.4083 19.1914C95.589 19.1914 94.6087 19.8917 94.6087 21.1508H91.0248C91.0248 17.987 93.6284 15.9156 97.6311 15.9156C101.634 15.9156 103.985 18.1831 103.985 22.1872V30.362H100.179L99.8986 28.5147C99.4505 29.8019 97.6031 30.7262 95.5037 30.7262L95.505 30.7249ZM97.0162 27.5331C98.6675 27.5331 99.8159 26.7208 99.8159 25.1256V24.3693L97.6324 24.5654C95.7571 24.7335 95.0848 25.1536 95.0848 26.0486C95.0848 27.0569 95.7011 27.5318 97.0162 27.5318V27.5331Z" fill="#1E302D"/>
|
||||
<path d="M86.6809 30.3608H82.37V19.9464H79.7104V16.3625H82.37V12.0236H86.6809V16.3625H89.3405V19.9464H86.6809V30.3608Z" fill="#1E302D"/>
|
||||
<path d="M69.5875 30.7249C66.6198 30.7249 64.7725 28.9896 64.7725 26.246C64.7725 23.6704 66.5918 22.0751 69.9516 21.823L73.8716 21.5149V21.2909C73.8716 19.9184 73.0314 19.1914 71.4921 19.1914C69.6728 19.1914 68.6925 19.8917 68.6925 21.1508H65.1086C65.1086 17.987 67.7122 15.9156 71.7149 15.9156C75.7177 15.9156 78.0692 18.1831 78.0692 22.1872V30.362H74.2612L73.9811 28.5147C73.533 29.8019 71.6857 30.7262 69.5862 30.7262L69.5875 30.7249ZM71.0987 27.5331C72.75 27.5331 73.8984 26.7208 73.8984 25.1256V24.3693L71.7149 24.5654C69.8396 24.7335 69.1674 25.1536 69.1674 26.0486C69.1674 27.0569 69.7836 27.5318 71.0987 27.5318V27.5331Z" fill="#1E302D"/>
|
||||
<path d="M53.3321 30.7249C49.3013 30.7249 46.6978 27.8692 46.6978 23.4463C46.6978 19.0234 49.3574 15.9156 53.6122 15.9156C55.3475 15.9156 57.0001 16.6159 57.8111 17.6509V9.25197H62.1219V30.3608H58.0631L57.8391 28.5695C57.0828 29.8566 55.3475 30.7249 53.3321 30.7249ZM54.3672 26.7769C56.3826 26.7769 57.7831 25.3777 57.7831 23.277C57.7831 21.1763 56.3839 19.7771 54.3672 19.7771C52.3505 19.7771 51.0354 21.2043 51.0354 23.277C51.0354 25.3497 52.3225 26.7769 54.3672 26.7769Z" fill="#1E302D"/>
|
||||
<path d="M190.314 23.5854C190.314 22.1667 190.585 20.9254 191.126 19.8614C191.686 18.7787 192.451 17.9387 193.422 17.3414C194.411 16.7254 195.559 16.4174 196.866 16.4174C198.434 16.4174 199.75 16.8374 200.814 17.6774C201.897 18.5174 202.569 19.6374 202.83 21.0374H200.898C200.637 20.104 200.142 19.3854 199.414 18.8814C198.686 18.3774 197.846 18.1254 196.894 18.1254C195.998 18.1254 195.195 18.3494 194.486 18.7974C193.777 19.2454 193.217 19.88 192.806 20.7014C192.414 21.504 192.218 22.456 192.218 23.5574C192.218 24.64 192.405 25.5827 192.778 26.3854C193.151 27.188 193.674 27.8134 194.346 28.2614C195.037 28.7094 195.839 28.9334 196.754 28.9334C197.781 28.9334 198.667 28.6814 199.414 28.1774C200.161 27.6547 200.665 26.9547 200.926 26.0774H202.886C202.681 26.992 202.289 27.7947 201.71 28.4854C201.131 29.176 200.413 29.7174 199.554 30.1094C198.714 30.4827 197.781 30.6694 196.754 30.6694C195.466 30.6694 194.337 30.38 193.366 29.8014C192.414 29.204 191.667 28.3734 191.126 27.3094C190.585 26.2454 190.314 25.004 190.314 23.5854Z" fill="#1E302D"/>
|
||||
<path d="M207.829 30.3334H205.897V9.92135H207.829V30.3334Z" fill="#1E302D"/>
|
||||
<path d="M210.986 23.5574C210.986 22.1574 211.285 20.9254 211.882 19.8614C212.479 18.7974 213.301 17.9574 214.346 17.3414C215.391 16.7254 216.567 16.4174 217.874 16.4174C219.199 16.4174 220.375 16.7254 221.402 17.3414C222.447 17.9574 223.269 18.7974 223.866 19.8614C224.463 20.9254 224.762 22.1574 224.762 23.5574C224.762 24.92 224.463 26.1427 223.866 27.2254C223.269 28.2894 222.447 29.1294 221.402 29.7454C220.375 30.3614 219.199 30.6694 217.874 30.6694C216.567 30.6694 215.391 30.3614 214.346 29.7454C213.301 29.1294 212.479 28.2894 211.882 27.2254C211.285 26.1427 210.986 24.92 210.986 23.5574ZM212.946 23.5294C212.946 24.5934 213.151 25.536 213.562 26.3574C213.991 27.16 214.57 27.7947 215.298 28.2614C216.045 28.7094 216.903 28.9334 217.874 28.9334C218.845 28.9334 219.694 28.7094 220.422 28.2614C221.169 27.7947 221.747 27.16 222.158 26.3574C222.587 25.536 222.802 24.5934 222.802 23.5294C222.802 22.484 222.587 21.56 222.158 20.7574C221.747 19.9547 221.169 19.32 220.422 18.8534C219.694 18.3867 218.845 18.1534 217.874 18.1534C216.903 18.1534 216.045 18.3867 215.298 18.8534C214.57 19.32 213.991 19.9547 213.562 20.7574C213.151 21.56 212.946 22.484 212.946 23.5294Z" fill="#1E302D"/>
|
||||
<path d="M237.044 16.7814H238.976V30.3334H237.296L237.044 28.0374C236.67 28.8214 236.045 29.456 235.168 29.9414C234.309 30.4267 233.348 30.6694 232.284 30.6694C230.697 30.6694 229.474 30.1654 228.616 29.1574C227.776 28.1494 227.356 26.824 227.356 25.1814V16.7534H229.316V24.5654C229.316 26.1707 229.642 27.3 230.296 27.9534C230.968 28.6067 231.836 28.9334 232.9 28.9334C234.206 28.9334 235.224 28.5134 235.952 27.6734C236.68 26.8147 237.044 25.5827 237.044 23.9774V16.7814Z" fill="#1E302D"/>
|
||||
<path d="M248.047 30.6694C246.74 30.6694 245.62 30.3614 244.687 29.7454C243.754 29.1294 243.035 28.2894 242.531 27.2254C242.027 26.1427 241.775 24.9294 241.775 23.5854C241.775 22.2227 242.027 21 242.531 19.9174C243.035 18.8347 243.754 17.9854 244.687 17.3694C245.639 16.7347 246.787 16.4174 248.131 16.4174C249.251 16.4174 250.24 16.66 251.099 17.1454C251.976 17.6307 252.648 18.368 253.115 19.3574V9.92135H255.047V30.3334H253.367L253.171 27.5614C252.686 28.6067 251.995 29.3907 251.099 29.9134C250.203 30.4174 249.186 30.6694 248.047 30.6694ZM248.383 28.9054C249.391 28.9054 250.24 28.6814 250.931 28.2334C251.622 27.7667 252.154 27.132 252.527 26.3294C252.9 25.508 253.087 24.5747 253.087 23.5294C253.087 22.484 252.9 21.56 252.527 20.7574C252.154 19.9547 251.622 19.32 250.931 18.8534C250.24 18.3867 249.391 18.1534 248.383 18.1534C247.412 18.1534 246.572 18.3867 245.863 18.8534C245.172 19.32 244.64 19.9547 244.267 20.7574C243.912 21.56 243.735 22.484 243.735 23.5294C243.735 24.5747 243.912 25.508 244.267 26.3294C244.64 27.132 245.172 27.7667 245.863 28.2334C246.572 28.6814 247.412 28.9054 248.383 28.9054Z" fill="#1E302D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"@typescript-eslint/no-non-null-assertion": 380,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 220,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
"trek/no-swallowed-catch": 128,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/static-components": 50,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
@@ -25,21 +25,21 @@
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
"@typescript-eslint/no-unused-expressions": 2,
|
||||
"react-hooks/purity": 2,
|
||||
"react-hooks/purity": 1,
|
||||
"preserve-caught-error": 53,
|
||||
"no-useless-escape": 1
|
||||
},
|
||||
"suppressed": {
|
||||
"@typescript-eslint/no-explicit-any": 40,
|
||||
"@typescript-eslint/no-explicit-any": 38,
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1927,
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1914,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
"trek/no-window-globals": 331,
|
||||
"trek/no-window-globals": 325,
|
||||
"trek/no-swallowed-catch": 14,
|
||||
"react-hooks/globals": 5,
|
||||
"react-hooks/refs": 22,
|
||||
|
||||
@@ -130,6 +130,15 @@
|
||||
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Booking save follow-up: travellers and files before the editor closes",
|
||||
"hooks": ["src/components/Planner/useBookingFollowUp.ts"],
|
||||
"desktop": ["src/components/Planner/ReservationModal.tsx", "src/components/Planner/TransportModal.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Bookings and transports lists",
|
||||
"hooks": ["src/components/Planner/bookings/bookingsModel.ts"],
|
||||
@@ -733,11 +742,8 @@
|
||||
{
|
||||
"feature": "Settings / Map: mapSettingsModel",
|
||||
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
|
||||
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMap.tsx"
|
||||
]
|
||||
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Map: useMapSettingsForm",
|
||||
@@ -974,8 +980,11 @@
|
||||
}
|
||||
],
|
||||
"mobileOnly": {
|
||||
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
|
||||
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
|
||||
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
|
||||
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
|
||||
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
|
||||
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
|
||||
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
|
||||
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
|
||||
|
||||
@@ -899,7 +899,6 @@
|
||||
"src/components/Roadtrip/useVehicleRange.test.ts",
|
||||
"src/components/Roadtrip/useVehicleRange.ts",
|
||||
"src/components/Roadtrip/vehicleRange.ts",
|
||||
"src/components/Settings/AboutTab.tsx",
|
||||
"src/components/Settings/AccountTab.test.tsx",
|
||||
"src/components/Settings/AccountTab.tsx",
|
||||
"src/components/Settings/AirTrailConnectionSection.test.tsx",
|
||||
@@ -991,9 +990,6 @@
|
||||
"src/components/Studio/useBookPresence.ts",
|
||||
"src/components/Studio/useBookStore.ts",
|
||||
"src/components/Studio/useSpreadInteraction.ts",
|
||||
"src/components/SystemNotices/ReleaseNoticeModal.test.tsx",
|
||||
"src/components/SystemNotices/ReleaseNoticeModal.tsx",
|
||||
"src/components/SystemNotices/ReleaseNoticeVisuals.tsx",
|
||||
"src/components/SystemNotices/SystemNoticeBanner.extra.test.tsx",
|
||||
"src/components/SystemNotices/SystemNoticeBanner.test.tsx",
|
||||
"src/components/SystemNotices/SystemNoticeBanner.tsx",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"admin.ts": 126,
|
||||
"admin.ts": 112,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 36,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||
"components/Budget/BudgetPanelMemberChips.tsx": 6,
|
||||
"components/Budget/CostsPanel.tsx": 3,
|
||||
"components/Budget/ReceiptPreviewModal.tsx": 2,
|
||||
@@ -34,7 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
|
||||
|
||||
@@ -2,28 +2,25 @@
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1873,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapView.tsx": 1337,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1063,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Map/MapViewGL.tsx": 2198,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/atlas/useAtlas.ts": 1210,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1918
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"App.tsx": 11,
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Admin/DefaultUserSettingsTab.tsx": 1,
|
||||
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||
"components/Atlas/AtlasCountryPlaces.tsx": 6,
|
||||
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
|
||||
"components/Budget/BudgetPanel.helpers.ts": 2,
|
||||
@@ -71,7 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
"components/Packing/PackingListPanelHeader.tsx": 5,
|
||||
@@ -284,7 +284,7 @@
|
||||
"mobile/screens/vacay/MVacay.tsx": 23,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"components/Roadtrip/dayColors.ts": 8,
|
||||
"components/Roadtrip/nightBookend.ts": 1,
|
||||
"components/Roadtrip/stopKinds.ts": 1,
|
||||
"components/Settings/AboutTab.tsx": 3,
|
||||
"components/Settings/AboutTab.tsx": 5,
|
||||
"components/Settings/AccountTab.tsx": 2,
|
||||
"components/Settings/AppearanceSettingsTab.tsx": 1,
|
||||
"components/Settings/PasskeysSection.tsx": 1,
|
||||
|
||||
@@ -436,6 +436,28 @@ describe('App — on-mount effects', () => {
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-018c: app-config is asked again after a failed answer, so managed is not lost', async () => {
|
||||
// A page opened while the server restarts got a 502 here and kept every
|
||||
// default for the session, which brought back the operator's settings.
|
||||
let calls = 0
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => {
|
||||
calls += 1
|
||||
return calls === 1 ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ managed: true })
|
||||
})
|
||||
)
|
||||
const setManaged = vi.fn()
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
setManaged,
|
||||
})
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true), { timeout: 4000 })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
|
||||
+25
-3
@@ -323,7 +323,21 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
||||
// Asked again until it answers: a page opened while the server restarts got a
|
||||
// 502 here and kept the defaults for the whole session, which on a managed
|
||||
// install put back every setting the operator owns.
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
type AppConfigAnswer = Awaited<ReturnType<typeof authApi.getAppConfig>>
|
||||
const fetchAppConfig = (attempt: number): Promise<AppConfigAnswer> => authApi.getAppConfig().catch((err: unknown) => {
|
||||
if (cancelled) throw err
|
||||
return new Promise<AppConfigAnswer>((resolve, reject) => {
|
||||
retryTimer = setTimeout(() => {
|
||||
fetchAppConfig(attempt + 1).then(resolve, reject)
|
||||
}, Math.min(30_000, 1_000 * 2 ** attempt))
|
||||
})
|
||||
})
|
||||
fetchAppConfig(0).then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -347,6 +361,10 @@ export default function App() {
|
||||
// Last, since a new release reloads the page from here.
|
||||
await reconcileAppVersion(config?.version)
|
||||
}).catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
@@ -402,6 +420,10 @@ export default function App() {
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
// The Help & Docs page is the wiki, written for somebody who runs TREK
|
||||
// themselves: keys, backups, updates, the server behind it. A managed install
|
||||
// does not offer it; the in-app help center stays.
|
||||
const managed = useAuthStore(s => s.managed)
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -456,7 +478,7 @@ export default function App() {
|
||||
path="/help"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -464,7 +486,7 @@ export default function App() {
|
||||
path="/help/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
|
||||
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true })
|
||||
})
|
||||
|
||||
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
|
||||
|
||||
@@ -574,9 +574,9 @@ export const adminApi = {
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
|
||||
pluginInstall: (id: string, opts?: Omit<PluginInstallRequest, 'id'>) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean, accept?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, { consent: consent || undefined, acceptTrekRange: accept || undefined }).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||
// resolves the newest TREK-compatible version itself.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PlaceContribDecisionRequest, PlaceContribLogQuery } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** /api/admin/places-contribute: sharing place details with the TREK Places API. Admin only. */
|
||||
export const placeContribApi = {
|
||||
state: (signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient.get('/admin/places-contribute', { signal }).then((r) => r.data),
|
||||
decide: (body: PlaceContribDecisionRequest): Promise<unknown> =>
|
||||
apiClient.put('/admin/places-contribute/decision', body).then((r) => r.data),
|
||||
log: (query: Partial<PlaceContribLogQuery>, signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient.get('/admin/places-contribute/log', { params: query, signal }).then((r) => r.data),
|
||||
withdraw: (): Promise<unknown> =>
|
||||
apiClient.post('/admin/places-contribute/withdraw', { confirm: true }).then((r) => r.data),
|
||||
};
|
||||
@@ -22,6 +22,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
az: 'Həddindən çox cəhd edildi. Bir az sonra yenidən cəhd edin.',
|
||||
he: 'יותר מדי ניסיונות. נסו שוב מאוחר יותר.',
|
||||
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/** /api/admin/service-identity: how this installation stands with the TREK services. Admin only. */
|
||||
export const serviceIdentityApi = {
|
||||
status: (signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient.get('/admin/service-identity', { signal }).then((r) => r.data),
|
||||
regenerate: (): Promise<unknown> => apiClient.post('/admin/service-identity/regenerate').then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { StorageReclaimConfirmRequest, StorageReclaimState } from '@trek/shared';
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** Admin > Storage, unreferenced files: the nightly deletion switch, a check on demand, and the large-share confirmation. */
|
||||
export const storageReclaimApi = {
|
||||
setDelete: (deleteEnabled: boolean): Promise<StorageReclaimState> =>
|
||||
apiClient.put('/admin/storage/reclaim', { deleteEnabled }).then((r) => r.data),
|
||||
/** Starts a report-only check in the background (202); its report arrives through the admin state. */
|
||||
check: (): Promise<{ started: true }> => apiClient.post('/admin/storage/reclaim/run').then((r) => r.data),
|
||||
/** `unreferenced` is the count the admin was shown: the server refuses when its own count has moved on. */
|
||||
confirm: (request: StorageReclaimConfirmRequest): Promise<StorageReclaimState> =>
|
||||
apiClient
|
||||
.post('/admin/storage/reclaim/confirm', request satisfies StorageReclaimConfirmRequest)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ValhallaRelayAction } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* POST /api/routing/valhalla/:action: a question for the TREK services'
|
||||
* Valhalla, which this server asks with the installation key. The answer is
|
||||
* Valhalla's own, its status included, so it is handed back as a Response and
|
||||
* read like the answer of any other Valhalla. Plain fetch on purpose: the
|
||||
* shared client would turn a 401 into a sign-in redirect and a 429 into a
|
||||
* toast, where the planner only wants to ask FOSSGIS instead.
|
||||
*/
|
||||
export function postValhallaRelay(action: ValhallaRelayAction, body: unknown, signal?: AbortSignal): Promise<Response> {
|
||||
return fetch(`/api/routing/valhalla/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
@@ -679,6 +680,16 @@ describe('AddonManager', () => {
|
||||
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-042b: a managed install has no wanderer switch under Tours', async () => {
|
||||
seedStore(useAuthStore, { managed: true });
|
||||
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
await unfoldShelves();
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
@@ -157,14 +158,18 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
// "N options" bar the way Collab's and Documents' are.
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
...(wandererOffered
|
||||
? [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
|
||||
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
|
||||
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
|
||||
} = useMcpTokensAdmin()
|
||||
const { t, locale } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const { formatDate } = useFormatDate()
|
||||
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
|
||||
const date = (iso: string) => formatDate(new Date(iso))
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
}
|
||||
|
||||
it('blocks Install when no published version runs on this TREK, and says why', async () => {
|
||||
it('marks a plugin no published version runs on, and still lets the admin install it', async () => {
|
||||
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
|
||||
expect(btn).toBeDisabled()
|
||||
expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('offers the newest version that DOES run here rather than a dead button', async () => {
|
||||
@@ -549,9 +549,11 @@ function panelRoot(container: HTMLElement): HTMLElement {
|
||||
}
|
||||
|
||||
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
|
||||
async function pickFilter(trigger: RegExp, option: RegExp) {
|
||||
fireEvent.click(await screen.findByTitle(trigger))
|
||||
fireEvent.click(await screen.findByRole('button', { name: option }))
|
||||
/** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
|
||||
const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
|
||||
async function pickFilter(option: RegExp) {
|
||||
if ((await screen.findByRole('button', { name: /^Filter/ })).getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton())
|
||||
fireEvent.click(await screen.findByRole('menuitemradio', { name: option }))
|
||||
}
|
||||
|
||||
/** The enable toggle of a named installed row. */
|
||||
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await pickFilter(/^Type: All types$/, /^Widget$/)
|
||||
await pickFilter(/^Widget$/)
|
||||
|
||||
expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('Countdown')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('OnOne')
|
||||
|
||||
await pickFilter(/^Status: All$/, /^Off$/)
|
||||
await pickFilter(/^Off$/)
|
||||
expect(await screen.findByText('OffOne')).toBeInTheDocument()
|
||||
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Status: Off$/, /^Error$/)
|
||||
await pickFilter(/^Error$/)
|
||||
expect(await screen.findByText('BadOne')).toBeInTheDocument()
|
||||
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Status: Error$/, /^Update available$/)
|
||||
await pickFilter(/^Update available$/)
|
||||
expect(await screen.findByText('OnOne')).toBeInTheDocument()
|
||||
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await pickFilter(/^Sort: Name$/, /^Updates first$/)
|
||||
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Updates first$/)
|
||||
expect(filterButton()).toHaveTextContent('Updates first')
|
||||
await clickDiscover()
|
||||
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
|
||||
@@ -1663,15 +1664,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — security footer', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
|
||||
describe('AdminPluginsPanel — security dialog', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
|
||||
panelWith([])
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('No plugins installed yet.')
|
||||
|
||||
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
|
||||
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
|
||||
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
|
||||
@@ -1700,9 +1702,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
|
||||
]
|
||||
|
||||
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
|
||||
await user.click(screen.getByTitle(filter))
|
||||
await user.click(screen.getByRole('button', { name: option }))
|
||||
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
|
||||
if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
|
||||
await user.click(screen.getByRole('menuitemradio', { name: option }))
|
||||
}
|
||||
|
||||
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
|
||||
@@ -1734,7 +1736,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Type:/, /^Widget$/)
|
||||
await pickFilter(user, /^Widget$/)
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
|
||||
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Active$/)
|
||||
await pickFilter(user, /^Active$/)
|
||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Off$/)
|
||||
await pickFilter(user, /^Off$/)
|
||||
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Error$/)
|
||||
await pickFilter(user, /^Error$/)
|
||||
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await screen.findByText('Alpha Widget')
|
||||
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
||||
await pickFilter(user, /^Updates first$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('Gamma Page')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Update available$/)
|
||||
await pickFilter(user, /^Update available$/)
|
||||
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
||||
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Updates first$/)
|
||||
expect(filterButton()).toHaveTextContent('Updates first')
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
|
||||
@@ -1802,19 +1803,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await screen.findByText('Old Tool')
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Most downloads$/)
|
||||
await pickFilter(user, /^Most downloads$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Recently updated$/)
|
||||
await pickFilter(user, /^Recently updated$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Name$/)
|
||||
await pickFilter(user, /^Name$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await screen.findByText('Mapper')
|
||||
|
||||
await pickFilter(user, /^Type:/, /^Page$/)
|
||||
await pickFilter(user, /^Page$/)
|
||||
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Poster')).toBeInTheDocument()
|
||||
|
||||
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
const { container } = render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await user.click(screen.getByTitle(/^Type:/))
|
||||
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
|
||||
await user.click(filterButton())
|
||||
expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
|
||||
|
||||
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
|
||||
fireEvent.click(layer)
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2004,12 +2005,11 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await openDiscover(user)
|
||||
await user.click(screen.getByTitle(/^Sort:/))
|
||||
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
|
||||
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
|
||||
|
||||
await user.click(filterButton())
|
||||
await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
|
||||
expect(filterButton()).toHaveTextContent('Most downloads')
|
||||
await user.click(screen.getByRole('tab', { name: /Installed/ }))
|
||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
|
||||
@@ -2026,9 +2026,8 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
expect(offer).not.toBeDisabled()
|
||||
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
|
||||
|
||||
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
|
||||
expect(dead).toBeDisabled()
|
||||
expect(dead.getAttribute('title')).toBeTruthy()
|
||||
// Nothing fits: no dead button any more, but a card that says so before anything installs.
|
||||
expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
|
||||
@@ -2556,7 +2555,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
|
||||
it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => {
|
||||
let posted: unknown = null
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
@@ -2566,7 +2565,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
|
||||
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
|
||||
const btn = await screen.findByRole('button', { name: /^install$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
fireEvent.click(btn)
|
||||
|
||||
@@ -2585,7 +2584,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
|
||||
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2,
|
||||
ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, CircleDot, Database,
|
||||
Globe, History, Puzzle, Bot, Loader2,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
@@ -12,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
|
||||
import { deriveCaps } from './pluginCaps'
|
||||
import PluginPoiCategoryList from './PluginPoiCategoryList'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -23,15 +23,18 @@ import {
|
||||
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
|
||||
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
|
||||
} from '../Settings/settingsKit'
|
||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
||||
import {
|
||||
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount,
|
||||
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel,
|
||||
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey,
|
||||
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch,
|
||||
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
|
||||
installOffer, signatureBlockView,
|
||||
type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
|
||||
type T, type VersionInfo, type VersionMismatch,
|
||||
} from './plugins/pluginModel'
|
||||
import { usePluginDetail } from './plugins/usePluginDetail'
|
||||
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
import PluginFilterMenu from './plugins/PluginFilterMenu'
|
||||
import PluginInstalledRow from './plugins/PluginInstalledRow'
|
||||
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
|
||||
import VersionLine from './plugins/VersionLine'
|
||||
|
||||
/**
|
||||
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
||||
@@ -42,31 +45,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
* tile; the security section at the bottom explains the model honestly.
|
||||
*/
|
||||
|
||||
// Runtime health → dot colour on the icon tile.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-success',
|
||||
starting: 'bg-info animate-pulse',
|
||||
error: 'bg-danger',
|
||||
inactive: 'bg-content-faint/60',
|
||||
disabled: 'bg-warning',
|
||||
incompatible: 'bg-warning',
|
||||
}
|
||||
|
||||
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
|
||||
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
|
||||
/** The eyebrow over a block, the size the dialogs' sections use. */
|
||||
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
/** A capability or dependency chip on an installed row. */
|
||||
const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium'
|
||||
const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary'
|
||||
const CHIP_NET = 'border-transparent bg-info-soft text-info'
|
||||
const CHIP_WARN = 'border-transparent bg-warning-soft text-warning'
|
||||
/** A small action inside a row or a card: the kit's button, a size down. */
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
|
||||
/** A line of state under a row's name: a refused update, a paused one, an error. */
|
||||
const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5'
|
||||
|
||||
/** A pill that explains itself on hover. */
|
||||
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -76,19 +61,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t }: { t: T }) {
|
||||
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
}
|
||||
|
||||
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
|
||||
function SideloadedBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<HintPill hint={t('admin.plugins.sideloadedHint')}>
|
||||
<StatusPill tone="warning" icon={<UploadCloud size={11} />}>{t('admin.plugins.sideloaded')}</StatusPill>
|
||||
</HintPill>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
|
||||
*
|
||||
@@ -113,15 +85,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
|
||||
function DevLinkBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<HintPill hint={t('admin.plugins.devLinkHint')}>
|
||||
<StatusPill icon={<Link2 size={11} />}>{t('admin.plugins.devLinkBadge')}</StatusPill>
|
||||
</HintPill>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeBadge({ type, t }: { type: string; t: T }) {
|
||||
return (
|
||||
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
|
||||
@@ -145,6 +108,7 @@ export default function AdminPluginsPanel() {
|
||||
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
|
||||
uploadPlugin, versionPicker, view,
|
||||
} = usePluginsAdmin()
|
||||
const [securityOpen, setSecurityOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative mb-24 sm:mb-0"
|
||||
@@ -181,6 +145,9 @@ export default function AdminPluginsPanel() {
|
||||
) : undefined}
|
||||
action={ready ? (
|
||||
<>
|
||||
<BandButton label={t('admin.plugins.security.short')} onClick={() => setSecurityOpen(true)}>
|
||||
<ShieldCheck size={15} strokeWidth={2} />
|
||||
</BandButton>
|
||||
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
|
||||
<UploadCloud size={15} strokeWidth={2} />
|
||||
</BandButton>
|
||||
@@ -242,31 +209,9 @@ export default function AdminPluginsPanel() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
|
||||
['trip-page', t('admin.plugins.type.trip-page')],
|
||||
]}
|
||||
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
|
||||
onPick={v => setTypeFilter(v as TypeFilter)} />
|
||||
|
||||
{view === 'installed' && (
|
||||
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
|
||||
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
|
||||
]}
|
||||
valueLabel={statusLabel(statusFilter, t)}
|
||||
onPick={v => setStatusFilter(v as StatusFilter)} />
|
||||
)}
|
||||
|
||||
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
|
||||
options={view === 'discover'
|
||||
? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
|
||||
: [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
|
||||
valueLabel={sortLabel(sort, t)}
|
||||
onPick={v => setSort(v as SortKey)} />
|
||||
<PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
|
||||
statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
|
||||
menu={menu} setMenu={setMenu} />
|
||||
</div>
|
||||
|
||||
{view === 'discover' ? (
|
||||
@@ -299,7 +244,7 @@ export default function AdminPluginsPanel() {
|
||||
) : (
|
||||
<SettingRows>
|
||||
{shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
<PluginInstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
newerIncompatible={newerIncompatible(p)}
|
||||
blocked={blockIsCurrent(p, latest[p.id])}
|
||||
@@ -320,7 +265,7 @@ export default function AdminPluginsPanel() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<SecurityInfo t={t} />
|
||||
{securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
@@ -360,7 +305,7 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{versionPicker && (
|
||||
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
|
||||
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -438,268 +383,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: (
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
||||
function useFollowTrigger(open: boolean) {
|
||||
const [, reanchor] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onMove = () => reanchor(n => n + 1)
|
||||
window.addEventListener('scroll', onMove, true)
|
||||
window.addEventListener('resize', onMove)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
window.removeEventListener('resize', onMove)
|
||||
}
|
||||
}, [open])
|
||||
}
|
||||
|
||||
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
|
||||
id: string; label: string; valueLabel: string
|
||||
options: Array<[string, string]>; onPick: (v: string) => void; value: string
|
||||
menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode
|
||||
}) {
|
||||
const open = menu === id
|
||||
const active = value !== options[0]?.[0]
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
// Portaled like the row menu: the card around the toolbar clips what overflows it.
|
||||
useFollowTrigger(open)
|
||||
return (
|
||||
<div className="relative flex-none">
|
||||
<button type="button" ref={btnRef} onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
|
||||
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
||||
active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
<span className="flex-none text-content-faint">{icon}</span>
|
||||
<span>{label}: </span>
|
||||
<span className="font-semibold text-content">{valueLabel}</span>
|
||||
<ChevronDown size={13} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div className="min-w-[200px] max-w-[calc(100vw-2rem)]" style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}>
|
||||
{options.map(([v, lbl]) => (
|
||||
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
|
||||
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
const menuOpen = menu === `row:${p.id}`
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const working = busy === p.id
|
||||
const on = p.enabled === 1 && p.status !== 'error'
|
||||
|
||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||
// Re-anchor while the page moves under it.
|
||||
useFollowTrigger(menuOpen)
|
||||
|
||||
const hostCount = p.egressHostCount ?? 0
|
||||
|
||||
return (
|
||||
<div className={`group relative flex items-start gap-3 px-3.5 py-3 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}>
|
||||
<div className="relative mt-0.5 flex-none">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
||||
</span>
|
||||
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
||||
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>{p.name}</span>
|
||||
{p.version && <span className="font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>v{p.version}</span>}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
||||
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
|
||||
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
|
||||
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
|
||||
</div>
|
||||
{p.description && <p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>{p.description}</p>}
|
||||
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
|
||||
its own row state, not an error state, and it persists until it is resolved
|
||||
rather than dying with the toast that first reported it. */}
|
||||
{blocked && p.updateBlock && (
|
||||
<div className={`${ROW_NOTE} text-warning`} style={fs(11.5)}>
|
||||
<ShieldAlert size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
|
||||
<button type="button" onClick={onReviewBlock}
|
||||
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80">
|
||||
{t('admin.plugins.reviewBlock')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A newer version this TREK can't run — informational, never a button: the fix
|
||||
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
|
||||
{newerIncompatible && (
|
||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
||||
<Info size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Held: the admin deliberately installed a non-latest version, so updates are
|
||||
paused rather than nagged — resuming is one click, right where the pause shows. */}
|
||||
{p.updateHold && (
|
||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
||||
<PauseCircle size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
|
||||
<button type="button" onClick={onResume} disabled={working}
|
||||
className="flex-none font-semibold text-content-secondary underline underline-offset-2 hover:opacity-80 disabled:opacity-50">
|
||||
{t('admin.plugins.resumeUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className={`${ROW_NOTE} text-danger`} style={fs(11.5)}>
|
||||
<AlertTriangle size={13} className="flex-none" /><span className="truncate">{p.last_error}</span>
|
||||
</div>
|
||||
) : (caps.length > 0 || p.operatorEgress) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className={`${CHIP} ${c.net ? CHIP_NET : CHIP_NEUTRAL}`} style={fs(11)}>
|
||||
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} /><span className="truncate">{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
|
||||
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
|
||||
Actionable, and warning-toned until at least one host exists, because
|
||||
until then the plugin cannot reach anything and looks silently broken. */}
|
||||
{p.operatorEgress && (
|
||||
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
||||
<button type="button" onClick={onEgress}
|
||||
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{deps.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`${CHIP} ${d.blocked || d.warn ? CHIP_WARN : CHIP_NEUTRAL}`} style={fs(11)}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} /><span className="truncate">{d.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 self-center">
|
||||
{hasUpdate && (
|
||||
<button type="button" onClick={onUpdate} disabled={working}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
||||
style={fs(11.5)}>
|
||||
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
|
||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
||||
<div className="relative">
|
||||
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
||||
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
|
||||
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen && createPortal(
|
||||
<div data-testid={`plugin-row-menu-${p.id}`} style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
||||
className="min-w-[200px]">
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
|
||||
an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
|
||||
never be invited to widen egress for a plugin that didn't ask for it
|
||||
(same rule the row's egress chip follows). */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted"><Github size={14} /></span> {t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
<a href={`https://github.com/${p.source_repo}/issues`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted"><CircleDot size={14} /></span> {t('admin.plugins.reportIssue')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
|
||||
const ROW_MENU_MAX_H = 260
|
||||
|
||||
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
|
||||
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
const r = trigger?.getBoundingClientRect()
|
||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
|
||||
const spaceBelow = window.innerHeight - r.bottom
|
||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||
zIndex: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of a popover menu, in the packing popovers' shape. */
|
||||
const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
||||
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>{icon}</span> {label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return (
|
||||
@@ -751,6 +434,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
|
||||
</span>
|
||||
)}
|
||||
{/* Says up front that this one has not been confirmed for the TREK it would run on,
|
||||
so "Install" never reads as a plain install; the dialog explains the risk. */}
|
||||
{!installed && offer.warn && (
|
||||
// Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot.
|
||||
<span className="absolute start-2.5 top-2.5 inline-flex max-w-[62%] items-center gap-1 rounded-full bg-warning px-2 py-[3px] font-geist font-semibold text-[color:var(--surface-card)] shadow-md"
|
||||
style={fs(11)}>
|
||||
<AlertTriangle size={11} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
|
||||
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
|
||||
</span>
|
||||
@@ -829,8 +522,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||
*/
|
||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
||||
function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
|
||||
}) {
|
||||
return (
|
||||
<DialogShell
|
||||
@@ -844,7 +537,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
|
||||
{confirm ? (
|
||||
<>
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={onConfirm}>{t('admin.plugins.installAnyway')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={onConfirm}>{confirmLabel}</DialogButton>
|
||||
</>
|
||||
) : (
|
||||
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
|
||||
@@ -1054,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const { detail, failed } = usePluginDetail(item.id)
|
||||
const { formatDate } = useFormatDate()
|
||||
|
||||
const manifest = detail?.manifest ?? null
|
||||
const permissions = manifest?.permissions ?? []
|
||||
@@ -1219,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
{(item.trek || item.minTrekVersion) && (
|
||||
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
|
||||
)}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
|
||||
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
|
||||
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
|
||||
)}
|
||||
@@ -1243,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
) : (
|
||||
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}>
|
||||
<VersionLine v={v} locale={locale} />
|
||||
<VersionLine v={v} />
|
||||
</DialogListItem>
|
||||
))}
|
||||
</DialogList>
|
||||
@@ -1253,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
)
|
||||
}
|
||||
|
||||
/** A version number with its date and, when the author signed it, a quiet tick. */
|
||||
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Meta({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
@@ -1279,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
|
||||
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
|
||||
* explained and never offered.
|
||||
*/
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
|
||||
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
|
||||
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
|
||||
t: T; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<DialogShell
|
||||
@@ -1303,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
|
||||
const current = v.version === plugin.version
|
||||
return (
|
||||
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
|
||||
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
|
||||
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
|
||||
{current ? (
|
||||
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
|
||||
) : v.compatible ? (
|
||||
@@ -1495,45 +1178,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
|
||||
// panel that lays out how plugins are contained, the limits, and the worst case.
|
||||
function SecurityInfo({ t }: { t: T }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const sections: Array<[string, string]> = [
|
||||
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
|
||||
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
|
||||
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
|
||||
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
|
||||
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
|
||||
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
|
||||
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
|
||||
]
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Lock}
|
||||
title={(
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
|
||||
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
|
||||
<span className="truncate">{t('admin.plugins.security.title')}</span>
|
||||
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck size={14} className="mt-0.5 flex-none text-content-faint" />
|
||||
<SettingsHint>{t('admin.plugins.reviewedMeaning')}</SettingsHint>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{sections.map(([h, b]) => (
|
||||
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>{t(h as never)}</h4>
|
||||
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>{t(b as never)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
const choices = await screen.findAllByRole('button', { name: option });
|
||||
await user.click(choices[choices.length - 1]);
|
||||
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
|
||||
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultUserSettingsTab date format (#2971)', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
// The choices are labelled by how they write 31 December.
|
||||
await pickFromSelect(user, '2026-12-31', '31.12.2026');
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,10 @@ import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, WeekStart } from '../../types'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import { dateFormatOptions } from '../../utils/dateFormat'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { defaultStyleForProvider } from '../Map/glProviders'
|
||||
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
|
||||
import type { MapProvider } from '../Settings/mapSettingsModel'
|
||||
import RoutingInstanceFields from './RoutingInstanceFields'
|
||||
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
|
||||
|
||||
@@ -66,26 +67,40 @@ function errorText(err: unknown, fallback: string): string {
|
||||
return err instanceof Error ? err.message : fallback
|
||||
}
|
||||
|
||||
interface ResetProps {
|
||||
shown: boolean
|
||||
onReset: () => void
|
||||
label: string
|
||||
}
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
|
||||
if (!shown) return null
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onReset}
|
||||
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const {
|
||||
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
|
||||
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
|
||||
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
const resetOf = (field: keyof Defaults): ResetProps => ({
|
||||
shown: isSet(field),
|
||||
onReset: () => reset(field),
|
||||
label: t('admin.defaultSettings.resetToBuiltIn'),
|
||||
})
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.colorMode')}
|
||||
value={colorModeValue(darkMode)}
|
||||
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.temperature')}
|
||||
value={defaults.temperature_unit ?? ''}
|
||||
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Distance */}
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
|
||||
<Segmented<DistanceUnit | ''>
|
||||
label={t('settings.distance')}
|
||||
value={defaults.distance_unit ?? ''}
|
||||
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.timeFormat')}
|
||||
value={defaults.time_format ?? ''}
|
||||
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
|
||||
<Segmented<WeekStart | ''>
|
||||
label={t('settings.weekStart')}
|
||||
value={defaults.week_start ?? ''}
|
||||
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Date format: every date field and numeric date (#2971) */}
|
||||
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.date_format ?? ''}
|
||||
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
|
||||
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
|
||||
/>
|
||||
</div>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.blurBookingCodes')}
|
||||
value={onOffValue(defaults.blur_booking_codes)}
|
||||
@@ -211,13 +237,13 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={pickTilePreset}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
options={presets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<SettingRows>
|
||||
<ChoiceRow
|
||||
stacked
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
<Segmented<MapProvider>
|
||||
@@ -283,7 +309,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
onChange={saveMapProvider}
|
||||
options={[
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
]}
|
||||
/>
|
||||
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapbox3d')}
|
||||
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
|
||||
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapboxQuality')}
|
||||
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// FE-ADMIN-CONTRIB-LOG-001 to FE-ADMIN-CONTRIB-LOG-004
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PlaceContribLogEntry } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import PlaceContribLog from './PlaceContribLog';
|
||||
|
||||
vi.mock('../../i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../i18n')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
|
||||
}));
|
||||
|
||||
const ENTRY: PlaceContribLogEntry = {
|
||||
id: 1,
|
||||
day: '2026-10-29',
|
||||
key: 'osm:n123',
|
||||
placeName: 'Café Luitpold',
|
||||
field: 'phone',
|
||||
valueSummary: '+49 89 242 8750',
|
||||
result: 'accepted',
|
||||
reason: null,
|
||||
};
|
||||
|
||||
function serve(entries: PlaceContribLogEntry[], total = entries.length) {
|
||||
const queries: URLSearchParams[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute/log', ({ request }) => {
|
||||
queries.push(new URL(request.url).searchParams);
|
||||
return HttpResponse.json({
|
||||
entries,
|
||||
total,
|
||||
page: Number(new URL(request.url).searchParams.get('page') ?? 1),
|
||||
perPage: 20,
|
||||
});
|
||||
})
|
||||
);
|
||||
return queries;
|
||||
}
|
||||
|
||||
describe('PlaceContribLog', () => {
|
||||
it('FE-ADMIN-CONTRIB-LOG-001: lists the place, the value, the field and the result', async () => {
|
||||
serve([ENTRY]);
|
||||
render(<PlaceContribLog />);
|
||||
expect(await screen.findByText('Café Luitpold')).toBeInTheDocument();
|
||||
expect(screen.getByText('+49 89 242 8750')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.contribute.field.phone')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.contribute.result.accepted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-LOG-002: an empty log says nothing was sent', async () => {
|
||||
serve([]);
|
||||
render(<PlaceContribLog />);
|
||||
expect(await screen.findByText('admin.contribute.log.empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-LOG-003: pages through a long log', async () => {
|
||||
const queries = serve([ENTRY], 45);
|
||||
render(<PlaceContribLog />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /common\.showMore/ }));
|
||||
expect(await screen.findByText('1 / 3')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'admin.contribute.log.next' }));
|
||||
await waitFor(() => expect(queries[queries.length - 1]?.get('page')).toBe('2'));
|
||||
expect(queries[queries.length - 1]?.get('perPage')).toBe('20');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-LOG-004: shows the newest five until the list is opened', async () => {
|
||||
serve(Array.from({ length: 8 }, (_, i) => ({ ...ENTRY, id: i + 1, placeName: `Place ${i + 1}` })));
|
||||
render(<PlaceContribLog />);
|
||||
expect(await screen.findByText('Place 5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Place 6')).not.toBeInTheDocument();
|
||||
const toggle = screen.getByRole('button', { name: 'common.showMore (3)' });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await userEvent.click(toggle);
|
||||
expect(screen.getByText('Place 8')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.showLess' }));
|
||||
expect(screen.queryByText('Place 8')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
PLACE_CONTRIB_FIELDS,
|
||||
type PlaceContribField,
|
||||
type PlaceContribLogResponse,
|
||||
type PlaceContribLogResult,
|
||||
} from '@trek/shared';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { placeContribRepo } from '../../repo/placeContribRepo';
|
||||
import { PillSelect } from '../shared/dialogParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { fieldLabel } from './placeContribModel';
|
||||
|
||||
const PER_PAGE = 20;
|
||||
/** Rows shown before the list is opened; a busy log should not push the page down. */
|
||||
const FOLDED = 5;
|
||||
const RESULTS: PlaceContribLogResult[] = ['accepted', 'duplicate', 'rejected', 'withheld', 'retracted'];
|
||||
const RESULT_LOOK: Record<PlaceContribLogResult, string> = {
|
||||
accepted: 'bg-success-soft text-success',
|
||||
duplicate: 'bg-surface-tertiary text-content-muted',
|
||||
rejected: 'bg-danger-soft text-danger',
|
||||
withheld: 'bg-surface-tertiary text-content-muted',
|
||||
retracted: 'bg-warning-soft text-warning',
|
||||
};
|
||||
|
||||
/**
|
||||
* What this installation sent, newest first: the place, the field, a short
|
||||
* form of the value and what the service made of it. Read only while the fold
|
||||
* is open, a page at a time, and only the newest FOLDED rows until opened.
|
||||
*/
|
||||
export default function PlaceContribLog() {
|
||||
const { t, locale } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const [result, setResult] = useState<PlaceContribLogResult | 'all'>('all');
|
||||
const [field, setField] = useState<PlaceContribField | 'all'>('all');
|
||||
const [data, setData] = useState<PlaceContribLogResponse | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
// The query that failed to load, so a new page or filter clears the message by itself.
|
||||
const query = `${page}|${result}|${field}`;
|
||||
const [failedQuery, setFailedQuery] = useState<string | null>(null);
|
||||
const failed = failedQuery === query;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
placeContribRepo
|
||||
.log(
|
||||
{
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
...(result === 'all' ? {} : { result }),
|
||||
...(field === 'all' ? {} : { field }),
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
.then(setData, () => {
|
||||
if (!controller.signal.aborted) setFailedQuery(`${page}|${result}|${field}`);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [page, result, field]);
|
||||
|
||||
const pages = data ? Math.max(1, Math.ceil(data.total / PER_PAGE)) : 1;
|
||||
const foldable = !!data && (data.entries.length > FOLDED || pages > 1);
|
||||
const rows = data ? (open || !foldable ? data.entries : data.entries.slice(0, FOLDED)) : [];
|
||||
const day = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PillSelect<PlaceContribLogResult | 'all'>
|
||||
quiet
|
||||
label={t('admin.contribute.log.result')}
|
||||
value={result}
|
||||
onChange={(v) => {
|
||||
setResult(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: t('admin.contribute.log.allResults') },
|
||||
...RESULTS.map((r) => ({ value: r, label: t(`admin.contribute.result.${r}`) })),
|
||||
]}
|
||||
/>
|
||||
<PillSelect<PlaceContribField | 'all'>
|
||||
quiet
|
||||
label={t('admin.contribute.log.field')}
|
||||
value={field}
|
||||
onChange={(v) => {
|
||||
setField(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: t('admin.contribute.log.allFields') },
|
||||
...PLACE_CONTRIB_FIELDS.map((f) => ({ value: f, label: fieldLabel(t, f) })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{failed && (
|
||||
<p className="m-0 text-danger" style={fs(12, 'body')}>
|
||||
{t('admin.contribute.log.failed')}
|
||||
</p>
|
||||
)}
|
||||
{data && data.entries.length === 0 && (
|
||||
<p className="m-0 py-3 text-center text-content-faint" style={fs(12, 'body')}>
|
||||
{t('admin.contribute.log.empty')}
|
||||
</p>
|
||||
)}
|
||||
{data && data.entries.length > 0 && (
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
|
||||
{rows.map((e) => (
|
||||
<li key={e.id} className="flex flex-wrap items-center gap-x-2.5 gap-y-1 px-3 py-2">
|
||||
<span className="w-12 flex-none tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{day.format(new Date(`${e.day}T12:00:00`))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{e.placeName}
|
||||
</span>
|
||||
{e.valueSummary && (
|
||||
<span className="block truncate text-content-muted" style={fs(11.5)}>
|
||||
{e.valueSummary}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 py-[2px] text-content-secondary"
|
||||
style={fs(11)}
|
||||
>
|
||||
{fieldLabel(t, e.field)}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex flex-none items-center rounded-full px-2 py-[2px] font-semibold ${RESULT_LOOK[e.result]}`}
|
||||
style={fs(11)}
|
||||
title={e.reason ?? undefined}
|
||||
>
|
||||
{t(`admin.contribute.result.${e.result}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{foldable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center gap-1 self-start font-medium text-content-muted hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={open ? 'rotate-180 transition-transform' : 'transition-transform'}
|
||||
/>
|
||||
{open ? t('common.showLess') : `${t('common.showMore')} (${(data?.total ?? 0) - FOLDED})`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{data && open && pages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label={t('admin.contribute.log.previous')}
|
||||
className="grid h-7 w-7 place-items-center rounded-lg border border-edge-faint bg-surface-card disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label={t('admin.contribute.log.next')}
|
||||
className="grid h-7 w-7 place-items-center rounded-lg border border-edge-faint bg-surface-card disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-013
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import PlaceContribPanel from './PlaceContribPanel';
|
||||
|
||||
// The keys themselves, so a test fails on a missing or renamed key rather than on wording.
|
||||
vi.mock('../../i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../i18n')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
|
||||
}));
|
||||
|
||||
const ON = { website: true, phone: true, email: true, opening_hours: true, description: true, image: true };
|
||||
|
||||
const UNDECIDED: PlaceContribState = {
|
||||
termsVersion: 1,
|
||||
ceiling: 'admin',
|
||||
eligible: true,
|
||||
ineligibleReason: null,
|
||||
consent: { enabled: false, fields: ON, termsVersion: null, decision: null, decidedBy: null, decidedAt: null },
|
||||
showPrompt: true,
|
||||
snoozedUntil: null,
|
||||
active: false,
|
||||
counts: { accepted: 0, duplicate: 0, rejected: 0, withheld: 0, retracted: 0 },
|
||||
lastSuccessDay: null,
|
||||
queue: { size: 0, cap: 5000, full: false },
|
||||
pauseReason: null,
|
||||
registrationOpen: false,
|
||||
};
|
||||
|
||||
const AGREED: PlaceContribState = {
|
||||
...UNDECIDED,
|
||||
showPrompt: false,
|
||||
active: true,
|
||||
consent: {
|
||||
enabled: true,
|
||||
fields: ON,
|
||||
termsVersion: 1,
|
||||
decision: 'agreed',
|
||||
decidedBy: 1,
|
||||
decidedAt: '2026-10-30T10:00:00Z',
|
||||
},
|
||||
counts: { accepted: 42, duplicate: 3, rejected: 1, withheld: 5, retracted: 0 },
|
||||
lastSuccessDay: '2026-10-29',
|
||||
queue: { size: 7, cap: 5000, full: false },
|
||||
};
|
||||
|
||||
/** Serves `state`, records every decision and answers it with `answer`. */
|
||||
function serve(
|
||||
state: PlaceContribState,
|
||||
answer: (body: PlaceContribDecisionRequest) => PlaceContribState = () => state
|
||||
) {
|
||||
const decisions: PlaceContribDecisionRequest[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute', () => HttpResponse.json(state)),
|
||||
http.put('/api/admin/places-contribute/decision', async ({ request }) => {
|
||||
const body = (await request.json()) as PlaceContribDecisionRequest;
|
||||
decisions.push(body);
|
||||
return HttpResponse.json(answer(body));
|
||||
}),
|
||||
http.get('/api/admin/places-contribute/log', () =>
|
||||
HttpResponse.json({ entries: [], total: 0, page: 1, perPage: 20 })
|
||||
)
|
||||
);
|
||||
return decisions;
|
||||
}
|
||||
|
||||
describe('PlaceContribPanel', () => {
|
||||
it('FE-ADMIN-CONTRIB-001: an undecided admin gets the prompt with every field switched on', async () => {
|
||||
serve(UNDECIDED);
|
||||
render(<PlaceContribPanel />);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('admin.contribute.prompt.title')).toBeInTheDocument();
|
||||
for (const field of Object.keys(ON)) {
|
||||
expect(within(dialog).getByRole('button', { name: `admin.contribute.field.${field}` })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
}
|
||||
expect(within(dialog).getByText('admin.contribute.never.notes')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('admin.contribute.never.providers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-002: nothing is decided until a button is pressed, and Agree sends the switches it shows', async () => {
|
||||
const decisions = serve(UNDECIDED, () => AGREED);
|
||||
render(<PlaceContribPanel />);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(decisions).toHaveLength(0);
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'admin.contribute.field.image' }));
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'admin.contribute.prompt.agree' }));
|
||||
await waitFor(() => expect(decisions).toHaveLength(1));
|
||||
expect(decisions[0]).toEqual({ action: 'agree', termsVersion: 1, fields: { ...ON, image: false } });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(screen.getByText('admin.contribute.status.active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-003: Agree is unavailable with every field off', async () => {
|
||||
serve(UNDECIDED);
|
||||
render(<PlaceContribPanel />);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
for (const field of Object.keys(ON)) {
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: `admin.contribute.field.${field}` }));
|
||||
}
|
||||
expect(within(dialog).getByRole('button', { name: 'admin.contribute.prompt.agree' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-004: "not now" snoozes and "no" declines for the current terms', async () => {
|
||||
const decisions = serve(UNDECIDED, () => ({ ...UNDECIDED, showPrompt: false }));
|
||||
const { unmount } = render(<PlaceContribPanel />);
|
||||
await userEvent.click(
|
||||
within(await screen.findByRole('dialog')).getByRole('button', { name: 'admin.contribute.prompt.snooze' })
|
||||
);
|
||||
await waitFor(() => expect(decisions).toEqual([{ action: 'snooze' }]));
|
||||
unmount();
|
||||
|
||||
render(<PlaceContribPanel />);
|
||||
await userEvent.click(
|
||||
within(await screen.findByRole('dialog')).getByRole('button', { name: 'admin.contribute.prompt.decline' })
|
||||
);
|
||||
await waitFor(() => expect(decisions[1]).toEqual({ action: 'decline', termsVersion: 1 }));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-005: closing the prompt decides nothing', async () => {
|
||||
const decisions = serve(UNDECIDED);
|
||||
render(<PlaceContribPanel />);
|
||||
await screen.findByRole('dialog');
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(decisions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-006: an agreement shows its figures and a switch per field', async () => {
|
||||
serve(AGREED);
|
||||
render(<PlaceContribPanel />);
|
||||
expect(await screen.findByText('admin.contribute.status.active')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'admin.contribute.field.phone' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-007: switching a field off sends the new switches', async () => {
|
||||
const decisions = serve(AGREED);
|
||||
render(<PlaceContribPanel />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'admin.contribute.field.description' }));
|
||||
await waitFor(() => expect(decisions).toEqual([{ action: 'fields', fields: { ...ON, description: false } }]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-008: the master switch asks before it stops sharing', async () => {
|
||||
const decisions = serve(AGREED, () => ({
|
||||
...AGREED,
|
||||
active: false,
|
||||
consent: { ...AGREED.consent, enabled: false, decision: 'off' },
|
||||
}));
|
||||
render(<PlaceContribPanel />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'admin.contribute.title' }));
|
||||
expect(decisions).toHaveLength(0);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'admin.contribute.offConfirm' }));
|
||||
await waitFor(() => expect(decisions).toEqual([{ action: 'off' }]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-009: an installation that cannot share says why and has no prompt', async () => {
|
||||
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'custom_places_url', showPrompt: false });
|
||||
render(<PlaceContribPanel />);
|
||||
expect(await screen.findByText('admin.contribute.ineligible.custom_places_url')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'admin.contribute.title' })).toBeDisabled();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-010: open registration is named in the prompt', async () => {
|
||||
serve({ ...UNDECIDED, registrationOpen: true });
|
||||
render(<PlaceContribPanel />);
|
||||
expect(
|
||||
await within(await screen.findByRole('dialog')).findByText('admin.contribute.prompt.openRegistration')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it('FE-ADMIN-CONTRIB-011: asks again while the key is still being fetched, and the prompt follows', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
let reads = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute', () => {
|
||||
reads += 1;
|
||||
return HttpResponse.json(
|
||||
reads === 1 ? { ...UNDECIDED, eligible: false, ineligibleReason: 'no_key', showPrompt: false } : UNDECIDED
|
||||
);
|
||||
})
|
||||
);
|
||||
render(<PlaceContribPanel />);
|
||||
expect(await screen.findByText('admin.contribute.ineligible.no_key')).toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(reads).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-012: standing alone, a card the operator switched off is not shown', async () => {
|
||||
const asked = vi.fn();
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute', () => {
|
||||
asked();
|
||||
return HttpResponse.json({ ...UNDECIDED, eligible: false, ineligibleReason: 'env_off', showPrompt: false });
|
||||
})
|
||||
);
|
||||
const { container } = render(<PlaceContribPanel hideWhenSwitchedOff />);
|
||||
await waitFor(() => expect(asked).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-013: standing alone, any other reason is still explained', async () => {
|
||||
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'not_production', showPrompt: false });
|
||||
render(<PlaceContribPanel hideWhenSwitchedOff />);
|
||||
expect(await screen.findByText('admin.contribute.ineligible.not_production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { PlaceContribField, PlaceContribState } from '@trek/shared';
|
||||
import { ChevronRight, HeartHandshake, Undo2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { FigureBadge } from '../Tours/paddle/FigureBadge';
|
||||
import PlaceContribLog from './PlaceContribLog';
|
||||
import {
|
||||
contribNote,
|
||||
contribStatus,
|
||||
fieldEntries,
|
||||
fieldLabel,
|
||||
refusalReason,
|
||||
type ContribStatus,
|
||||
} from './placeContribModel';
|
||||
import PlaceContribPrompt from './PlaceContribPrompt';
|
||||
import { usePlaceContrib } from './usePlaceContrib';
|
||||
|
||||
const PILL_TONE: Record<ContribStatus, 'success' | 'warning' | 'neutral'> = {
|
||||
active: 'success',
|
||||
paused: 'warning',
|
||||
off: 'neutral',
|
||||
unavailable: 'neutral',
|
||||
};
|
||||
|
||||
function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
|
||||
const { locale } = useTranslation();
|
||||
const number = new Intl.NumberFormat(locale);
|
||||
const last = state.lastSuccessDay
|
||||
? new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(
|
||||
new Date(`${state.lastSuccessDay}T12:00:00`)
|
||||
)
|
||||
: t('admin.contribute.never');
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<FigureBadge
|
||||
label={t('admin.contribute.figure.accepted')}
|
||||
value={number.format(state.counts.accepted)}
|
||||
info={t('admin.contribute.figure.acceptedInfo')}
|
||||
/>
|
||||
<FigureBadge
|
||||
label={t('admin.contribute.figure.queue')}
|
||||
value={number.format(state.queue.size)}
|
||||
tone={state.queue.full ? 'warning' : 'neutral'}
|
||||
info={state.queue.full ? t('admin.contribute.figure.queueFull') : undefined}
|
||||
/>
|
||||
<FigureBadge label={t('admin.contribute.figure.last')} value={last} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharing place details with the TREK Places API, inside the TREK API card:
|
||||
* one switch for the installation, one per field, the transmission log and the
|
||||
* way to take everything back. Turning it on for the first time opens the same
|
||||
* prompt an admin gets on the first visit, so an agreement always comes from it.
|
||||
*/
|
||||
/**
|
||||
* Standing alone (managed settings), a card that can only say "not available"
|
||||
* because the operator switched sharing off is a dead end.
|
||||
*/
|
||||
function hiddenAlone(state: PlaceContribState, alone: boolean): boolean {
|
||||
return alone && state.ineligibleReason === 'env_off';
|
||||
}
|
||||
|
||||
export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hideWhenSwitchedOff?: boolean } = {}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const contrib = usePlaceContrib();
|
||||
const { state } = contrib;
|
||||
const [prompting, setPrompting] = useState<boolean | null>(null);
|
||||
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
|
||||
|
||||
if (!state) return null;
|
||||
if (hiddenAlone(state, hideWhenSwitchedOff)) return null;
|
||||
const status = contribStatus(state);
|
||||
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
|
||||
// The prompt opens by itself once per visit while the server asks for it.
|
||||
const showPrompt = prompting ?? state.showPrompt;
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const reason = refusalReason(err);
|
||||
toast.error(
|
||||
reason === 'terms_outdated' || reason === 'no_field'
|
||||
? t(`admin.contribute.refused.${reason}`)
|
||||
: getApiErrorMessage(err, t('common.error'))
|
||||
);
|
||||
};
|
||||
|
||||
const toggleField = (field: PlaceContribField) => {
|
||||
const fields = { ...state.consent.fields, [field]: !state.consent.fields[field] };
|
||||
if (!Object.values(fields).some(Boolean)) {
|
||||
setConfirm('off');
|
||||
return;
|
||||
}
|
||||
contrib.decide({ action: 'fields', fields }).catch(fail);
|
||||
};
|
||||
|
||||
const withdraw = () => {
|
||||
setConfirm(null);
|
||||
contrib.withdraw().then((r) => {
|
||||
const total = Object.values(r.withdrawn).reduce((a, b) => a + b, 0);
|
||||
toast.success(t('admin.contribute.withdrawn', { total }));
|
||||
}, fail);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
flush
|
||||
icon={HeartHandshake}
|
||||
title={t('admin.contribute.title')}
|
||||
badge={<StatusPill tone={PILL_TONE[status]}>{t(`admin.contribute.status.${status}`)}</StatusPill>}
|
||||
hint={contribNote(t, state)}
|
||||
action={
|
||||
<ToggleSwitch
|
||||
on={agreed && state.consent.enabled}
|
||||
label={t('admin.contribute.title')}
|
||||
disabled={!state.eligible || contrib.busy}
|
||||
onToggle={() => (agreed && state.consent.enabled ? setConfirm('off') : setPrompting(true))}
|
||||
/>
|
||||
}
|
||||
bodyClassName="flex flex-col gap-3 p-4"
|
||||
>
|
||||
{agreed && state.consent.enabled && (
|
||||
<>
|
||||
<Figures state={state} t={t} />
|
||||
<ul className="m-0 grid list-none grid-cols-2 gap-1.5 p-0 max-sm:grid-cols-1">
|
||||
{fieldEntries(state.consent.fields).map(([field, on]) => (
|
||||
<li
|
||||
key={field}
|
||||
className="flex items-center justify-between gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-1.5"
|
||||
>
|
||||
<span className={on ? 'text-content' : 'text-content-faint'} style={fs(12.5, 'body')}>
|
||||
{fieldLabel(t, field)}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={on}
|
||||
label={fieldLabel(t, field)}
|
||||
disabled={contrib.busy}
|
||||
onToggle={() => toggleField(field)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(agreed || state.consent.decision === 'off') && (
|
||||
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The admin page's fold head (pages/admin/FoldSummary), which a component cannot import. */}
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className="flex-none text-content-faint transition-transform group-open:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.contribute.log.title')}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="flex flex-col gap-3 border-t border-edge-faint p-3">
|
||||
<PlaceContribLog />
|
||||
<button
|
||||
type="button"
|
||||
disabled={contrib.busy}
|
||||
onClick={() => setConfirm('withdraw')}
|
||||
className="inline-flex items-center gap-1.5 self-start rounded-lg border border-edge-faint bg-surface-card px-3 py-1.5 font-medium text-danger hover:bg-danger-soft disabled:opacity-40"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
{t('admin.contribute.withdraw')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{showPrompt && state.eligible && (
|
||||
<PlaceContribPrompt state={state} contrib={contrib} onClose={() => setPrompting(false)} />
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={confirm === 'off'}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
setConfirm(null);
|
||||
contrib.decide({ action: 'off' }).catch(fail);
|
||||
}}
|
||||
title={t('admin.contribute.offTitle')}
|
||||
message={t('admin.contribute.offMessage')}
|
||||
confirmLabel={t('admin.contribute.offConfirm')}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={confirm === 'withdraw'}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={withdraw}
|
||||
title={t('admin.contribute.withdrawTitle')}
|
||||
message={t('admin.contribute.withdrawMessage')}
|
||||
confirmLabel={t('admin.contribute.withdraw')}
|
||||
danger
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { PlaceContribFieldSwitches, PlaceContribState } from '@trek/shared';
|
||||
import { Check, HeartHandshake, ShieldCheck, TriangleAlert, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { ALL_FIELDS_ON, fieldEntries, fieldLabel, refusalReason } from './placeContribModel';
|
||||
import type { usePlaceContrib } from './usePlaceContrib';
|
||||
|
||||
const NEVER = ['notes', 'files', 'trips', 'people', 'providers'] as const;
|
||||
|
||||
/**
|
||||
* The question an admin gets once: share what this installation's users type
|
||||
* about places with every other TREK. Asked warmly and with every field already
|
||||
* on, because saying yes is the point of asking; but nothing leaves before the
|
||||
* admin presses Agree, and "not now" and "no" are as easy to reach.
|
||||
*
|
||||
* Closing the dialog decides nothing: it comes back on the next visit until one
|
||||
* of the three buttons is pressed.
|
||||
*/
|
||||
export default function PlaceContribPrompt({
|
||||
state,
|
||||
contrib,
|
||||
onClose,
|
||||
}: {
|
||||
state: PlaceContribState;
|
||||
contrib: ReturnType<typeof usePlaceContrib>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [fields, setFields] = useState<PlaceContribFieldSwitches>(() =>
|
||||
state.consent.decision === 'agreed' || state.consent.decision === 'off' ? state.consent.fields : ALL_FIELDS_ON
|
||||
);
|
||||
const none = !Object.values(fields).some(Boolean);
|
||||
|
||||
const run = async (body: Parameters<typeof contrib.decide>[0], done?: string) => {
|
||||
try {
|
||||
await contrib.decide(body);
|
||||
if (done) toast.success(done);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const reason = refusalReason(err);
|
||||
toast.error(
|
||||
reason === 'terms_outdated' || reason === 'no_field'
|
||||
? t(`admin.contribute.refused.${reason}`)
|
||||
: getApiErrorMessage(err, t('common.error'))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy="place-contrib-prompt-title"
|
||||
width="detail"
|
||||
blocked={contrib.busy}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<HeartHandshake size={22} className="text-content" aria-hidden="true" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId="place-contrib-prompt-title"
|
||||
eyebrow="TREK Places API"
|
||||
title={t('admin.contribute.prompt.title')}
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<DialogButton
|
||||
disabled={contrib.busy}
|
||||
onClick={() => void run({ action: 'decline', termsVersion: state.termsVersion })}
|
||||
>
|
||||
{t('admin.contribute.prompt.decline')}
|
||||
</DialogButton>
|
||||
<FooterSpacer />
|
||||
<DialogButton disabled={contrib.busy} onClick={() => void run({ action: 'snooze' })}>
|
||||
{t('admin.contribute.prompt.snooze')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
autoFocus
|
||||
disabled={contrib.busy || none}
|
||||
icon={<HeartHandshake size={15} aria-hidden="true" />}
|
||||
onClick={() =>
|
||||
void run(
|
||||
{ action: 'agree', termsVersion: state.termsVersion, fields },
|
||||
t('admin.contribute.prompt.thanks')
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.contribute.prompt.agree')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="m-0 leading-relaxed text-content" style={fs(14, 'body')}>
|
||||
{t('admin.contribute.prompt.lead')}
|
||||
</p>
|
||||
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
|
||||
{t('admin.contribute.prompt.why')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-2" aria-labelledby="place-contrib-prompt-fields">
|
||||
<h3 id="place-contrib-prompt-fields" className="m-0 font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('admin.contribute.prompt.whatLabel')}
|
||||
</h3>
|
||||
<ul className="m-0 grid list-none grid-cols-2 gap-1.5 p-0 max-sm:grid-cols-1">
|
||||
{fieldEntries(fields).map(([field, on]) => (
|
||||
<li
|
||||
key={field}
|
||||
className="flex items-center justify-between gap-3 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 text-content" style={fs(13, 'body')}>
|
||||
<Check
|
||||
size={14}
|
||||
strokeWidth={2.4}
|
||||
className={on ? 'text-success' : 'text-content-faint opacity-40'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{fieldLabel(t, field)}</span>
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={on}
|
||||
label={fieldLabel(t, field)}
|
||||
onToggle={() => setFields((f) => ({ ...f, [field]: !f[field] }))}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="m-0 leading-normal text-content-faint" style={fs(11.5)}>
|
||||
{t('admin.contribute.prompt.imageNote')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="flex flex-col gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary p-3"
|
||||
aria-labelledby="place-contrib-prompt-never"
|
||||
>
|
||||
<h3
|
||||
id="place-contrib-prompt-never"
|
||||
className="m-0 flex items-center gap-1.5 font-semibold text-content"
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ShieldCheck size={15} className="text-success" aria-hidden="true" />
|
||||
{t('admin.contribute.prompt.neverLabel')}
|
||||
</h3>
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
||||
{NEVER.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2.5 py-[3px] font-medium text-content-secondary"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<X size={12} strokeWidth={2.4} className="text-content-faint" aria-hidden="true" />
|
||||
{t(`admin.contribute.never.${item}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="m-0 leading-normal text-content-muted" style={fs(12, 'body')}>
|
||||
{t('admin.contribute.prompt.how')} {t('admin.contribute.prompt.past')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{state.registrationOpen && (
|
||||
<p
|
||||
className="m-0 flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2 leading-normal text-warning"
|
||||
style={fs(12, 'body')}
|
||||
role="note"
|
||||
>
|
||||
<TriangleAlert size={15} className="mt-[1px] flex-none" aria-hidden="true" />
|
||||
{t('admin.contribute.prompt.openRegistration')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||
{t('admin.contribute.prompt.footnote')}
|
||||
</p>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, useState } from 'react'
|
||||
import type { ValhallaProvider } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT, LABEL } from '../shared/dialogParts'
|
||||
@@ -6,6 +7,7 @@ import { INPUT, LABEL } from '../shared/dialogParts'
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
routing_valhalla_provider?: ValhallaProvider
|
||||
}
|
||||
|
||||
interface RoutingFieldsProps {
|
||||
@@ -25,6 +27,14 @@ const ROUTERS = [
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
// '' is no choice of the admin: the instance then decides as before the choice existed.
|
||||
const PROVIDERS = [
|
||||
{ value: '', labelKey: 'settings.valhallaProviderOption.auto' },
|
||||
{ value: 'trek', labelKey: 'settings.valhallaProviderOption.trek' },
|
||||
{ value: 'fossgis', labelKey: 'settings.valhallaProviderOption.fossgis' },
|
||||
{ value: 'custom', labelKey: 'settings.valhallaProviderOption.custom' },
|
||||
] as const
|
||||
|
||||
// The phone screen's look, unchanged: MAdminDefaultUserSettings passes its own hint class.
|
||||
const PHONE = {
|
||||
wrap: 'min-w-0 space-y-1.5',
|
||||
@@ -43,6 +53,12 @@ const DESKTOP = {
|
||||
input: INPUT,
|
||||
}
|
||||
|
||||
function FieldHint({ id, text, hintClassName }: { id: string; text: string; hintClassName?: string }) {
|
||||
return hintClassName !== undefined
|
||||
? <p id={id} className={hintClassName}>{text}</p>
|
||||
: <p id={id} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{text}</p>
|
||||
}
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
@@ -81,14 +97,45 @@ function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: R
|
||||
aria-describedby={`${id}-hint`}
|
||||
className={look.input}
|
||||
/>
|
||||
{phone
|
||||
? <p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
: <p id={`${id}-hint`} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{t(router.hint)}</p>}
|
||||
<FieldHint id={`${id}-hint`} text={t(router.hint)} hintClassName={hintClassName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Which Valhalla answers what OSRM cannot; picking "automatic" clears the choice. */
|
||||
function RoutingProviderField({ defaults, onSave, onReset, hintClassName }: RoutingFieldsProps) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const phone = hintClassName !== undefined
|
||||
const look = phone ? PHONE : DESKTOP
|
||||
const pick = async (value: string) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (value === '') await onReset('routing_valhalla_provider')
|
||||
else await onSave({ routing_valhalla_provider: value as ValhallaProvider })
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className={`${look.wrap} ${!phone && saving ? 'opacity-60' : ''}`} aria-busy={saving}>
|
||||
<div className={look.head}>
|
||||
<label htmlFor={id} className={look.label}>{t('settings.valhallaProvider')}</label>
|
||||
</div>
|
||||
<select id={id} value={defaults.routing_valhalla_provider ?? ''} disabled={saving}
|
||||
onChange={e => void pick(e.target.value)} aria-describedby={`${id}-hint`} className={look.input}>
|
||||
{PROVIDERS.map(option => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
|
||||
</select>
|
||||
<FieldHint id={`${id}-hint`} text={t('settings.valhallaProviderHint')} hintClassName={hintClassName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
const phone = props.hintClassName !== undefined
|
||||
return <div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
return (
|
||||
<div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>
|
||||
<RoutingProviderField {...props} />
|
||||
{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// FE-ADMIN-SVCID-001 to FE-ADMIN-SVCID-010
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ServiceIdentityStatus } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import ServiceIdentityPanel from './ServiceIdentityPanel';
|
||||
|
||||
// The keys themselves, so a test fails on a missing or renamed key rather than on wording.
|
||||
vi.mock('../../i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../i18n')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
|
||||
}));
|
||||
|
||||
const QUOTA = { limit: 10_000, used: 2_500, remaining: 7_500, resetAt: '2026-10-30T00:00:00.000Z' };
|
||||
const CONNECTED: ServiceIdentityStatus = {
|
||||
state: 'connected',
|
||||
tier: 'std',
|
||||
keySource: 'registered',
|
||||
services: [
|
||||
{ service: 'places', inUse: true, quota: QUOTA },
|
||||
{ service: 'photon', inUse: false, quota: null },
|
||||
{ service: 'valhalla', inUse: false, quota: null },
|
||||
],
|
||||
lastSeenAt: '2026-10-29T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function serve(status: ServiceIdentityStatus) {
|
||||
server.use(http.get('/api/admin/service-identity', () => HttpResponse.json(status)));
|
||||
}
|
||||
|
||||
describe('ServiceIdentityPanel', () => {
|
||||
it('FE-ADMIN-SVCID-001: shows the state, the tier and today against the limit', async () => {
|
||||
serve(CONNECTED);
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.state.connected')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.trekApi.quota.tier.std')).toBeInTheDocument();
|
||||
const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' });
|
||||
expect(bar).toHaveAttribute('aria-valuenow', '2500');
|
||||
expect(bar).toHaveAttribute('aria-valuemax', '10000');
|
||||
// What is left is the figure of the tile; used and the limit sit beside it as badges.
|
||||
expect(screen.getByText('7,500')).toBeInTheDocument();
|
||||
expect(screen.getByText('2,500')).toBeInTheDocument();
|
||||
expect(screen.getByText('10,000')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.trekApi.quota.limit')).toBeInTheDocument();
|
||||
// The day starts again at the same time for every service, so it is said once, in the header.
|
||||
expect(screen.getAllByText('admin.trekApi.quota.resets')).toHaveLength(1);
|
||||
expect(screen.queryByText(/admin\.trekApi\.quota\.hint\./)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-002: an installation without a key says so and offers a new identity', async () => {
|
||||
serve({ state: 'legacy', tier: 'legacy', keySource: null, services: [], lastSeenAt: null });
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.legacy')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-003: switched off, it shows why and offers nothing', async () => {
|
||||
serve({ state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null });
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.off')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-004: a key from the environment is named, and only changed there', async () => {
|
||||
serve({ ...CONNECTED, keySource: 'env', tier: 'own' });
|
||||
render(<ServiceIdentityPanel />);
|
||||
// A badge naming the variable; the sentence explaining it is its tooltip.
|
||||
expect(await screen.findByText('TREK_SERVICES_KEY')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.trekApi.quota.key')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-005: a service in use without an allowance to show says it cannot be read', async () => {
|
||||
serve({ ...CONNECTED, services: [{ service: 'places', inUse: true, quota: null }] });
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.unavailable')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-009: lists place search, address search and routing, each with its own allowance', async () => {
|
||||
serve({
|
||||
...CONNECTED,
|
||||
services: [
|
||||
{ service: 'places', inUse: true, quota: QUOTA },
|
||||
{ service: 'photon', inUse: true, quota: { ...QUOTA, used: 40, remaining: 9_960 } },
|
||||
{ service: 'valhalla', inUse: true, quota: { ...QUOTA, used: 9_500, remaining: 500 } },
|
||||
],
|
||||
});
|
||||
render(<ServiceIdentityPanel />);
|
||||
const list = await screen.findByRole('list', { name: 'admin.trekApi.quota.title' });
|
||||
expect(list.querySelectorAll('li')).toHaveLength(3);
|
||||
const value = (name: string) => screen.getByRole('progressbar', { name }).getAttribute('aria-valuenow');
|
||||
expect(value('admin.trekApi.quota.service.places')).toBe('2500');
|
||||
expect(value('admin.trekApi.quota.service.photon')).toBe('40');
|
||||
expect(value('admin.trekApi.quota.service.valhalla')).toBe('9500');
|
||||
expect(screen.queryByText('admin.trekApi.quota.notInUse')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-010: a service this instance does not ask is named as not in use, its allowance still shown', async () => {
|
||||
serve({
|
||||
...CONNECTED,
|
||||
services: [
|
||||
{ service: 'places', inUse: true, quota: QUOTA },
|
||||
{ service: 'photon', inUse: false, quota: null },
|
||||
{ service: 'valhalla', inUse: false, quota: QUOTA },
|
||||
],
|
||||
});
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findAllByText('admin.trekApi.quota.notInUse')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('progressbar')).toHaveLength(2);
|
||||
expect(screen.queryByText('admin.trekApi.quota.unavailable')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-006: a failed read says so instead of showing nothing', async () => {
|
||||
server.use(http.get('/api/admin/service-identity', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-007: a revoked key asks before it starts over, then shows the new state', async () => {
|
||||
let regenerated = 0;
|
||||
serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null });
|
||||
server.use(
|
||||
http.post('/api/admin/service-identity/regenerate', () => {
|
||||
regenerated += 1;
|
||||
return HttpResponse.json({
|
||||
state: 'registering',
|
||||
tier: 'legacy',
|
||||
keySource: null,
|
||||
services: [],
|
||||
lastSeenAt: null,
|
||||
});
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'admin.trekApi.quota.regenerate' }));
|
||||
expect(screen.getByText('admin.trekApi.quota.regenerateBody')).toBeInTheDocument();
|
||||
expect(regenerated).toBe(0);
|
||||
|
||||
const regenerateButtons = screen.getAllByRole('button', { name: 'admin.trekApi.quota.regenerate' });
|
||||
const confirm = regenerateButtons[regenerateButtons.length - 1];
|
||||
if (!confirm) throw new Error('no confirm button');
|
||||
await user.click(confirm);
|
||||
await waitFor(() => expect(regenerated).toBe(1));
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.registering')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-008: a managed install leaves the identity to its operator', async () => {
|
||||
serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null });
|
||||
render(<ServiceIdentityPanel managed />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
|
||||
import { MapPin, RefreshCw, Route, Search, Signal, type LucideIcon } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { formatClockTime } from '../../utils/formatters';
|
||||
import { SettingsCard } from '../Settings/settingsKit';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { FigureBadge } from '../Tours/paddle/FigureBadge';
|
||||
import { useServiceIdentity } from './useServiceIdentity';
|
||||
|
||||
const STATE_DOT: Record<ServiceIdentityState, string> = {
|
||||
connected: 'bg-success',
|
||||
registering: 'bg-warning motion-safe:animate-pulse',
|
||||
legacy: 'bg-[var(--text-faint)]',
|
||||
off: 'bg-[var(--text-faint)]',
|
||||
revoked: 'bg-danger',
|
||||
};
|
||||
|
||||
const SERVICE_ICON: Record<string, LucideIcon> = { places: MapPin, photon: Search, valhalla: Route };
|
||||
|
||||
/** Below a tenth of the day left the bar turns amber, at nothing left red. */
|
||||
function barTone(quota: ServiceQuotaView): string {
|
||||
if (quota.remaining === 0) return 'bg-danger';
|
||||
return quota.remaining < quota.limit / 10 ? 'bg-warning' : 'bg-accent';
|
||||
}
|
||||
|
||||
function figureTone(quota: ServiceQuotaView): string {
|
||||
if (quota.remaining === 0) return 'text-danger';
|
||||
return quota.remaining < quota.limit / 10 ? 'text-warning' : 'text-content';
|
||||
}
|
||||
|
||||
/** The clock time the counts start again, in the user's format; one for all services, which share the day. */
|
||||
function useResetTime(status: ServiceIdentityStatus | null): string | null {
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
|
||||
const at = status?.services.find((s) => s.quota)?.quota?.resetAt;
|
||||
if (!at) return null;
|
||||
const reset = new Date(at);
|
||||
return formatClockTime(
|
||||
`${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`,
|
||||
is12h
|
||||
);
|
||||
}
|
||||
|
||||
function tileNote(t: TranslationFn, allowance: ServiceAllowance): string | null {
|
||||
if (!allowance.inUse) return t('admin.trekApi.quota.notInUse');
|
||||
return allowance.quota ? null : t('admin.trekApi.quota.unavailable');
|
||||
}
|
||||
|
||||
/**
|
||||
* One service under the key, as a tile: what is left today as the figure that
|
||||
* matters, the day's use as a bar, used and limit as badges. A service not in
|
||||
* use is dimmed rather than hidden, so the row reads the same on every install.
|
||||
*/
|
||||
function ServiceTile({ allowance }: { allowance: ServiceAllowance }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const number = new Intl.NumberFormat(locale);
|
||||
const name = t(`admin.trekApi.quota.service.${allowance.service}`);
|
||||
const note = tileNote(t, allowance);
|
||||
const quota = allowance.quota;
|
||||
const Icon = SERVICE_ICON[allowance.service] ?? MapPin;
|
||||
const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`flex min-w-0 flex-col gap-2.5 rounded-xl border border-edge-faint bg-surface-card p-3 shadow-sm ${allowance.inUse ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-surface-tertiary text-content-muted">
|
||||
<Icon size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<p className="m-0 truncate text-caption font-medium text-content-secondary">{name}</p>
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className={`m-0 truncate text-title font-semibold tabular-nums leading-tight ${figureTone(quota)}`}>
|
||||
{number.format(quota.remaining)}
|
||||
</p>
|
||||
<p className="m-0 text-caption text-content-muted">{t('admin.trekApi.quota.remaining')}</p>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={name}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={quota.limit}
|
||||
aria-valuenow={quota.used}
|
||||
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<FigureBadge label={t('admin.trekApi.quota.used')} value={number.format(quota.used)} />
|
||||
<FigureBadge label={t('admin.trekApi.quota.limit')} value={number.format(quota.limit)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, failed: boolean): string | null {
|
||||
if (!status) return failed ? t('admin.trekApi.quota.unavailable') : null;
|
||||
return status.state === 'connected' ? null : t(`admin.trekApi.quota.hint.${status.state}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this installation stands with the TREK services: whether it has its key,
|
||||
* and for place search, address search and routing today's allowance against
|
||||
* what is used, and when the count starts again.
|
||||
* Read when the card is in view and refreshed every 30 seconds while it stays.
|
||||
* A managed install leaves the identity to its operator, so it offers no new one.
|
||||
*/
|
||||
export default function ServiceIdentityPanel({ managed = false }: { managed?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const { status, failed, regenerating, regenerate } = useServiceIdentity(panel);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const resetAt = useResetTime(status);
|
||||
|
||||
const confirmRegenerate = async () => {
|
||||
setConfirming(false);
|
||||
try {
|
||||
await regenerate();
|
||||
toast.success(t('admin.trekApi.quota.regenerated'));
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const hint = stateHint(t, status, failed);
|
||||
const canRegenerate = !managed && status !== null && status.keySource !== 'env' && status.state !== 'off';
|
||||
|
||||
return (
|
||||
<div ref={panel}>
|
||||
<SettingsCard
|
||||
flush
|
||||
icon={Signal}
|
||||
title={t('admin.trekApi.quota.title')}
|
||||
badge={
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2 py-[2px] text-caption font-semibold text-content shadow-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`}
|
||||
/>
|
||||
{status ? t(`admin.trekApi.quota.state.${status.state}`) : '…'}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<>
|
||||
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
|
||||
{status?.keySource === 'env' && (
|
||||
<FigureBadge
|
||||
label={t('admin.trekApi.quota.key')}
|
||||
value="TREK_SERVICES_KEY"
|
||||
info={t('admin.trekApi.quota.envKey')}
|
||||
/>
|
||||
)}
|
||||
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{status && status.services.length > 0 && (
|
||||
<ul
|
||||
aria-label={t('admin.trekApi.quota.title')}
|
||||
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
|
||||
>
|
||||
{status.services.map((allowance) => (
|
||||
<ServiceTile key={allowance.service} allowance={allowance} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
|
||||
|
||||
{/* The one action, on a rule of its own below the figures. */}
|
||||
{canRegenerate && (
|
||||
<div className="-mx-4 -mb-4 flex items-center justify-end border-t border-edge-faint px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={regenerating}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
size={13}
|
||||
strokeWidth={2}
|
||||
className={regenerating ? 'motion-safe:animate-spin' : ''}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('admin.trekApi.quota.regenerate')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
onConfirm={() => void confirmRegenerate()}
|
||||
title={t('admin.trekApi.quota.regenerateTitle')}
|
||||
message={t('admin.trekApi.quota.regenerateBody')}
|
||||
confirmLabel={t('admin.trekApi.quota.regenerate')}
|
||||
danger={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../.
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||
import { aiAddonsOf, isAiAddon } from './aiAddons';
|
||||
@@ -36,6 +37,9 @@ export function useAddonManager() {
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toursWanderer, setToursWanderer] = useState(false);
|
||||
// wanderer sends a user's searches to a server of their choosing, which a
|
||||
// managed install's terms do not cover; the operator decides, not the admin.
|
||||
const wandererOffered = !useAuthStore((s) => s.managed);
|
||||
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||
const [toursSkiRouting, setToursSkiRouting] = useState(false);
|
||||
@@ -160,6 +164,7 @@ export function useAddonManager() {
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
PLACE_CONTRIB_FIELDS,
|
||||
type PlaceContribField,
|
||||
type PlaceContribFieldSwitches,
|
||||
type PlaceContribState,
|
||||
} from '@trek/shared';
|
||||
import type { TranslationFn } from '../../types';
|
||||
|
||||
/** Every switch on: what the prompt proposes, and what an installation that never decided reads as. */
|
||||
export const ALL_FIELDS_ON: PlaceContribFieldSwitches = {
|
||||
website: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
opening_hours: true,
|
||||
description: true,
|
||||
image: true,
|
||||
};
|
||||
|
||||
export const fieldLabel = (t: TranslationFn, field: PlaceContribField): string => t(`admin.contribute.field.${field}`);
|
||||
|
||||
/** The switches as a list, in the order the contract names the fields. */
|
||||
export const fieldEntries = (switches: PlaceContribFieldSwitches): [PlaceContribField, boolean][] =>
|
||||
PLACE_CONTRIB_FIELDS.map((field) => [field, switches[field]]);
|
||||
|
||||
export type ContribStatus = 'active' | 'paused' | 'off' | 'unavailable';
|
||||
|
||||
/** Where sharing stands, as the pill beside the title says it. */
|
||||
export function contribStatus(state: PlaceContribState): ContribStatus {
|
||||
if (!state.eligible) return 'unavailable';
|
||||
if (!state.active) return 'off';
|
||||
return state.pauseReason ? 'paused' : 'active';
|
||||
}
|
||||
|
||||
/** The sentence under the title: why it cannot run, why it waits, or what it does. */
|
||||
export function contribNote(t: TranslationFn, state: PlaceContribState): string {
|
||||
if (!state.eligible && state.ineligibleReason) return t(`admin.contribute.ineligible.${state.ineligibleReason}`);
|
||||
if (state.active && state.pauseReason) return t(`admin.contribute.pause.${state.pauseReason}`);
|
||||
return t(state.active ? 'admin.contribute.noteActive' : 'admin.contribute.noteOff');
|
||||
}
|
||||
|
||||
/** The refusal a decision can meet, as the server names it in `reason`, or null for any other failure. */
|
||||
export function refusalReason(err: unknown): string | null {
|
||||
const reason = (err as { response?: { data?: { reason?: unknown } } } | null)?.response?.data?.reason;
|
||||
return typeof reason === 'string' && reason ? reason : null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||
import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel';
|
||||
|
||||
const MENU_ID = 'filters';
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
value: string;
|
||||
options: Array<[string, string]>;
|
||||
onPick: (v: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type, status and order of the plugin list behind one button instead of three, so the
|
||||
* toolbar stays search plus one control. The button counts the filters that narrow the
|
||||
* list (the order does not narrow it) and names the order it is in.
|
||||
*/
|
||||
export default function PluginFilterMenu({
|
||||
t,
|
||||
view,
|
||||
typeFilter,
|
||||
setTypeFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
sort,
|
||||
setSort,
|
||||
menu,
|
||||
setMenu,
|
||||
}: {
|
||||
t: T;
|
||||
view: 'installed' | 'discover';
|
||||
typeFilter: TypeFilter;
|
||||
setTypeFilter: (v: TypeFilter) => void;
|
||||
statusFilter: StatusFilter;
|
||||
setStatusFilter: (v: StatusFilter) => void;
|
||||
sort: SortKey;
|
||||
setSort: (v: SortKey) => void;
|
||||
menu: string | null;
|
||||
setMenu: (v: string | null) => void;
|
||||
}) {
|
||||
const open = menu === MENU_ID;
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
useFollowTrigger(open);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: t('admin.plugins.filterType'),
|
||||
value: typeFilter,
|
||||
options: [
|
||||
['all', t('admin.plugins.allTypes')],
|
||||
['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')],
|
||||
['page', t('admin.plugins.type.page')],
|
||||
['trip-page', t('admin.plugins.type.trip-page')],
|
||||
],
|
||||
onPick: (v) => setTypeFilter(v as TypeFilter),
|
||||
},
|
||||
...(view === 'installed'
|
||||
? [
|
||||
{
|
||||
title: t('admin.plugins.filterStatus'),
|
||||
value: statusFilter,
|
||||
options: [
|
||||
['all', t('admin.plugins.allStatuses')],
|
||||
['on', t('admin.plugins.status.active')],
|
||||
['off', t('admin.plugins.stateOff')],
|
||||
['update', t('admin.plugins.filterUpdate')],
|
||||
['err', t('admin.plugins.status.error')],
|
||||
] as Array<[string, string]>,
|
||||
onPick: (v: string) => setStatusFilter(v as StatusFilter),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t('admin.plugins.sortBy'),
|
||||
value: sort,
|
||||
options:
|
||||
view === 'discover'
|
||||
? [
|
||||
['name', t('admin.plugins.sortName')],
|
||||
['recent', t('admin.plugins.sortRecent')],
|
||||
['downloads', t('admin.plugins.sortDownloads')],
|
||||
]
|
||||
: [
|
||||
['name', t('admin.plugins.sortName')],
|
||||
['recent', t('admin.plugins.sortRecent')],
|
||||
['updates', t('admin.plugins.sortUpdates')],
|
||||
],
|
||||
onPick: (v) => setSort(v as SortKey),
|
||||
},
|
||||
];
|
||||
const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="relative flex-none">
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
onClick={() => setMenu(open ? null : MENU_ID)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={`inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
||||
narrowing ? 'border-[color:var(--text-primary)]' : 'border-edge'
|
||||
}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<SlidersHorizontal size={14} className="flex-none text-content-faint" />
|
||||
<span className="font-semibold text-content">{t('admin.plugins.filters')}</span>
|
||||
{narrowing > 0 && (
|
||||
<span
|
||||
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1.5 font-geist font-bold tabular-nums text-accent-text"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{narrowing}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-content-faint">{sortLabel(sort, t)}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
className="w-[240px] max-w-[calc(100vw-2rem)]"
|
||||
style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}
|
||||
>
|
||||
{sections.map((s, i) => (
|
||||
<div key={s.title} className={i > 0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}>
|
||||
<p
|
||||
className="m-0 px-2.5 pb-1 pt-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(10)}
|
||||
>
|
||||
{s.title}
|
||||
</p>
|
||||
{s.options.map(([v, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={s.value === v}
|
||||
key={v}
|
||||
onClick={() => s.onPick(v)}
|
||||
className={`${MENU_ITEM} ${s.value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
|
||||
<Check
|
||||
size={14}
|
||||
className={`flex-none text-content-muted ${s.value === v ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
Bug,
|
||||
CircleDot,
|
||||
Github,
|
||||
Globe,
|
||||
History,
|
||||
Info,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PauseCircle,
|
||||
RotateCw,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { useRef, type ComponentType, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import PluginIcon from '../../shared/PluginIcon';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { deriveCaps } from '../pluginCaps';
|
||||
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||
import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel';
|
||||
|
||||
// Runtime health → dot colour on the icon tile.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-success',
|
||||
starting: 'bg-info animate-pulse',
|
||||
error: 'bg-danger',
|
||||
inactive: 'bg-content-faint/60',
|
||||
disabled: 'bg-warning',
|
||||
incompatible: 'bg-warning',
|
||||
};
|
||||
|
||||
/** Every fact about a plugin wears the same chip; only its icon carries a colour. */
|
||||
const FACT =
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary';
|
||||
|
||||
type Tone = 'warning' | 'danger' | 'neutral';
|
||||
const NOTICE_LOOK: Record<Tone, string> = {
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
neutral: 'bg-surface-secondary text-content-muted',
|
||||
};
|
||||
|
||||
/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */
|
||||
interface Notice {
|
||||
key: string;
|
||||
tone: Tone;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
text: string;
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
}
|
||||
|
||||
function Fact({
|
||||
icon,
|
||||
iconClass = 'text-content-muted',
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
iconClass?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const chip = (
|
||||
<span className={FACT} style={fs(11)}>
|
||||
<span className={`flex flex-none ${iconClass}`}>{icon}</span>
|
||||
<span className="truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
return hint ? (
|
||||
<Tooltip label={hint}>
|
||||
<span className="inline-flex max-w-full">{chip}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
chip
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeLine({ n }: { n: Notice }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-1.5 ${NOTICE_LOOK[n.tone]}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<n.icon size={13} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate">{n.text}</span>
|
||||
{n.action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={n.action.onClick}
|
||||
disabled={n.action.disabled}
|
||||
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80 disabled:opacity-50"
|
||||
>
|
||||
{n.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>
|
||||
{icon}
|
||||
</span>{' '}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface InstalledRowProps {
|
||||
p: PluginRow;
|
||||
t: T;
|
||||
busy: string | null;
|
||||
menu: string | null;
|
||||
setMenu: (v: string | null) => void;
|
||||
hasUpdate: boolean;
|
||||
latestVer?: string;
|
||||
newerIncompatible: { version: string; range: string } | null;
|
||||
blocked: boolean;
|
||||
onToggle: () => void;
|
||||
onUpdate: () => void;
|
||||
onRestart: () => void;
|
||||
onChangeVersion: () => void;
|
||||
onResume: () => void;
|
||||
onReviewBlock: () => void;
|
||||
onErrors: () => void;
|
||||
onEgress: () => void;
|
||||
onSettings: () => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed plugin, read top to bottom: its name and version, what it does, the
|
||||
* facts about it as one row of alike chips (what it adds, whether it was reviewed and
|
||||
* signed, where it may connect), then each state that needs attention on a line of its
|
||||
* own with the action that resolves it. The controls stay on the right.
|
||||
*/
|
||||
export default function PluginInstalledRow(props: InstalledRowProps) {
|
||||
const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props;
|
||||
const caps = deriveCaps(
|
||||
parseJson<string[]>(p.permissions, []),
|
||||
parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
|
||||
t
|
||||
);
|
||||
const deps = deriveDeps(p, t);
|
||||
const menuOpen = menu === `row:${p.id}`;
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const working = busy === p.id;
|
||||
const hostCount = p.egressHostCount ?? 0;
|
||||
|
||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||
useFollowTrigger(menuOpen);
|
||||
|
||||
const notices: Notice[] = [];
|
||||
if (p.status === 'error' && p.last_error) {
|
||||
notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error });
|
||||
}
|
||||
// A refused update leaves a WORKING plugin pinned at its old version: its own state,
|
||||
// kept until resolved rather than dying with the toast that first reported it.
|
||||
if (blocked && p.updateBlock) {
|
||||
notices.push({
|
||||
key: 'block',
|
||||
tone: 'warning',
|
||||
icon: ShieldAlert,
|
||||
text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }),
|
||||
action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock },
|
||||
});
|
||||
}
|
||||
for (const [i, d] of deps.entries()) {
|
||||
if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label });
|
||||
}
|
||||
// Held: the admin deliberately installed a non-latest version, so updates are paused
|
||||
// rather than nagged — resuming is one click, right where the pause shows.
|
||||
if (p.updateHold) {
|
||||
notices.push({
|
||||
key: 'hold',
|
||||
tone: 'neutral',
|
||||
icon: PauseCircle,
|
||||
text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }),
|
||||
action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working },
|
||||
});
|
||||
}
|
||||
// A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade.
|
||||
if (newerIncompatible) {
|
||||
notices.push({
|
||||
key: 'newer',
|
||||
tone: 'neutral',
|
||||
icon: Info,
|
||||
text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-3 px-3.5 py-3.5 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="relative mt-0.5 flex-none">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
||||
</span>
|
||||
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>
|
||||
{p.name}
|
||||
</span>
|
||||
{p.version && (
|
||||
<span className="flex-none font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>
|
||||
v{p.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{caps.map((c, i) => (
|
||||
<Fact key={i} icon={<c.icon size={12} />} iconClass={c.net ? 'text-info' : 'text-content-muted'}>
|
||||
{c.label}
|
||||
</Fact>
|
||||
))}
|
||||
{deps
|
||||
.filter((d) => !d.blocked && !d.warn)
|
||||
.map((d, i) => (
|
||||
<Fact key={`d${i}`} icon={<d.icon size={12} />}>
|
||||
{d.label}
|
||||
</Fact>
|
||||
))}
|
||||
{p.reviewed_at && (
|
||||
<Fact icon={<ShieldCheck size={12} />} iconClass="text-success">
|
||||
{t('admin.plugins.reviewed')}
|
||||
</Fact>
|
||||
)}
|
||||
{p.source_repo === 'local:upload' && (
|
||||
<Fact icon={<UploadCloud size={12} />} iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}>
|
||||
{t('admin.plugins.sideloaded')}
|
||||
</Fact>
|
||||
)}
|
||||
{p.source_repo === 'local:link' && (
|
||||
<Fact icon={<Link2 size={12} />} hint={t('admin.plugins.devLinkHint')}>
|
||||
{t('admin.plugins.devLinkBadge')}
|
||||
</Fact>
|
||||
)}
|
||||
{/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */}
|
||||
{isRegistrySourced(p.source_repo) &&
|
||||
(p.signed ? (
|
||||
<Fact icon={<KeyRound size={12} />} hint={t('admin.plugins.signedHint')}>
|
||||
{t('admin.plugins.signed')}
|
||||
</Fact>
|
||||
) : (
|
||||
<Fact icon={<ShieldAlert size={12} />} iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}>
|
||||
{t('admin.plugins.unsigned')}
|
||||
</Fact>
|
||||
))}
|
||||
{/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds
|
||||
its host, and until one exists the plugin reaches nothing, so it asks for it. */}
|
||||
{p.operatorEgress && (
|
||||
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
||||
<button type="button" onClick={props.onEgress} className={`${FACT} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} className={hostCount > 0 ? 'text-info' : 'text-warning'} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notices.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||
{notices.map((n) => (
|
||||
<NoticeLine key={n.key} n={n} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 self-center">
|
||||
{hasUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onUpdate}
|
||||
disabled={working}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`min-w-[42px] text-end font-medium ${p.enabled === 1 ? 'text-content-secondary' : 'text-content-faint'}`}
|
||||
style={fs(12)}
|
||||
>
|
||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={props.onToggle} />
|
||||
<div className="relative">
|
||||
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
||||
<button
|
||||
type="button"
|
||||
ref={menuBtnRef}
|
||||
data-testid={`plugin-row-menu-btn-${p.id}`}
|
||||
onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={t('admin.table.actions')}
|
||||
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
|
||||
>
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
data-testid={`plugin-row-menu-${p.id}`}
|
||||
style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem
|
||||
icon={<RotateCw size={14} />}
|
||||
label={t('admin.plugins.restart')}
|
||||
onClick={props.onRestart}
|
||||
/>
|
||||
)}
|
||||
{/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
label={t('admin.plugins.instanceSettings')}
|
||||
onClick={props.onSettings}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={props.onErrors} />
|
||||
{/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem
|
||||
icon={<Globe size={14} />}
|
||||
label={t('admin.plugins.allowedHosts')}
|
||||
onClick={props.onEgress}
|
||||
/>
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem
|
||||
icon={<History size={14} />}
|
||||
label={t('admin.plugins.changeVersion')}
|
||||
onClick={props.onChangeVersion}
|
||||
/>
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a
|
||||
href={`https://github.com/${p.source_repo}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||
<Github size={14} />
|
||||
</span>{' '}
|
||||
{t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
<a
|
||||
href={`https://github.com/${p.source_repo}/issues`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||
<CircleDot size={14} />
|
||||
</span>{' '}
|
||||
{t('admin.plugins.reportIssue')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={props.onUninstall} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import type { T } from './pluginModel';
|
||||
|
||||
/**
|
||||
* How plugins are contained and where that ends, opened from the shield in the panel's
|
||||
* header. It used to sit under the list as a card of its own, which pushed a wall of
|
||||
* text into the page every admin scrolls past.
|
||||
*/
|
||||
export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) {
|
||||
const sections: Array<[string, string]> = [
|
||||
[t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')],
|
||||
[t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')],
|
||||
[t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')],
|
||||
[t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')],
|
||||
[t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')],
|
||||
[t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')],
|
||||
[t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')],
|
||||
];
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy="plugin-security-title"
|
||||
width="detail"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<ShieldCheck size={20} className="text-content-secondary" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId="plugin-security-title"
|
||||
title={t('admin.plugins.security.short')}
|
||||
sub={t('admin.plugins.reviewedMeaning')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.ok')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{sections.map(([h, b]) => (
|
||||
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{h}
|
||||
</h4>
|
||||
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>
|
||||
{b}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { VersionInfo } from './pluginModel';
|
||||
|
||||
/** A version number with its date and, when the author signed it, a quiet tick. */
|
||||
export default function VersionLine({ v }: { v: VersionInfo }) {
|
||||
const { formatDate } = useFormatDate();
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<span
|
||||
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
|
||||
style={fs(12.5)}
|
||||
>
|
||||
v{v.version}
|
||||
</span>
|
||||
{v.publishedAt && (
|
||||
<span className="text-content-faint" style={fs(11.5)}>
|
||||
{formatDate(new Date(v.publishedAt))}
|
||||
</span>
|
||||
)}
|
||||
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
|
||||
/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */
|
||||
const ROW_MENU_MAX_H = 260;
|
||||
|
||||
/**
|
||||
* Where a portaled menu sits: right-aligned under its trigger, or above it when the
|
||||
* window has no room below. Fixed, because the cards around the toolbar and the rows
|
||||
* clip whatever overflows them.
|
||||
*/
|
||||
export function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
const r = trigger?.getBoundingClientRect();
|
||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
|
||||
const spaceBelow = window.innerHeight - r.bottom;
|
||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||
zIndex: 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** One entry of a popover menu, in the packing popovers' shape. */
|
||||
export const MENU_ITEM =
|
||||
'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary';
|
||||
|
||||
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
||||
export function useFollowTrigger(open: boolean) {
|
||||
const [, reanchor] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMove = () => reanchor((n) => n + 1);
|
||||
window.addEventListener('scroll', onMove, true);
|
||||
window.addEventListener('resize', onMove);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true);
|
||||
window.removeEventListener('resize', onMove);
|
||||
};
|
||||
}, [open]);
|
||||
}
|
||||
@@ -111,8 +111,14 @@ describe('pluginModel', () => {
|
||||
blocked: false,
|
||||
version: '0.9.0',
|
||||
});
|
||||
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' });
|
||||
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' });
|
||||
// Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
|
||||
expect(installOffer(incompatible, t)).toMatchObject({
|
||||
blocked: false,
|
||||
label: 'admin.plugins.install',
|
||||
warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' },
|
||||
});
|
||||
// TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice.
|
||||
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
|
||||
|
||||
@@ -298,7 +298,8 @@ export function installOffer(
|
||||
title,
|
||||
};
|
||||
}
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title };
|
||||
// No version fits this TREK: the admin may still install this one plugin at their own risk.
|
||||
return bypassOffer(item, t, title, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells.
|
||||
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
|
||||
expect(result.current.confirmUninstall).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
|
||||
serve([
|
||||
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
|
||||
]);
|
||||
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
|
||||
const { result } = await mounted();
|
||||
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
|
||||
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
|
||||
expect(result.current.bypass.warning).toMatchObject({
|
||||
acceptedByAdmin: true,
|
||||
action: 'activate',
|
||||
trekRange: '>=3.2.0 <4.0.0',
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.bypass.confirm();
|
||||
});
|
||||
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
|
||||
serve([], [entry({ id: 'beta', name: 'Beta' })]);
|
||||
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
|
||||
const { result } = await mounted();
|
||||
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
|
||||
act(() => result.current.install('beta', undefined, warn));
|
||||
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
result.current.bypass.confirm();
|
||||
});
|
||||
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,9 +317,14 @@ export function usePluginsAdmin() {
|
||||
act(
|
||||
id,
|
||||
() =>
|
||||
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
|
||||
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
||||
}),
|
||||
adminApi
|
||||
.pluginInstall(id, {
|
||||
...(version ? { version } : {}),
|
||||
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
||||
}),
|
||||
t('admin.plugins.installed')
|
||||
)
|
||||
);
|
||||
@@ -392,10 +397,10 @@ export function usePluginsAdmin() {
|
||||
}
|
||||
};
|
||||
|
||||
const attemptActivate = (p: PluginRow) => {
|
||||
const attemptActivate = (p: PluginRow, acceptTrekRange = false) => {
|
||||
const cascaded = autoEnabledDeps(p);
|
||||
return adminApi
|
||||
.pluginActivate(p.id)
|
||||
.pluginActivate(p.id, false, acceptTrekRange)
|
||||
.then(() => {
|
||||
toast.success(t('admin.plugins.activated'));
|
||||
if (cascaded.length)
|
||||
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
|
||||
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
|
||||
return;
|
||||
}
|
||||
setBusy(p.id);
|
||||
setMenu(null);
|
||||
attemptActivate(p).finally(() => {
|
||||
setBusy(null);
|
||||
refresh();
|
||||
});
|
||||
const run = (acceptTrekRange: boolean) => {
|
||||
setBusy(p.id);
|
||||
setMenu(null);
|
||||
attemptActivate(p, acceptTrekRange).finally(() => {
|
||||
setBusy(null);
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
// Outside the TREK versions its author declared: the admin may run it anyway, after
|
||||
// reading what that risks. The choice holds for this version of the plugin only.
|
||||
if (p.dependencyStatus === 'hostIncompatible') {
|
||||
const warn: RangeWarning = {
|
||||
name: p.name,
|
||||
trekRange: p.trekRange ?? null,
|
||||
hostVersion: p.hostVersion ?? '?',
|
||||
acceptedByAdmin: true,
|
||||
onConfirm: null,
|
||||
action: 'activate',
|
||||
};
|
||||
bypass.guard(warn, () => run(true));
|
||||
return;
|
||||
}
|
||||
run(false);
|
||||
};
|
||||
|
||||
// Download a missing/outdated plugin dependency (latest compatible for its range,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageCategory, type StorageConfig } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE, STORAGE_RECLAIM_CATEGORIES, type StorageAdminState, type StorageCategory, type StorageConfig, type StorageReclaimReport } from '@trek/shared';
|
||||
import { server } from '../../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
|
||||
import { ToastContainer } from '../../shared/Toast';
|
||||
@@ -11,6 +11,16 @@ const S3_MASKED = {
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** A last check with three covers a week old and two younger ones; every other category never reclaimed. */
|
||||
function reclaimReport(): StorageReclaimReport {
|
||||
const categories = Object.fromEntries(
|
||||
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, { objects: 0, unreferenced: { objects: 0, bytes: 0 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: 'not_reclaimed' as const }]),
|
||||
) as StorageReclaimReport['categories'];
|
||||
categories.covers = { objects: 40, unreferenced: { objects: 5, bytes: 5000 }, eligible: { objects: 3, bytes: 3000 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: null };
|
||||
categories.places = { objects: 10, unreferenced: { objects: 3, bytes: 300 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: true, skipped: null };
|
||||
return { startedAt: Date.now() - 60_000, finishedAt: Date.now() - 30_000, mode: 'report', stopped: null, categories };
|
||||
}
|
||||
|
||||
function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminState {
|
||||
return {
|
||||
backends: [
|
||||
@@ -35,6 +45,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
@@ -1217,4 +1228,55 @@ describe('AdminStoragePanel', () => {
|
||||
expect(within(row).getByText(/Sync failed: disk full/)).toBeInTheDocument();
|
||||
expect(within(row).getByRole('button', { name: 'Sync now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-051: unreferenced files: the last check shows, the switch, the check and a confirmation go to the server', async () => {
|
||||
const lastRun = reclaimReport();
|
||||
let put: unknown = null;
|
||||
let runs = 0;
|
||||
let confirm: unknown = null;
|
||||
server.use(
|
||||
http.put('/api/admin/storage/reclaim', async ({ request }) => {
|
||||
put = await request.json();
|
||||
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
|
||||
}),
|
||||
http.post('/api/admin/storage/reclaim/run', () => {
|
||||
runs += 1;
|
||||
return HttpResponse.json({ started: true }, { status: 202 });
|
||||
}),
|
||||
http.post('/api/admin/storage/reclaim/confirm', async ({ request }) => {
|
||||
confirm = await request.json();
|
||||
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
|
||||
}),
|
||||
);
|
||||
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun, lastDeleteRun: null } }));
|
||||
// Once a check started, the state the panel polls says it runs.
|
||||
server.use(
|
||||
http.get('/api/admin/storage', () =>
|
||||
HttpResponse.json(baseState({ reclaim: { deleteEnabled: true, running: runs > 0, lastRun, lastDeleteRun: null } })),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('Unreferenced files')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('storage-reclaim-covers')).getByText(/Can be freed/)).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('storage-reclaim-places')).getByText(/More than a fifth/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete unreferenced files' }));
|
||||
await waitFor(() => expect(put).toEqual({ deleteEnabled: true }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete unreferenced files' })).toHaveAttribute('aria-pressed', 'true'));
|
||||
fireEvent.click(within(screen.getByTestId('storage-reclaim-places')).getByRole('button', { name: 'Confirm' }));
|
||||
await waitFor(() => expect(confirm).toEqual({ category: 'places', unreferenced: 3 }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
|
||||
await waitFor(() => expect(runs).toBe(1));
|
||||
// The check runs in the background: the panel shows it running until the state says otherwise.
|
||||
await waitFor(() => expect(screen.getByText('Checking…')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-051b: a refused check names the reason in the operator\'s language', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/storage/reclaim/run', () =>
|
||||
HttpResponse.json({ error: 'busy', code: 'reclaim_busy' }, { status: 409 }),
|
||||
),
|
||||
);
|
||||
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun: null, lastDeleteRun: null } }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
|
||||
await waitFor(() => expect(screen.getByText('Another storage task is running. Try again in a moment.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit'
|
||||
import BackendForm from './BackendForm'
|
||||
import StorageReclaimSection from './StorageReclaimSection'
|
||||
import { CACHE_CATEGORIES, primaryNameOf } from './storageModel'
|
||||
import { categoryNames, migrationPromptLine, useStoragePanel } from './useStoragePanel'
|
||||
|
||||
@@ -96,6 +97,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
const {
|
||||
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
|
||||
closeMigratePrompt, migrationQueue, handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
|
||||
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
|
||||
} = useStoragePanel()
|
||||
// Open/closed only: the render below and moveAndSave both recompute
|
||||
// computeMigrationCandidates(draft, state) fresh rather than reading the
|
||||
@@ -627,6 +629,18 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<StorageReclaimSection
|
||||
reclaim={reclaim}
|
||||
checking={reclaimChecking}
|
||||
saving={reclaimSaving}
|
||||
confirming={reclaimConfirming}
|
||||
onConfirm={(request) => void handleReclaimConfirm(request)}
|
||||
onCheck={() => void handleReclaimCheck()}
|
||||
onDelete={(enabled) => void handleReclaimDelete(enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Eraser, Loader2, ScanSearch } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
import { relativeTime } from '../../../utils/relativeTime';
|
||||
import Section from '../../Settings/Section';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { SETTINGS_BUTTON, SettingRow, SettingRows, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { ReclaimCount, ReclaimView } from './reclaimModel';
|
||||
|
||||
interface Props {
|
||||
reclaim: ReclaimView;
|
||||
/** A check was asked for or is running. */
|
||||
checking: boolean;
|
||||
/** The switch is on its way to the server. */
|
||||
saving: boolean;
|
||||
onCheck: () => void;
|
||||
onDelete: (enabled: boolean) => void;
|
||||
/** The category whose confirmation is on its way. */
|
||||
confirming: ReclaimView['lines'][number]['category'] | null;
|
||||
onConfirm: (request: { category: ReclaimView['lines'][number]['category']; unreferenced: number }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unreferenced files on the desktop storage panel: what the last check found,
|
||||
* the switch that lets the nightly run delete them, a check on demand and the
|
||||
* confirmation a category with an unusually large share of unused files waits
|
||||
* for. The phone shows the same through MStorageReclaimCard, off the same view model.
|
||||
*/
|
||||
export default function StorageReclaimSection({
|
||||
reclaim,
|
||||
checking,
|
||||
saving,
|
||||
onCheck,
|
||||
onDelete,
|
||||
confirming,
|
||||
onConfirm,
|
||||
}: Props): React.ReactElement {
|
||||
const { t, locale } = useTranslation();
|
||||
const count = (c: ReclaimCount) => t('storage.usage.line', { count: c.objects, size: formatBytes(c.bytes) });
|
||||
const checked = reclaim.checkedAt !== null;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('storage.reclaim.title')}
|
||||
icon={Eraser}
|
||||
hint={t('storage.reclaim.hint')}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
onClick={onCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
) : (
|
||||
<ScanSearch size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
{checking ? t('storage.reclaim.running') : t('storage.reclaim.check')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('storage.reclaim.delete')}
|
||||
hint={t('storage.reclaim.deleteHint')}
|
||||
dimmed={saving}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={reclaim.deleteEnabled}
|
||||
onToggle={() => onDelete(!reclaim.deleteEnabled)}
|
||||
label={t('storage.reclaim.delete')}
|
||||
disabled={saving}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="px-3.5 py-3" data-testid="storage-reclaim-summary">
|
||||
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||
{reclaim.checkedAt !== null
|
||||
? t('storage.reclaim.checked', { age: relativeTime(reclaim.checkedAt, locale) })
|
||||
: t('storage.reclaim.never')}
|
||||
</p>
|
||||
{reclaim.stopped && (
|
||||
<p role="note" className="m-0 mt-1.5 leading-snug text-warning" style={fs(11.5)}>
|
||||
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
|
||||
</p>
|
||||
)}
|
||||
{checked && (
|
||||
<div className="mt-2.5 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<Figure label={t('storage.reclaim.freeable')} value={count(reclaim.freeable)} />
|
||||
<Figure label={t('storage.reclaim.waiting')} value={count(reclaim.waiting)} />
|
||||
<Figure label={t('storage.reclaim.deleted')} value={count(reclaim.freed)} />
|
||||
</div>
|
||||
)}
|
||||
{checked && !reclaim.stopped && reclaim.lines.length === 0 && (
|
||||
<SettingsHint className="mt-2.5">{t('storage.reclaim.nothing')}</SettingsHint>
|
||||
)}
|
||||
{reclaim.lines.length > 0 && (
|
||||
<ul className="m-0 mt-3 flex list-none flex-col gap-2 p-0">
|
||||
{reclaim.lines.map((line) => (
|
||||
<li
|
||||
key={line.category}
|
||||
data-testid={`storage-reclaim-${line.category}`}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{t(`storage.category.${line.category}`)}
|
||||
</span>
|
||||
{line.freeable.objects > 0 && (
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{t('storage.reclaim.freeable')}: {count(line.freeable)}
|
||||
</span>
|
||||
)}
|
||||
{line.waiting.objects > 0 && (
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{t('storage.reclaim.waiting')}: {count(line.waiting)}
|
||||
</span>
|
||||
)}
|
||||
{line.skipped && (
|
||||
<StatusPill tone="warning">{t(`storage.reclaim.skip.${line.skipped}`)}</StatusPill>
|
||||
)}
|
||||
</div>
|
||||
{line.confirmationRequired && (
|
||||
<div className="flex flex-wrap items-center gap-2.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5">
|
||||
<p className="m-0 min-w-0 flex-1 basis-56 leading-snug text-warning" style={fs(11.5)}>
|
||||
{t('storage.reclaim.confirmNeeded')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
|
||||
disabled={confirming === line.category}
|
||||
>
|
||||
{confirming === line.category && (
|
||||
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
|
||||
)}
|
||||
{t('storage.reclaim.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingRows>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[12px] bg-surface-secondary px-3 py-2">
|
||||
<p className="m-0 text-content-faint" style={fs(11)}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="m-0 mt-0.5 font-geist font-semibold tabular-nums text-content" style={fs(12.5, 'body')}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
|
||||
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimReport } from '@trek/shared';
|
||||
|
||||
import { reclaimView } from './reclaimModel';
|
||||
|
||||
const none = { objects: 0, bytes: 0 };
|
||||
|
||||
function category(patch: Partial<StorageReclaimCategoryReport> = {}): StorageReclaimCategoryReport {
|
||||
return {
|
||||
objects: 0,
|
||||
unreferenced: none,
|
||||
eligible: none,
|
||||
deleted: none,
|
||||
confirmationRequired: false,
|
||||
skipped: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function report(
|
||||
patch: Partial<Record<(typeof STORAGE_RECLAIM_CATEGORIES)[number], StorageReclaimCategoryReport>>,
|
||||
mode: StorageReclaimReport['mode'] = 'report'
|
||||
): StorageReclaimReport {
|
||||
const categories = Object.fromEntries(
|
||||
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, category({ skipped: 'not_reclaimed' })])
|
||||
) as StorageReclaimReport['categories'];
|
||||
return { startedAt: 1_000, finishedAt: 2_000, mode, stopped: null, categories: { ...categories, ...patch } };
|
||||
}
|
||||
|
||||
describe('reclaimView', () => {
|
||||
it('FE-ADMIN-STOR-RECLAIM-001: no state reads as deletion off, not running and never checked', () => {
|
||||
expect(reclaimView(null)).toEqual({
|
||||
deleteEnabled: false,
|
||||
running: false,
|
||||
checkedAt: null,
|
||||
stopped: null,
|
||||
freeable: none,
|
||||
waiting: none,
|
||||
freed: none,
|
||||
freedAt: null,
|
||||
lines: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-002: freeable is what is a week old, waiting what is younger, summed over categories', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: true,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: report({
|
||||
covers: category({
|
||||
objects: 40,
|
||||
unreferenced: { objects: 5, bytes: 500 },
|
||||
eligible: { objects: 3, bytes: 300 },
|
||||
}),
|
||||
places: category({ objects: 10, unreferenced: { objects: 1, bytes: 100 } }),
|
||||
}),
|
||||
});
|
||||
expect(view.deleteEnabled).toBe(true);
|
||||
expect(view.checkedAt).toBe(2_000);
|
||||
expect(view.freeable).toEqual({ objects: 3, bytes: 300 });
|
||||
expect(view.waiting).toEqual({ objects: 3, bytes: 300 });
|
||||
expect(view.lines.map((line) => line.category)).toEqual(['covers', 'places']);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-003: freed comes from the last run with deletion on, which a check on demand does not replace', () => {
|
||||
const deleting = report(
|
||||
{
|
||||
files: category({
|
||||
unreferenced: { objects: 2, bytes: 20 },
|
||||
eligible: { objects: 2, bytes: 20 },
|
||||
deleted: { objects: 2, bytes: 20 },
|
||||
}),
|
||||
},
|
||||
'delete'
|
||||
);
|
||||
const view = reclaimView({
|
||||
deleteEnabled: true,
|
||||
running: false,
|
||||
lastDeleteRun: { ...deleting, finishedAt: 900 },
|
||||
lastRun: report({}),
|
||||
});
|
||||
expect(view.freed).toEqual({ objects: 2, bytes: 20 });
|
||||
expect(view.freedAt).toBe(900);
|
||||
expect(view.freeable).toEqual(none);
|
||||
expect(view.lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-004: a skipped category shows its reason and counts toward nothing, one never reclaimed shows nothing', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: false,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: report({ avatars: category({ skipped: 'foreign_install', unreferenced: { objects: 9, bytes: 9 } }) }),
|
||||
});
|
||||
expect(view.lines).toEqual([
|
||||
{
|
||||
category: 'avatars',
|
||||
freeable: none,
|
||||
waiting: none,
|
||||
confirmationRequired: false,
|
||||
unreferenced: 9,
|
||||
skipped: 'foreign_install',
|
||||
},
|
||||
]);
|
||||
expect(view.waiting).toEqual(none);
|
||||
expect(view.lines.some((line) => line.category === 'backups')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-005: a pass that stopped says why, and a category it stopped in keeps its partial figures', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: false,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: {
|
||||
...report({ covers: category({ skipped: 'interrupted', unreferenced: { objects: 4, bytes: 40 } }) }),
|
||||
stopped: 'failed',
|
||||
},
|
||||
});
|
||||
expect(view.stopped).toBe('failed');
|
||||
expect(view.waiting).toEqual({ objects: 4, bytes: 40 });
|
||||
expect(view.lines[0]).toMatchObject({ category: 'covers', skipped: 'interrupted' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-006: a category waiting for a confirmation shows even with nothing old enough yet', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: true,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: report({ places: category({ objects: 10, confirmationRequired: true }) }),
|
||||
});
|
||||
expect(view.lines).toEqual([
|
||||
{ category: 'places', freeable: none, waiting: none, confirmationRequired: true, unreferenced: 0, skipped: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-007: a running check reads as running', () => {
|
||||
expect(reclaimView({ deleteEnabled: false, running: true, lastRun: null, lastDeleteRun: null }).running).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
STORAGE_RECLAIM_CATEGORIES,
|
||||
type StorageReclaimCategoryReport,
|
||||
type StorageReclaimReport,
|
||||
type StorageReclaimState,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A number of files and their size, as the report counts them. */
|
||||
export interface ReclaimCount {
|
||||
objects: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** What one category of the last check has to say, when it has anything to say. */
|
||||
interface ReclaimCategoryLine {
|
||||
category: (typeof STORAGE_RECLAIM_CATEGORIES)[number];
|
||||
/** Unused for a week or more: what the nightly run deletes once deletion is on. */
|
||||
freeable: ReclaimCount;
|
||||
/** Unused for less than a week: these get their week first. */
|
||||
waiting: ReclaimCount;
|
||||
/**
|
||||
* An unusually large share of the category looks unused: nothing in it is
|
||||
* deleted until an admin confirms that this is expected (a cleaned-up
|
||||
* backlog, say, rather than a second installation in the same bucket).
|
||||
*/
|
||||
confirmationRequired: boolean;
|
||||
/** Everything the check found unused in the category, the count a confirmation is given for. */
|
||||
unreferenced: number;
|
||||
/** Why the category was left alone, or stopped part way; null when it was checked through. */
|
||||
skipped: Exclude<StorageReclaimCategoryReport['skipped'], 'not_reclaimed'> | null;
|
||||
}
|
||||
|
||||
/** The admin's view of the reclaim pass: the switch and what the last check found. */
|
||||
export interface ReclaimView {
|
||||
deleteEnabled: boolean;
|
||||
/** A check, nightly or on demand, is running now. */
|
||||
running: boolean;
|
||||
/** When the last check finished, ms; null before the first one. */
|
||||
checkedAt: number | null;
|
||||
/** Why the last check stopped before the end; null when it ran through. */
|
||||
stopped: StorageReclaimReport['stopped'];
|
||||
freeable: ReclaimCount;
|
||||
waiting: ReclaimCount;
|
||||
/** What the last run with deletion on removed: a check on demand only reports, so it never resets this. */
|
||||
freed: ReclaimCount;
|
||||
/** When that run finished, ms; null when nothing ever ran with deletion on. */
|
||||
freedAt: number | null;
|
||||
/** Only the categories with something to free, something waiting, something to confirm, or a reason they were skipped. */
|
||||
lines: ReclaimCategoryLine[];
|
||||
}
|
||||
|
||||
const NONE: ReclaimCount = { objects: 0, bytes: 0 };
|
||||
|
||||
function add(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
|
||||
return { objects: a.objects + b.objects, bytes: a.bytes + b.bytes };
|
||||
}
|
||||
|
||||
function minus(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
|
||||
return { objects: Math.max(0, a.objects - b.objects), bytes: Math.max(0, a.bytes - b.bytes) };
|
||||
}
|
||||
|
||||
function freedBy(run: StorageReclaimReport | null): ReclaimCount {
|
||||
if (!run) return NONE;
|
||||
return STORAGE_RECLAIM_CATEGORIES.reduce(
|
||||
(sum, category) => add(sum, run.categories[category]?.deleted ?? NONE),
|
||||
NONE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reclaim state as the storage panels show it, desktop and phone alike.
|
||||
* `null` (a server from before the reclaim pass, or a state not loaded) reads
|
||||
* as deletion off, not running and never checked.
|
||||
*/
|
||||
/**
|
||||
* One category of the last check as a line, or null when it has nothing to
|
||||
* say. A category the pass stopped in still counts what it found; one it
|
||||
* never judged counts nothing.
|
||||
*/
|
||||
function lineOf(
|
||||
category: ReclaimCategoryLine['category'],
|
||||
report: StorageReclaimCategoryReport | undefined
|
||||
): ReclaimCategoryLine | null {
|
||||
if (!report || report.skipped === 'not_reclaimed') return null;
|
||||
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
|
||||
// What was deleted on this run no longer waits to be freed.
|
||||
const freeable = judged ? minus(report.eligible, report.deleted) : NONE;
|
||||
const waiting = judged ? minus(report.unreferenced, report.eligible) : NONE;
|
||||
const line = {
|
||||
category,
|
||||
freeable,
|
||||
waiting,
|
||||
confirmationRequired: report.confirmationRequired,
|
||||
unreferenced: report.unreferenced.objects,
|
||||
skipped: report.skipped,
|
||||
};
|
||||
const quiet = freeable.objects === 0 && waiting.objects === 0 && !line.confirmationRequired && line.skipped === null;
|
||||
return quiet ? null : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reclaim state as the storage panels show it, desktop and phone alike.
|
||||
* `null` (a server from before the reclaim pass, or a state not loaded) reads
|
||||
* as deletion off, not running and never checked.
|
||||
*/
|
||||
export function reclaimView(state: StorageReclaimState | null): ReclaimView {
|
||||
const run = state?.lastRun ?? null;
|
||||
const lines = STORAGE_RECLAIM_CATEGORIES.flatMap((category) => lineOf(category, run?.categories[category]) ?? []);
|
||||
const freeable = lines.reduce((sum, line) => add(sum, line.freeable), NONE);
|
||||
const waiting = lines.reduce((sum, line) => add(sum, line.waiting), NONE);
|
||||
const deleteRun = state?.lastDeleteRun ?? null;
|
||||
return {
|
||||
deleteEnabled: state?.deleteEnabled ?? false,
|
||||
running: state?.running ?? false,
|
||||
checkedAt: run?.finishedAt ?? null,
|
||||
stopped: run?.stopped ?? null,
|
||||
freeable,
|
||||
waiting,
|
||||
freed: freedBy(deleteRun),
|
||||
freedAt: deleteRun?.finishedAt ?? null,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,7 @@ const STATE: StorageAdminState = {
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
|
||||
@@ -14,6 +14,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
|
||||
categories: { files: { backend: 'uploads-local', source: 'default' } } as StorageAdminState['categories'],
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
|
||||
import type {
|
||||
StorageAdminState,
|
||||
StorageBackend,
|
||||
StorageConfig,
|
||||
StorageConfigPut,
|
||||
StorageReclaimConfirmRequest,
|
||||
StorageTestResponse,
|
||||
} from '@trek/shared'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { storageReclaimApi } from '../../../api/storageReclaim'
|
||||
import { getApiErrorMessage, type ApiError } from '../../../types'
|
||||
import { settingsDocumentOf } from './storageModel'
|
||||
|
||||
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
|
||||
|
||||
/** A refused reclaim call: the server's code when it sent one the panel can name, its message otherwise. */
|
||||
export type ReclaimFailure = { code: string } | { message: string }
|
||||
|
||||
function reclaimFailureOf(err: unknown, fallback: string): ReclaimFailure {
|
||||
const code = (err as ApiError | null)?.response?.data as { code?: unknown } | undefined
|
||||
return typeof code?.code === 'string' && code.code.startsWith('reclaim_')
|
||||
? { code: code.code }
|
||||
: { message: getApiErrorMessage(err, fallback) }
|
||||
}
|
||||
|
||||
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
|
||||
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
|
||||
|
||||
@@ -58,6 +76,12 @@ export interface StorageAdmin {
|
||||
startMigration: (category: string, to: string) => Promise<string | null>
|
||||
cancelMigration: (category: string) => Promise<string | null>
|
||||
refreshStats: () => Promise<string | null>
|
||||
/** Switches the nightly deletion of unreferenced files on or off; null on success. */
|
||||
setReclaimDelete: (enabled: boolean) => Promise<ReclaimFailure | null>
|
||||
/** Starts a check that only reports, in the background; the state polls until it is done. Null on success. */
|
||||
checkReclaim: () => Promise<ReclaimFailure | null>
|
||||
/** Lets the nightly run delete in a category where an unusually large share looks unused. Null on success. */
|
||||
confirmReclaim: (request: StorageReclaimConfirmRequest) => Promise<ReclaimFailure | null>
|
||||
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
|
||||
refreshState: () => Promise<void>
|
||||
/**
|
||||
@@ -271,7 +295,8 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
useEffect(() => {
|
||||
if (
|
||||
!state?.backfills.some((b) => b.status === 'running') &&
|
||||
!state?.migrations.some((m) => m.status === 'running')
|
||||
!state?.migrations.some((m) => m.status === 'running') &&
|
||||
!state?.reclaim?.running
|
||||
)
|
||||
return
|
||||
const iv = setInterval(() => {
|
||||
@@ -356,6 +381,71 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
}
|
||||
}, [genericError])
|
||||
|
||||
/**
|
||||
* Writes the reclaim part of the state. It counts as a deliberate state
|
||||
* application, so a poll GET that was in flight before it is dropped instead
|
||||
* of putting the switch back where it was.
|
||||
*/
|
||||
const setReclaim = useCallback((reclaim: NonNullable<StorageAdminState['reclaim']>) => {
|
||||
const prev = stateRef.current
|
||||
if (!prev) return
|
||||
stateSeq.current += 1
|
||||
const next = { ...prev, reclaim }
|
||||
stateRef.current = next
|
||||
setState(next)
|
||||
}, [])
|
||||
|
||||
const setReclaimDelete = useCallback(
|
||||
async (enabled: boolean): Promise<ReclaimFailure | null> => {
|
||||
try {
|
||||
setReclaim(await storageReclaimApi.setDelete(enabled))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return reclaimFailureOf(err, genericError)
|
||||
}
|
||||
},
|
||||
[genericError, setReclaim],
|
||||
)
|
||||
|
||||
const checkReclaim = useCallback(async (): Promise<ReclaimFailure | null> => {
|
||||
try {
|
||||
await storageReclaimApi.check()
|
||||
// Running from here on: the poll above picks the report up once the pass is done.
|
||||
const current = stateRef.current?.reclaim
|
||||
setReclaim({
|
||||
deleteEnabled: current?.deleteEnabled ?? false,
|
||||
running: true,
|
||||
lastRun: current?.lastRun ?? null,
|
||||
lastDeleteRun: current?.lastDeleteRun ?? null,
|
||||
})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return reclaimFailureOf(err, genericError)
|
||||
}
|
||||
}, [genericError, setReclaim])
|
||||
|
||||
const confirmReclaim = useCallback(
|
||||
async (request: StorageReclaimConfirmRequest): Promise<ReclaimFailure | null> => {
|
||||
try {
|
||||
setReclaim(await storageReclaimApi.confirm(request))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
const failure = reclaimFailureOf(err, genericError)
|
||||
// The count moved on since it was shown: show the current one before the admin confirms again.
|
||||
if ('code' in failure && failure.code === 'reclaim_confirm_stale') {
|
||||
try {
|
||||
await refreshState()
|
||||
} catch (reloadErr: unknown) {
|
||||
// Without the new figure there is nothing to confirm against: say why instead.
|
||||
return reclaimFailureOf(reloadErr, genericError)
|
||||
}
|
||||
}
|
||||
return failure
|
||||
}
|
||||
},
|
||||
[genericError, setReclaim, refreshState],
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
storageBusy,
|
||||
@@ -376,6 +466,9 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
startMigration,
|
||||
cancelMigration,
|
||||
refreshStats,
|
||||
setReclaimDelete,
|
||||
checkReclaim,
|
||||
confirmReclaim,
|
||||
refreshState,
|
||||
discardDraft,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-STOR-PANEL-001 to -017: the storage panel logic behind both admin shells.
|
||||
// FE-ADMIN-STOR-PANEL-001 to -020: the storage panel logic behind both admin shells.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
|
||||
@@ -28,6 +28,7 @@ function baseState(): StorageAdminState {
|
||||
) as StorageAdminState['categories'],
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
@@ -57,6 +58,9 @@ function makeAdmin(state: StorageAdminState | null, draft?: StorageConfig): Stor
|
||||
startMigration: vi.fn(async () => null),
|
||||
cancelMigration: vi.fn(async () => null),
|
||||
refreshStats: vi.fn(async () => null),
|
||||
setReclaimDelete: vi.fn(async () => null),
|
||||
checkReclaim: vi.fn(async () => null),
|
||||
confirmReclaim: vi.fn(async () => null),
|
||||
refreshState: vi.fn(async () => {}),
|
||||
discardDraft: vi.fn(async () => {}),
|
||||
};
|
||||
@@ -282,3 +286,48 @@ describe('migrationPromptLine', () => {
|
||||
).toBe('storage.migrate.promptLineUnknown {"category":"storage.category.covers","from":"a","to":"b"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStoragePanel: unreferenced files', () => {
|
||||
it('FE-ADMIN-STOR-PANEL-018: the reclaim switch and the check go through the admin hook, a failure as a toast', async () => {
|
||||
fake.admin = makeAdmin(baseState());
|
||||
vi.mocked(fake.admin.checkReclaim).mockResolvedValueOnce({ code: 'reclaim_busy' });
|
||||
vi.mocked(fake.admin.setReclaimDelete).mockResolvedValueOnce({ message: 'disk full' });
|
||||
const { result } = renderHook(() => useStoragePanel());
|
||||
await act(async () => {
|
||||
await result.current.handleReclaimDelete(true);
|
||||
});
|
||||
expect(fake.admin.setReclaimDelete).toHaveBeenCalledWith(true);
|
||||
// A failure without a code the panel knows shows the server's message.
|
||||
expect(toast.error).toHaveBeenCalledWith('disk full');
|
||||
expect(result.current.reclaimSaving).toBe(false);
|
||||
await act(async () => {
|
||||
await result.current.handleReclaimCheck();
|
||||
});
|
||||
expect(fake.admin.checkReclaim).toHaveBeenCalledTimes(1);
|
||||
// A code the panel knows is shown in the operator's language.
|
||||
expect(toast.error).toHaveBeenCalledWith('storage.reclaim.error.reclaim_busy');
|
||||
expect(result.current.reclaimChecking).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-PANEL-020: confirming a category goes through the admin hook and says so; a running check keeps the button busy', async () => {
|
||||
const state = baseState();
|
||||
state.reclaim = { deleteEnabled: true, running: true, lastRun: null, lastDeleteRun: null };
|
||||
fake.admin = makeAdmin(state);
|
||||
const { result } = renderHook(() => useStoragePanel());
|
||||
expect(result.current.reclaimChecking).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.handleReclaimConfirm({ category: 'places', unreferenced: 3 });
|
||||
});
|
||||
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith({ category: 'places', unreferenced: 3 });
|
||||
expect(result.current.reclaimConfirming).toBeNull();
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.reclaim.confirmed');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-PANEL-019: a server without the reclaim state reads as deletion off and never checked', () => {
|
||||
fake.admin = makeAdmin(baseState());
|
||||
const { result } = renderHook(() => useStoragePanel());
|
||||
expect(result.current.reclaim.deleteEnabled).toBe(false);
|
||||
expect(result.current.reclaim.checkedAt).toBeNull();
|
||||
expect(result.current.reclaim.lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,13 @@ import type {
|
||||
StorageConfig,
|
||||
StorageConfigPut,
|
||||
StorageMigrationStatus,
|
||||
StorageReclaimConfirmRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { reclaimView } from './reclaimModel';
|
||||
import {
|
||||
adoptedMirrorFor,
|
||||
computeMigrationCandidates,
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
type FoldedBackendRow,
|
||||
type MigrationCandidate,
|
||||
} from './storageModel';
|
||||
import { useStorageAdmin } from './useStorageAdmin';
|
||||
import { useStorageAdmin, type ReclaimFailure } from './useStorageAdmin';
|
||||
|
||||
/** Display-name mapper for joined category lists: the raw id renders only in the badge. */
|
||||
export const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
@@ -165,6 +167,35 @@ export function useStoragePanel() {
|
||||
if (error) toast.error(error);
|
||||
};
|
||||
|
||||
// The click itself, until the server answered; the pass that follows shows through `reclaim.running`.
|
||||
const [reclaimStarting, setReclaimStarting] = useState(false);
|
||||
const [reclaimSaving, setReclaimSaving] = useState(false);
|
||||
const reclaimError = (failure: ReclaimFailure) =>
|
||||
'code' in failure ? t(`storage.reclaim.error.${failure.code}`) : failure.message;
|
||||
const handleReclaimCheck = async () => {
|
||||
setReclaimStarting(true);
|
||||
const failure = await admin.checkReclaim();
|
||||
setReclaimStarting(false);
|
||||
if (failure) toast.error(reclaimError(failure));
|
||||
};
|
||||
const handleReclaimDelete = async (enabled: boolean) => {
|
||||
setReclaimSaving(true);
|
||||
const failure = await admin.setReclaimDelete(enabled);
|
||||
setReclaimSaving(false);
|
||||
if (failure) toast.error(reclaimError(failure));
|
||||
};
|
||||
// The category whose confirmation is on its way, so its button waits for the answer.
|
||||
const [reclaimConfirming, setReclaimConfirming] = useState<StorageReclaimConfirmRequest['category'] | null>(null);
|
||||
const handleReclaimConfirm = async (request: StorageReclaimConfirmRequest) => {
|
||||
setReclaimConfirming(request.category);
|
||||
const failure = await admin.confirmReclaim(request);
|
||||
setReclaimConfirming(null);
|
||||
if (failure) toast.error(reclaimError(failure));
|
||||
else toast.success(t('storage.reclaim.confirmed'));
|
||||
};
|
||||
const reclaim = reclaimView(admin.state?.reclaim ?? null);
|
||||
const reclaimChecking = reclaimStarting || reclaim.running;
|
||||
|
||||
const handleStartBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return;
|
||||
setSyncPrompt((prev) => (prev === row.name ? null : prev));
|
||||
@@ -371,6 +402,13 @@ export function useStoragePanel() {
|
||||
migrationQueue,
|
||||
handleCancelMigration,
|
||||
handleRefreshStats,
|
||||
reclaim,
|
||||
reclaimChecking,
|
||||
reclaimSaving,
|
||||
reclaimConfirming,
|
||||
handleReclaimCheck,
|
||||
handleReclaimDelete,
|
||||
handleReclaimConfirm,
|
||||
handleStartBackfill,
|
||||
handleCancelBackfill,
|
||||
loaded,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TREK_API_SOURCES, trekApiFacts, trekApiFields } from './trekApiModel';
|
||||
import {
|
||||
TREK_API_SOURCES,
|
||||
trekApiAddressFeatures,
|
||||
trekApiFacts,
|
||||
trekApiFields,
|
||||
trekApiRoutingFeatures,
|
||||
} from './trekApiModel';
|
||||
|
||||
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-003
|
||||
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-004
|
||||
|
||||
const t = (key: string) => `<${key}>`;
|
||||
|
||||
@@ -22,10 +28,12 @@ describe('trekApiModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-002: leads with four facts, each with a glyph', () => {
|
||||
it('FE-TREKAPIMODEL-002: leads with the three services, then the terms, each with a glyph', () => {
|
||||
const facts = trekApiFacts(t);
|
||||
expect(facts.map((f) => f.text)).toEqual([
|
||||
'<admin.trekApi.factPlaces>',
|
||||
'<admin.trekApi.factAddresses>',
|
||||
'<admin.trekApi.factRouting>',
|
||||
'<admin.trekApi.factNoKey>',
|
||||
'<admin.trekApi.factOffline>',
|
||||
'<admin.trekApi.factPrivacy>',
|
||||
@@ -34,6 +42,28 @@ describe('trekApiModel', () => {
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-003: names the data sources untranslated', () => {
|
||||
expect(TREK_API_SOURCES).toEqual(['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']);
|
||||
expect(TREK_API_SOURCES).toEqual([
|
||||
'Overture Maps Foundation',
|
||||
'OpenStreetMap',
|
||||
'Wikivoyage',
|
||||
'Wikimedia',
|
||||
'Mapzen Terrain Tiles',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-004: lists what the address search and the routing answer', () => {
|
||||
expect(trekApiAddressFeatures(t)).toEqual([
|
||||
'<admin.trekApi.addrStreets>',
|
||||
'<admin.trekApi.addrTowns>',
|
||||
'<admin.trekApi.addrReverse>',
|
||||
'<admin.trekApi.addrLanguages>',
|
||||
]);
|
||||
expect(trekApiRoutingFeatures(t)).toEqual([
|
||||
'<admin.trekApi.routeCar>',
|
||||
'<admin.trekApi.routeBike>',
|
||||
'<admin.trekApi.routeFoot>',
|
||||
'<admin.trekApi.routeAvoid>',
|
||||
'<admin.trekApi.routeElevation>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Globe2, KeyRound, ShieldOff, WifiOff } from 'lucide-react';
|
||||
import { Globe2, KeyRound, Route, Search, ShieldOff, WifiOff } from 'lucide-react';
|
||||
|
||||
import type { TranslationFn } from '../../types';
|
||||
|
||||
/**
|
||||
* Proper nouns, so they are not translated. Naming them is also a licence obligation,
|
||||
* not decoration: ODbL and CC BY-SA both require attribution wherever their content is
|
||||
* shown.
|
||||
* shown, and the terrain tiles the routes take their heights from ask to be named too.
|
||||
*/
|
||||
export const TREK_API_SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia'];
|
||||
export const TREK_API_SOURCES = [
|
||||
'Overture Maps Foundation',
|
||||
'OpenStreetMap',
|
||||
'Wikivoyage',
|
||||
'Wikimedia',
|
||||
'Mapzen Terrain Tiles',
|
||||
];
|
||||
|
||||
/**
|
||||
* The fields a TREK Places API result carries, for the desktop card and the phone block
|
||||
@@ -29,10 +35,33 @@ export function trekApiFields(t: TranslationFn): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** The four facts the TREK Places API block leads with, each with its glyph. */
|
||||
/** What the address search answers: an address typed, a town, the address of a point. */
|
||||
export function trekApiAddressFeatures(t: TranslationFn): string[] {
|
||||
return [
|
||||
t('admin.trekApi.addrStreets'),
|
||||
t('admin.trekApi.addrTowns'),
|
||||
t('admin.trekApi.addrReverse'),
|
||||
t('admin.trekApi.addrLanguages'),
|
||||
];
|
||||
}
|
||||
|
||||
/** What the routing does: the three ways of getting there, what a drive can avoid, the heights. */
|
||||
export function trekApiRoutingFeatures(t: TranslationFn): string[] {
|
||||
return [
|
||||
t('admin.trekApi.routeCar'),
|
||||
t('admin.trekApi.routeBike'),
|
||||
t('admin.trekApi.routeFoot'),
|
||||
t('admin.trekApi.routeAvoid'),
|
||||
t('admin.trekApi.routeElevation'),
|
||||
];
|
||||
}
|
||||
|
||||
/** The facts the TREK API block leads with, each with its glyph: the three services first, then the terms. */
|
||||
export function trekApiFacts(t: TranslationFn) {
|
||||
return [
|
||||
{ Icon: Globe2, text: t('admin.trekApi.factPlaces') },
|
||||
{ Icon: Search, text: t('admin.trekApi.factAddresses') },
|
||||
{ Icon: Route, text: t('admin.trekApi.factRouting') },
|
||||
{ Icon: KeyRound, text: t('admin.trekApi.factNoKey') },
|
||||
{ Icon: WifiOff, text: t('admin.trekApi.factOffline') },
|
||||
{ Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
|
||||
* cards render their own markup over it.
|
||||
*/
|
||||
export function useAuditLog(serverTimezone?: string) {
|
||||
const { locale } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
|
||||
import { backupApi } from '../../api/client';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { t, locale } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||
|
||||
const loadBackups = async () => {
|
||||
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
|
||||
minute: '2-digit',
|
||||
};
|
||||
if (serverTimezone) opts.timeZone = serverTimezone;
|
||||
return new Date(dateStr).toLocaleString(locale, opts);
|
||||
return formatDateTime(new Date(dateStr), opts);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl';
|
||||
import {
|
||||
defaultStyleForProvider,
|
||||
@@ -11,7 +12,13 @@ import {
|
||||
normalizeStyleForProvider,
|
||||
styleSettingKey,
|
||||
} from '../Map/glProviders';
|
||||
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
|
||||
import {
|
||||
type MapProvider,
|
||||
mapboxOffered,
|
||||
mapPresetsFor,
|
||||
normalizeProvider,
|
||||
styleForProvider,
|
||||
} from '../Settings/mapSettingsModel';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import type { RoutingDefaults } from './RoutingInstanceFields';
|
||||
|
||||
@@ -21,6 +28,7 @@ export type Defaults = RoutingDefaults & {
|
||||
dark_mode?: string | boolean;
|
||||
time_format?: string;
|
||||
week_start?: WeekStart;
|
||||
date_format?: DateFormat;
|
||||
default_currency?: string;
|
||||
blur_booking_codes?: boolean;
|
||||
map_tile_url?: string;
|
||||
@@ -53,6 +61,9 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mapTileUrl, setMapTileUrl] = useState('');
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
|
||||
// The operator's token reaches the admin's own settings the same way it reaches everybody's.
|
||||
const operatorMapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token);
|
||||
const [mapboxToken, setMapboxToken] = useState('');
|
||||
const [cartoKey, setCartoKey] = useState('');
|
||||
const [mapboxStyle, setMapboxStyle] = useState('');
|
||||
@@ -153,6 +164,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
defaults,
|
||||
loaded,
|
||||
managed,
|
||||
presets: mapPresetsFor(managed, hasAmapKey),
|
||||
canMapbox: mapboxOffered(managed, operatorMapboxToken),
|
||||
isSet,
|
||||
save,
|
||||
reset,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// FE-ADMIN-CONTRIBHOOK-001
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { PlaceContribState } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { placeContribRepo } from '../../repo/placeContribRepo';
|
||||
import { usePlaceContrib } from './usePlaceContrib';
|
||||
|
||||
const STATE = { ineligibleReason: null, active: true } as unknown as PlaceContribState;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('usePlaceContrib', () => {
|
||||
it('FE-ADMIN-CONTRIBHOOK-001: a withdrawal that went through is answered even when the state cannot be read again', async () => {
|
||||
const state = vi.spyOn(placeContribRepo, 'state').mockResolvedValueOnce(STATE);
|
||||
vi.spyOn(placeContribRepo, 'withdraw').mockResolvedValue({ withdrawn: { website: 2 }, cleared: 1 });
|
||||
const { result } = renderHook(() => usePlaceContrib());
|
||||
await waitFor(() => expect(result.current.state).toBe(STATE));
|
||||
|
||||
state.mockRejectedValueOnce(new Error('offline'));
|
||||
let answer: unknown;
|
||||
await act(async () => {
|
||||
answer = await result.current.withdraw();
|
||||
});
|
||||
|
||||
expect(answer).toEqual({ withdrawn: { website: 2 }, cleared: 1 });
|
||||
expect(result.current.state).toBe(STATE);
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { placeContribRepo } from '../../repo/placeContribRepo';
|
||||
|
||||
const KEY_RETRIES = 6;
|
||||
const KEY_RETRY_MS = 3_000;
|
||||
|
||||
/**
|
||||
* The admin's decision on sharing place details. Read once when the settings
|
||||
* tab opens, so the prompt can come up without the card being scrolled to;
|
||||
* every decision answers the new state, which replaces the old one.
|
||||
*/
|
||||
export function usePlaceContrib() {
|
||||
const [state, setState] = useState<PlaceContribState | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// A fresh installation fetches its key on first use, so the first answer can
|
||||
// be no_key; asking again a few times lets the prompt appear once it landed.
|
||||
const read = (tries: number) => {
|
||||
placeContribRepo.state(controller.signal).then(
|
||||
(next) => {
|
||||
setState(next);
|
||||
if (next.ineligibleReason === 'no_key' && tries > 1) timer = setTimeout(() => read(tries - 1), KEY_RETRY_MS);
|
||||
},
|
||||
() => {
|
||||
if (!controller.signal.aborted) setFailed(true);
|
||||
}
|
||||
);
|
||||
};
|
||||
read(KEY_RETRIES);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const decide = useCallback(async (body: PlaceContribDecisionRequest): Promise<PlaceContribState> => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await placeContribRepo.decide(body);
|
||||
setState(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const withdraw = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await placeContribRepo.withdraw();
|
||||
// The withdrawal went through; a failed re-read keeps the old state rather than reporting it failed.
|
||||
await placeContribRepo.state().then(setState, () => undefined);
|
||||
return result;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { state, failed, busy, decide, withdraw };
|
||||
}
|
||||
@@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => {
|
||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||
// An admin's own "run it anyway" for this one plugin says so, not "version checks off".
|
||||
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||
warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||
label: 'admin.plugins.dep.trekAcceptedUnknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
||||
it('bypassOffer: an enabled Install that carries what the dialog must say first', () => {
|
||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||
expect(offer).toEqual({
|
||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
||||
blocked: false, label: 'admin.plugins.install', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null },
|
||||
})
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true })
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
|
||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => {
|
||||
const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} }
|
||||
expect(rangeWarningCopy(mine, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin',
|
||||
body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({
|
||||
body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}',
|
||||
confirmLabel: 'admin.plugins.activateAnyway',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n'
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
||||
/**
|
||||
* The server's "this only went through because the version check was set aside" marker:
|
||||
* by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE.
|
||||
*/
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean }
|
||||
/**
|
||||
* What the range-bypass warning needs to say, and what to do if the admin accepts (null = a
|
||||
* notice). `action` names what the confirm button does: install (the default) or activate.
|
||||
*/
|
||||
export interface RangeWarning extends TrekRangeBypass {
|
||||
name: string
|
||||
onConfirm: (() => void) | null
|
||||
action?: 'install' | 'activate'
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip a row carries while it runs outside its declared range on the operator's
|
||||
@@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm:
|
||||
*/
|
||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||
if (!b) return null
|
||||
const label = b.acceptedByAdmin
|
||||
? b.trekRange
|
||||
? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekAcceptedUnknown')
|
||||
: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown')
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
||||
label,
|
||||
blocked: false,
|
||||
warn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Install button for a registry entry the server flagged incompatible, once the
|
||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
* The Install button for a registry entry the server flagged incompatible: the server will
|
||||
* take the newest version regardless, so the button says exactly that — and carries what
|
||||
* the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
|
||||
* plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin.
|
||||
* `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
*/
|
||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
||||
return {
|
||||
blocked: false,
|
||||
label: t('admin.plugins.installAnyway'),
|
||||
title,
|
||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
||||
export function bypassOffer(
|
||||
item: { name: string; trek?: string | null; hostVersion?: string },
|
||||
t: T,
|
||||
title: string,
|
||||
byAdmin = false
|
||||
) {
|
||||
const warn: RangeWarning = {
|
||||
name: item.name,
|
||||
trekRange: item.trek ?? null,
|
||||
hostVersion: item.hostVersion ?? '?',
|
||||
acceptedByAdmin: byAdmin,
|
||||
onConfirm: null,
|
||||
}
|
||||
return { blocked: false, label: t('admin.plugins.install'), title, warn }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers
|
||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||
* that, in rare cases, that can cost them data.
|
||||
*/
|
||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
||||
export function rangeWarningCopy(
|
||||
w: RangeWarning,
|
||||
t: T
|
||||
): { title: string; body: string; confirm: boolean; confirmLabel: string } {
|
||||
const confirm = w.onConfirm !== null
|
||||
const vars = { name: w.name, range: w.trekRange, host: w.hostVersion }
|
||||
const body = w.acceptedByAdmin
|
||||
? w.trekRange
|
||||
? t('admin.plugins.rangeBypass.bodyAdmin', vars)
|
||||
: t('admin.plugins.rangeBypass.bodyAdminUnknown', vars)
|
||||
: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', vars)
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', vars)
|
||||
const title = w.acceptedByAdmin
|
||||
? t('admin.plugins.rangeBypass.titleAdmin')
|
||||
: confirm
|
||||
? t('admin.plugins.rangeBypass.title')
|
||||
: t('admin.plugins.rangeBypass.noticeTitle')
|
||||
return {
|
||||
confirm,
|
||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
||||
body: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
||||
title,
|
||||
body,
|
||||
confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-ADMIN-SVCID-HOOK-001 to FE-ADMIN-SVCID-HOOK-004
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ServiceIdentityStatus } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useServiceIdentity } from './useServiceIdentity';
|
||||
|
||||
const { status, regenerate, visible } = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
visible: { current: true },
|
||||
}));
|
||||
|
||||
vi.mock('../../repo/serviceIdentityRepo', () => ({ serviceIdentityRepo: { status, regenerate } }));
|
||||
vi.mock('../../hooks/useElementVisible', () => ({ useElementVisible: () => visible.current }));
|
||||
|
||||
const LEGACY: ServiceIdentityStatus = {
|
||||
state: 'legacy',
|
||||
tier: 'legacy',
|
||||
keySource: null,
|
||||
services: [],
|
||||
lastSeenAt: null,
|
||||
};
|
||||
const card = { current: null };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
visible.current = true;
|
||||
status.mockReset().mockResolvedValue(LEGACY);
|
||||
regenerate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useServiceIdentity', () => {
|
||||
it('FE-ADMIN-SVCID-HOOK-001: reads once in view, then every 30 seconds', async () => {
|
||||
const { result } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {});
|
||||
expect(result.current.status).toEqual(LEGACY);
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-HOOK-002: reads nothing while the card is out of view, and stops when it leaves', async () => {
|
||||
visible.current = false;
|
||||
const { rerender } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
});
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
|
||||
visible.current = true;
|
||||
rerender();
|
||||
await act(async () => {});
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
visible.current = false;
|
||||
rerender();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
});
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-HOOK-003: marks a failed read, and clears the mark on the next good one', async () => {
|
||||
status.mockRejectedValueOnce(new Error('offline'));
|
||||
const { result } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {});
|
||||
expect(result.current.failed).toBe(true);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(result.current.failed).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-HOOK-004: a new identity replaces the status, and a refusal reaches the caller', async () => {
|
||||
const registering = { ...LEGACY, state: 'registering' as const };
|
||||
regenerate.mockResolvedValueOnce(registering).mockRejectedValueOnce(new Error('409'));
|
||||
const { result } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {
|
||||
await result.current.regenerate();
|
||||
});
|
||||
expect(result.current.status).toEqual(registering);
|
||||
await act(async () => {
|
||||
await expect(result.current.regenerate()).rejects.toThrow('409');
|
||||
});
|
||||
expect(result.current.regenerating).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ServiceIdentityStatus } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState, type RefObject } from 'react';
|
||||
import { useElementVisible } from '../../hooks/useElementVisible';
|
||||
import { serviceIdentityRepo } from '../../repo/serviceIdentityRepo';
|
||||
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The installation's standing with the TREK services for the admin card:
|
||||
* read when the card comes into view, then every 30 seconds while it stays
|
||||
* there. Admin configuration, online only like the rest of the settings tab.
|
||||
*/
|
||||
export function useServiceIdentity(card: RefObject<Element | null>) {
|
||||
const visible = useElementVisible(card);
|
||||
const [status, setStatus] = useState<ServiceIdentityStatus | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const controller = new AbortController();
|
||||
const refresh = () => {
|
||||
serviceIdentityRepo.status(controller.signal).then(
|
||||
(next) => {
|
||||
setStatus(next);
|
||||
setFailed(false);
|
||||
},
|
||||
() => {
|
||||
if (!controller.signal.aborted) setFailed(true);
|
||||
}
|
||||
);
|
||||
};
|
||||
refresh();
|
||||
const timer = setInterval(refresh, REFRESH_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
const regenerate = useCallback(async (): Promise<void> => {
|
||||
setRegenerating(true);
|
||||
try {
|
||||
setStatus(await serviceIdentityRepo.regenerate());
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { status, failed, regenerating, regenerate };
|
||||
}
|
||||
@@ -440,6 +440,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
currency: { labelKey: 'assistant.change.label.currency' },
|
||||
stopType: { labelKey: 'assistant.change.label.stopType' },
|
||||
text: { labelKey: 'assistant.change.label.text' },
|
||||
title: { labelKey: 'assistant.change.label.title' },
|
||||
content: { labelKey: 'assistant.change.label.content' },
|
||||
icon: { labelKey: 'assistant.change.label.icon' },
|
||||
color: { labelKey: 'assistant.change.label.color' },
|
||||
position: { labelKey: 'assistant.change.label.position' },
|
||||
|
||||
@@ -20,7 +20,10 @@ const trip = (id: number, start: string | null, end: string | null) => ({
|
||||
end_date: end,
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rankPickerTrips', () => {
|
||||
it('FE-AI-PICK-001: the running trip leads, then the soonest to come, then the latest past one', () => {
|
||||
@@ -59,6 +62,9 @@ describe('rankPickerTrips', () => {
|
||||
|
||||
describe('useAssistantTripPicker', () => {
|
||||
it('FE-AI-PICK-004: loads the trips once and ranks them; a failed load offers none', async () => {
|
||||
// The hook ranks against the real clock; pin it so trip 7 keeps running.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(`${TODAY}T12:00:00`));
|
||||
const list = vi
|
||||
.spyOn(tripRepo, 'list')
|
||||
.mockResolvedValueOnce({ trips: [trip(7, '2026-10-01', '2026-10-30') as Trip], archivedTrips: [] });
|
||||
|
||||
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
|
||||
// the 801.76 in the dialog. The pill beside the line is the last step.
|
||||
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
// Row total and payer chip stay the booked euros at today's rate, and so does what
|
||||
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
|
||||
// had Bob pay back more than he owed and reopened the trip.
|
||||
expect(screen.getAllByText('$791.55')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
|
||||
expect(screen.queryByText('you lent $400.88')).toBeNull()
|
||||
// A bill in the display currency counts as typed (#2965), as the balances net it:
|
||||
// row total and payer chip read the dollars entered, nothing to explain under it,
|
||||
// and what I lent is half of them, which settle-up offers too.
|
||||
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
|
||||
expect(screen.getAllByText('$801.76')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||||
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
await waitFor(() => expect(exported).toBeTruthy())
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
|
||||
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
|
||||
} finally {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
}
|
||||
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
|
||||
// $910.02, on the day header and on the total spend card.
|
||||
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
|
||||
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
|
||||
// $921.76, on the day header and on the total spend card.
|
||||
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
|
||||
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
|
||||
// Label, its text block, the header row, then the card itself.
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(card.textContent).toContain('$910.02')
|
||||
expect(card.textContent).toContain('$921.76')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||||
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
|
||||
mount([hotel()], {
|
||||
settlements: [
|
||||
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
|
||||
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
// The hotel's split chips can carry the same figure, so look past them to the payment.
|
||||
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
|
||||
expect(outsideChips('$395.77')).toHaveLength(1)
|
||||
expect(await screen.findByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
expect(outsideChips('$400.88')).toHaveLength(1)
|
||||
expect(outsideChips('$30.00')).toHaveLength(1)
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
|
||||
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
|
||||
// The list reads the dollars typed, so there is no conversion to preview.
|
||||
expect(screen.queryByText(/live rate/)).toBeNull()
|
||||
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
|
||||
|
||||
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
|
||||
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
|
||||
// Entered in the display currency, it reads as entered (#2965).
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
|
||||
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import { readDateFormat } from '../../hooks/useDateFormat';
|
||||
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { formatDisplayDate } from '../../utils/dateFormat';
|
||||
import { currencyDecimals } from '../../utils/formatters';
|
||||
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
|
||||
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
|
||||
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
|
||||
return (e.currency || ctx.tripCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
|
||||
/**
|
||||
* An amount of this expense in the display currency, at the rate the expense was booked at;
|
||||
* as typed when it was entered in the display currency (#2965).
|
||||
*/
|
||||
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
|
||||
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
|
||||
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
|
||||
return (s.currency || ctx.displayCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
|
||||
/**
|
||||
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
|
||||
* as entered when it was made in the display currency (#2965).
|
||||
*/
|
||||
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
|
||||
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
const currency = paymentCurrency(s, ctx);
|
||||
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
|
||||
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
|
||||
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
const fmtDate = (iso: string) => {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
return formatDisplayDate(
|
||||
new Date(iso + 'T00:00:00Z'),
|
||||
opts.locale,
|
||||
{
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
readDateFormat()
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
|
||||
|
||||
describe('expenseFxPreview', () => {
|
||||
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
|
||||
expect(fx.inTrip).toBeCloseTo(685.26, 2)
|
||||
expect(fx.shown).toBeCloseTo(791.55, 2)
|
||||
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
|
||||
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
|
||||
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
|
||||
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
|
||||
|
||||
describe('splitShareLabel', () => {
|
||||
it('prints the entered share, then the share the row lends or borrows', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
|
||||
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
|
||||
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
|
||||
// Read in the trip currency, the share is the booked euros.
|
||||
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { currencyDecimals, formatMoney } from '../../utils/formatters'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
|
||||
@@ -33,8 +33,8 @@ export interface ExpenseFx {
|
||||
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
|
||||
* frozen when the expense was entered (the server re-freezes only when the currency changes),
|
||||
* so it is previewed at that rate, the way the list will show it after the save. Anything
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
|
||||
* in the display currency that still reads back as typed.
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
|
||||
* the display currency, which counts as typed (#2965).
|
||||
*/
|
||||
export function expenseFxPreview(args: {
|
||||
total: number
|
||||
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
|
||||
convert: (amount: number, from: string | null | undefined) => number
|
||||
}): ExpenseFx | null {
|
||||
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
|
||||
if (total === 0) return null
|
||||
if (total === 0 || currency === base) return null
|
||||
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
|
||||
if (currency === base && inTrip == null) return null
|
||||
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
|
||||
const unit = 10 ** currencyDecimals(base)
|
||||
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
|
||||
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
|
||||
onConfirm,
|
||||
}: DawarichAcceptDialogProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const { formatDateTime } = useFormatDate()
|
||||
const phone = useIsPhone()
|
||||
const labelId = useId()
|
||||
|
||||
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-caption text-content">
|
||||
{t('dawarich.accept.recorded', {
|
||||
from: new Date(suggestion.startedAt).toLocaleString(locale),
|
||||
from: formatDateTime(new Date(suggestion.startedAt)),
|
||||
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { frameLine, frameLng } from '../../utils/lngFrame';
|
||||
import { useLngFrame } from '../Map/lngFrameContext';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
@@ -81,6 +83,8 @@ export default function FerrataLayerLeaflet({
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
const paint = useFerrataPaint();
|
||||
// Drawn in the map's longitude frame, with the trip (#2975).
|
||||
const frame = useLngFrame();
|
||||
const iconGroups = useIconGroups(groups);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
|
||||
@@ -110,7 +114,7 @@ export default function FerrataLayerLeaflet({
|
||||
group.segments.map((segment) => ({
|
||||
group,
|
||||
segment,
|
||||
positions: ferrataPositions(segment),
|
||||
positions: frameLine(frame, ferrataPositions(segment)),
|
||||
casing,
|
||||
litCasing,
|
||||
line,
|
||||
@@ -118,7 +122,7 @@ export default function FerrataLayerLeaflet({
|
||||
hit,
|
||||
}))
|
||||
);
|
||||
}, [groups, paint]);
|
||||
}, [groups, paint, frame]);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
|
||||
@@ -163,7 +167,7 @@ export default function FerrataLayerLeaflet({
|
||||
{iconGroups.map((group) => (
|
||||
<Marker
|
||||
key={`${group.id}-icon`}
|
||||
position={group.midpoint}
|
||||
position={frame ? [group.midpoint[0], frameLng(frame, group.midpoint[1])] : group.midpoint}
|
||||
icon={ICON}
|
||||
keyboard={false}
|
||||
eventHandlers={{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { downloadFile } from '../../utils/fileDownload'
|
||||
import { readDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
|
||||
export function isImage(mimeType?: string | null) {
|
||||
if (!mimeType) return false
|
||||
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
|
||||
downloadFile(url, filename).catch(() => {})
|
||||
}
|
||||
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user