Compare commits

..

35 Commits

Author SHA1 Message Date
jubnl edb54a9f11 Publish trek-3.1.1.tgz 2026-06-18 18:14:22 +00:00
mauriceboe e127ae667f Publish trek-3.1.0.tgz 2026-06-16 20:23:47 +00:00
mauriceboe 6d19615aa8 Publish trek-3.0.22.tgz 2026-05-24 23:14:01 +00:00
jubnl bd00477249 Publish trek-3.0.21.tgz 2026-05-15 22:53:27 +00:00
jubnl e7f04fa58b Publish trek-3.0.20.tgz 2026-05-13 08:35:48 +00:00
jubnl 82ce51d259 Publish trek-3.0.19.tgz 2026-05-13 08:13:52 +00:00
jubnl 3c94d4c1cc Publish trek-3.0.18.tgz 2026-05-10 14:03:41 +00:00
jubnl 8119cabe8a Publish trek-3.0.17.tgz 2026-05-07 11:50:07 +00:00
jubnl 4d48151d13 Publish trek-3.0.16.tgz 2026-05-06 19:39:24 +00:00
jubnl 39011687e2 Publish trek-3.0.15.tgz 2026-05-04 12:22:39 +00:00
jubnl 17793d5f60 Publish trek-3.0.14.tgz 2026-05-03 15:40:22 +00:00
jubnl ce6140b527 Publish trek-3.0.13.tgz 2026-04-30 23:44:12 +00:00
jubnl 9b49d284ba Publish trek-3.0.12.tgz 2026-04-28 22:17:31 +00:00
jubnl 9c3ddc119c Publish trek-3.0.11.tgz 2026-04-28 03:17:50 +00:00
jubnl 240c17bef1 Publish trek-3.0.10.tgz 2026-04-26 22:22:47 +00:00
Julien G. 8472c74914 Remove trek chart entry for version 3.0.10
Removed entry for trek version 3.0.10.
2026-04-27 00:19:16 +02:00
jubnl e85457a0cd Publish trek-3.0.10.tgz 2026-04-26 22:15:31 +00:00
mauriceboe 0464db0c41 Publish trek-3.0.9.tgz 2026-04-26 19:59:48 +00:00
jubnl 18cb0f2367 Publish trek-3.0.8.tgz 2026-04-23 17:50:07 +00:00
jubnl 5ff85a87bb Publish trek-3.0.7.tgz 2026-04-23 09:08:03 +00:00
jubnl 42a6cc1152 Publish trek-3.0.6.tgz 2026-04-23 08:54:03 +00:00
jubnl c4a3492b42 Publish trek-3.0.5.tgz 2026-04-23 08:07:35 +00:00
jubnl 14675d6cd1 Publish trek-3.0.4.tgz 2026-04-23 07:14:04 +00:00
jubnl 9aa0faca2e Publish trek-3.0.3.tgz 2026-04-22 20:17:06 +00:00
jubnl fd2acff978 Publish trek-3.0.2.tgz 2026-04-22 19:25:56 +00:00
mauriceboe ef06203543 Publish trek-3.0.1.tgz 2026-04-22 17:58:37 +00:00
mauriceboe 29bc4c0fa0 Publish trek-3.0.0.tgz 2026-04-22 16:59:27 +00:00
jubnl 2c1164c6af Publish trek-2.9.14.tgz 2026-04-15 05:33:58 +00:00
jubnl dd361b2974 Publish trek-2.9.13.tgz 2026-04-11 13:26:57 +00:00
Julien G. 30f75b07d2 Delete trek-2.10.0.tgz 2026-04-10 17:31:21 +02:00
Julien G. 13ea5f80dd Remove trek version 2.10.0 from index.yaml
Removed outdated entry for trek version 2.10.0.
2026-04-10 17:31:04 +02:00
jubnl 9c00fd9188 Publish trek-2.9.12.tgz 2026-04-10 05:51:35 +00:00
jubnl 2d0c66a47b Publish trek-2.9.12.tgz 2026-04-10 05:44:48 +00:00
jubnl 35ace54d1c Publish trek-2.10.0.tgz 2026-04-10 05:36:28 +00:00
jubnl 7e14828a2e chore: initialize gh-pages branch 2026-04-10 07:35:25 +02:00
945 changed files with 256 additions and 325045 deletions
-37
View File
@@ -1,37 +0,0 @@
node_modules
client/node_modules
server/node_modules
client/dist
shared/dist
data
uploads
.git
.github
**/.env
**/.env.*
*.log
*.md
!client/**/*.md
chart/
docs/
docker-compose.yml
unraid-template.xml
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.db
*.db-shm
*.db-wal
**/coverage
.DS_Store
Thumbs.db
.vscode
.idea
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
wiki/
scripts/
charts/
-27
View File
@@ -1,27 +0,0 @@
# Normalize line endings to LF on commit
* text=auto eol=lf
# Explicitly enforce LF for source files
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.py text eol=lf
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.pdf binary
*.zip binary
-2
View File
@@ -1,2 +0,0 @@
ko_fi: mauriceboe
buy_me_a_coffee: mauriceboe
-111
View File
@@ -1,111 +0,0 @@
name: Bug Report
description: Create a report to help us improve TREK
title: "[BUG] "
labels: []
body:
- type: checkboxes
id: preflight
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input
id: version
attributes:
label: TREK version
description: Found in the Settings → About, or in the Docker image tag
placeholder: "e.g. 2.8.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
placeholder: When I do X, Y happens instead of Z…
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Step-by-step instructions to reliably trigger the bug.
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment method
options:
- Docker Compose
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Proxmox Community Script
- Sources
- Other
validations:
required: true
- type: input
id: os
attributes:
label: Host OS
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
- type: dropdown
id: user_os
attributes:
label: Accessing TREK from
options:
- Desktop browser
- Mobile browser
- Mobile app (PWA)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser (if applicable)
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
- type: textarea
id: logs
attributes:
label: Relevant logs or error output
description: Paste any relevant server or browser console output here.
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Drag and drop screenshots here if applicable.
- type: textarea
id: context
attributes:
label: Additional context
description: Anything else that might help us understand the issue.
-11
View File
@@ -1,11 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/mauriceboe/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/mauriceboe/TREK/discussions
about: For questions and general help, use Discussions instead
-21
View File
@@ -1,21 +0,0 @@
## Description
<!-- What does this PR do? Why? -->
## Related Issue or Discussion
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed
@@ -1,71 +0,0 @@
name: Close issues with unchanged bad titles
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v7
with:
script: |
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'invalid-title',
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const issue of issues) {
const createdAt = new Date(issue.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
const titleLower = issue.title.trim().toLowerCase();
if (!badTitles.includes(titleLower)) {
// Title was fixed — remove the label and move on
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'invalid-title',
});
continue;
}
// Still a bad title after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'## Issue closed',
'',
'This issue has been automatically closed because the title was not updated within 24 hours.',
'',
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
}
@@ -1,93 +0,0 @@
name: Close PRs with unchanged wrong base branch
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
pull-requests: write
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v7
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) {
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
if (hasBypass) continue;
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
// Wiki-only PRs are exempt — clear label and skip
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
// Base was fixed — remove label and move on
if (pull.base.ref !== 'main') {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
// Still targeting main after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
body: [
'## PR closed',
'',
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
'',
'Feel free to open a new PR targeting `dev`.',
].join('\n'),
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
state: 'closed',
});
}
@@ -1,92 +0,0 @@
name: Flag issues with bad titles
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
check-title:
runs-on: ubuntu-latest
steps:
- name: Flag or redirect issue
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const titleLower = title.toLowerCase();
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const featureRequestTitles = [
"feature request", "[feature]", "[feature request]", "[enhancement]"
];
if (badTitles.includes(titleLower)) {
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
color: 'e4e669',
description: 'Issue title does not meet quality standards',
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ['invalid-title'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: [
'## Invalid title',
'',
`Your issue title \`${title}\` is too generic to be actionable.`,
'',
'Please edit the title to something descriptive that summarizes the problem — for example:',
'> _Map view crashes when zooming in on Safari 17_',
'',
'**This issue will be automatically closed in 24 hours if the title has not been updated.**',
].join('\n'),
});
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
}
-183
View File
@@ -1,183 +0,0 @@
name: Build & Push Docker Image (Prerelease)
on:
workflow_dispatch:
inputs:
bump:
description: 'Bump line for next prerelease (auto detects in-flight major)'
type: choice
options: [auto, minor, major]
default: auto
permissions:
contents: write
concurrency:
group: prerelease-build
cancel-in-progress: false
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine prerelease version
id: bump
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
TARGET="${MAJOR}.$((MINOR + 1)).0"
fi
echo "Target: $TARGET"
# Find the highest existing prerelease N for this target and increment
LAST_N=$(git tag -l "v${TARGET}-pre.*" | sed 's/.*-pre\.//' | sort -n | tail -1)
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
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 }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
docker buildx imagetools create \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
- name: Push git tag
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "v$VERSION"
git push origin "v$VERSION"
- name: Clean up old prerelease tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
KEEP=20
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
TOTAL=${#ALL_TAGS[@]}
DELETE_COUNT=$((TOTAL - KEEP))
if [ "$DELETE_COUNT" -gt 0 ]; then
for TAG in "${ALL_TAGS[@]:0:$DELETE_COUNT}"; do
echo "Deleting old prerelease tag: $TAG"
git push origin --delete "$TAG"
done
fi
-219
View File
@@ -1,219 +0,0 @@
name: Build & Push Docker Image
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
description: 'Force bump line (auto = patch/finalize as today)'
type: choice
options: [auto, patch, minor, major]
default: auto
confirm_major:
description: "Type MAJOR (all caps) to confirm a major release"
type: string
default: ''
permissions:
contents: write
concurrency:
group: stable-build
cancel-in-progress: false
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
run: |
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)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
NEW_VERSION="$((MAJOR + 1)).0.0"
BUMP="major"
elif [ "$BUMP_INPUT" = "minor" ]; then
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
BUMP="minor"
elif [ "$BUMP_INPUT" = "patch" ]; then
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
BUMP="patch"
else
# auto: finalize in-flight prerelease if one exists, else patch
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
fi
echo "Bump type: $BUMP"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
# 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
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
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 commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
build:
runs-on: ${{ matrix.runner }}
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: main
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
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 }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Download build digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
docker buildx imagetools create \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-latest
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
-137
View File
@@ -1,137 +0,0 @@
name: Enforce PR Target Branch
on:
pull_request_target:
types: [opened, reopened, edited, synchronize]
jobs:
check-target:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
contents: read
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v7
with:
script: |
const base = context.payload.pull_request.base.ref;
const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number;
// bypass-branch-check label skips all enforcement
if (labels.includes('bypass-branch-check')) {
console.log('bypass-branch-check label present, skipping enforcement.');
return;
}
// Wiki-only PRs are exempt from branch enforcement
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
console.log('All changed files are under wiki/ — skipping enforcement.');
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.pull_request.user.login,
});
permission = data.permission;
} catch (_) {
// User is not a collaborator — treat as 'none'
}
if (['admin', 'write'].includes(permission)) {
console.log(`User has '${permission}' permission, skipping.`);
return;
}
// Already labeled — avoid spamming on every push
if (labels.includes('wrong-base-branch')) {
core.setFailed("PR must target `dev`, not `main`.");
return;
}
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
});
} catch (err) {
if (err.status === 404) {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
color: 'd73a4a',
description: 'PR is targeting the wrong base branch',
});
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['wrong-base-branch'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'## Wrong target branch',
'',
'This PR targets `main`, but contributions must go through `dev` first.',
'',
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
'',
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
'',
'> _If you need to merge directly to `main`, contact a maintainer._',
].join('\n'),
});
core.setFailed("PR must target `dev`, not `main`.");
-37
View File
@@ -1,37 +0,0 @@
name: Security Scan
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
pull-requests: write
jobs:
scout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: trek:scan
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@v1
with:
command: cves
image: trek:scan
only-severities: critical,high
exit-code: true
-105
View File
@@ -1,105 +0,0 @@
name: Tests
permissions:
contents: read
on:
pull_request:
branches: [main, dev]
paths:
- 'server/**'
- 'client/**'
- 'shared/**'
- '.github/workflows/test.yml'
jobs:
shared-contracts:
name: Shared Contracts (Zod)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace server
- name: Build shared
run: npm run build --workspace=shared
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Typecheck (informational)
# Pre-existing type errors in the NestJS rewrite; surfaces them without
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
continue-on-error: true
run: cd server && npm run typecheck
- name: Run tests
run: cd server && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: backend-coverage
path: server/coverage/
retention-days: 7
client-tests:
name: Client Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests
run: cd client && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: frontend-coverage
path: client/coverage/
retention-days: 7
-26
View File
@@ -1,26 +0,0 @@
name: Deploy Wiki
on:
push:
branches: [main]
paths:
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
-68
View File
@@ -1,68 +0,0 @@
# Dependencies
node_modules/
# Build output
client/dist/
server/dist/
shared/dist/
server/public/*
!server/public/.gitkeep
# Generated PWA icons (built from SVG via prebuild)
client/public/icons/*.png
# Database
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
# User data
server/data/*
server/uploads/
# Environment
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
.claude/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
*.pid
*.seed
*.pid.lock
# Coverage
coverage
*.lcov
.nyc_output
# Cache
.npm
.eslintcache
.cache
*.tsbuildinfo
*.tgz
.scannerwork
test-data
.run
.full-review
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

-524
View File
@@ -1,524 +0,0 @@
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
# TREK 3.0.0
<video src="https://github.com/mauriceboe/trek-media/raw/main/.github/assets/TREK1.mp4" controls width="100%"></video>
> **The biggest TREK release to date.** A new Journey addon turns your trips into rich travel journals. Mapbox GL joins Leaflet as a first-class renderer. MCP gets a full OAuth 2.1 authorization server. Offline-first PWA, self-service password reset, and a dashboard redesigned from the ground up. Fifteen languages, top to bottom.
---
## Breaking Changes
### Photos moved from Trip Planner to Journey
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
**What this means for you:**
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
### New Immich API Key Permissions Required
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
**Previous versions required:**
| Permission | Used for |
|---|---|
| `user.read` | Connection test |
| `asset.read` | Browse photos by date, search |
| `asset.view` | Stream thumbnails |
| `asset.download` | Stream originals |
| `album.read` | List and browse albums |
| `timeline.read` | Browse timeline buckets |
**New in 3.0.0 — additionally required:**
| Permission | Used for |
|---|---|
| `asset.upload` | Sync uploaded Journey photos to Immich |
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
### OIDC_ONLY deprecated
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
---
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
## Journey Addon — Travel Journal
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
### Core
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
- **Entry reorder** — move-up / move-down arrows on each entry (desktop), skipped on skeleton suggestions
- **Hide skeletons toggle** — per-contributor setting to focus on the written entries only
### Photos
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
- **EXIF metadata** — displayed in lightbox for Immich photos
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
- **Safari gallery picker fix** — repaired grid layout collapse on Safari (#717)
### Sharing & Export
- **Public share links** — token-based access with language picker, no login required
- **Public photo proxy** — validates share token instead of auth for photo streaming
- **Thumbnail size in public gallery** — grid loads thumbnails instead of originals, lightbox keeps originals (cuts bandwidth on shared links significantly)
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
### Collaboration
- **Contributors** — invite users as editors or viewers
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
- **Cover image** — upload or pick from journey photos
### Frontend
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
- **JourneyPublicPage** — public share view with language picker and read-only timeline
---
## Mapbox GL as a First-Class Renderer
Leaflet gets a sibling. Users can now switch the trip planner map to **Mapbox GL JS** for a proper 3D globe, terrain, and 3D buildings.
- **Settings toggle** — choose between Leaflet and Mapbox GL in Settings > Map
- **Globe projection** — smooth rotating globe when zoomed out, mercator when zoomed in
- **3D terrain and buildings** — enabled on Standard and Satellite styles, with custom 3D buildings in dark/light mode
- **Trip route, GPX geometries, place markers** — full feature parity with the Leaflet renderer
- **Transport reservations overlay** — great-circle arcs for flights/cruises, straight lines for trains/cars, clickable endpoint badges with IATA codes, rotating mid-arc stats label for flights. Honours the per-booking "show route" toggle in DayPlanSidebar
- **Auto-fit on load** — planner map zooms to the trip's places on initial render
- **Booking route label toggle** — separate setting to hide IATA labels on endpoint markers
- **Infrastructure** — WebAssembly allowed in CSP for Mapbox GL's 3D engine, PWA precache limit raised so the mapbox-gl bundle builds, Mapbox endpoints allowed in `connect-src` / `img-src`
---
## MCP: OAuth 2.1 & Granular Scopes
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at `POST /oauth/register`, with strict redirect_uri validation (HTTPS / loopback / reverse-DNS private-use schemes only; rejects `javascript:` / `data:` / `file:` / etc.)
- **RFC 9728 Protected Resource Metadata** — `/.well-known/oauth-protected-resource` exposes the MCP endpoint's auth requirements for client auto-discovery
- **RFC 8707 audience binding** — tokens are audience-bound to `<app_url>/mcp` by default and validated on every MCP request
- **Consent screen** — user-facing scope selection with grouped permission display
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
- **Per-client rate limiting** — configurable rate limits per OAuth client
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
- **Compound tools** — single-call multi-step workflows (e.g. create day with places in one tool call, fetch full trip context) to reduce MCP round-trips
- **Surface alignment** — MCP tool schemas and responses kept in sync with the current app state (fewer drifted fields, correct enum sets)
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
- **Collab sub-feature gating** — MCP tools for chat/notes/polls respect the admin-level collab sub-feature toggles
---
## Self-Service Password Reset
Users can now reset their own password without admin intervention.
- **Email-based flow** — `/forgot-password` issues a single-use reset token delivered via SMTP (or logged to the server console if SMTP is not configured)
- **MFA-aware** — if the user has MFA enabled, the reset endpoint additionally verifies a TOTP code or backup code before rotating the password
- **Session invalidation** — resetting the password bumps `users.password_version`, which kicks every existing JWT, MCP static token, and OAuth bearer token for that user out in one shot
- **Server-side URL building** — the reset link is built from `APP_URL` / `ALLOWED_ORIGINS`, not from request headers, so a spoofed `Host` / `Origin` cannot redirect the link to an attacker-controlled domain
- **Rate limiting + audit** — per-IP rate limit on `/forgot-password`, all requests audited (including "no such user" so abuse is visible)
---
## Dashboard Redesign
The dashboard has been rebuilt with a mobile-first design language.
### Mobile
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
### Desktop
- **Unified header toolbar** — the dashboard, planner, vacay, and journey now share the same toolbar style
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
### Both
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
- **Dark mode** — full dark mode support across all new components
- **Shared PageSidebar** — Settings and Admin pages share a single sidebar component for layout consistency
---
## PWA Offline Mode
TREK now works offline as a Progressive Web App with full data synchronization.
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
- **Offline trip planner** — full planner functionality with cached data
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
- **Idempotency keys** — prevents duplicate mutations on replay, scoped by `(key, user_id, method, path)` so the same key on different endpoints can't leak cached bodies
- **Offline document downloads** — document downloads work from the PWA cache when the network is unavailable
---
## Transport Reservations: Multi-Day + Map Visualization
- **Multi-day transport reservations** — flights, trains, cruises, car rentals can span multiple days with a dedicated modal and automatic route segmentation across the affected days (#384, #587)
- **Map visualization** — transport endpoints render on both Leaflet and Mapbox GL maps as clickable badges with IATA codes, great-circle arcs for flights/cruises, straight lines for trains/cars, and a rotating mid-arc stats label (IATA → IATA · distance · duration) on flights
- **Per-booking route toggle** — each booking in DayPlanSidebar has a "Show booking routes" button; connections only render when toggled on
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new `check_in_end` field (#366)
- **Cascaded delete** — deleting a reservation now cleans up related budget items, file links, and trip_items
---
## Reservations Redesign
The reservations panel has been completely redesigned with a modern, unified layout.
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
---
## Apple Wallet pkpass Support
- **.pkpass MIME type** — server correctly serves `application/vnd.apple.pkpass` with the right Content-Type
- **Upload + download** — .pkpass files can be attached to bookings or places and opened directly in Apple Wallet on iOS
---
## Todo Due-Date Reminders
- **Scheduler** — a new background scheduler scans todos with upcoming due dates and sends one reminder per item (default lead: 3 days)
- **No spam** — `todo_items.reminded_at` prevents re-sending a reminder for the same item on subsequent scheduler runs
- **Notification channel aware** — reminders respect the user's notification channel preferences (email, webhook, ntfy)
---
## Collab Sub-Feature Toggles
Individual collab sections can now be toggled on/off from the admin addons page (#604).
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
- **Mobile** — disabled tabs are hidden from the tab bar
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
---
## Place Import: KMZ/KML + Naver Maps + Selective GPX
Three ways to import places into your trips.
### KMZ/KML Import
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
### Naver Maps List Import
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
- **Pagination support** — handles large Naver Maps lists with automatic pagination
### Selective GPX/KML Element Import
- **Pick what to import** — import modal now lets you choose individual waypoints / tracks / folders instead of an all-or-nothing dump
- **Performance** — larger files (thousands of points) parse and render without freezing the UI
---
## Search Autocomplete
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
- **Google Places API** — primary autocomplete provider with location bias
- **Nominatim fallback** — free fallback when Google API key is not configured
- **Bounding box bias** — search results biased to the current map viewport
---
## ntfy Notification Channel
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
- **Full i18n** — ntfy strings translated in all 15 languages
---
## Login & Language
- **Language dropdown on login page** — users can select their preferred language before logging in
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
---
## Granular Auth Toggles
- **OIDC_ONLY replaced** — split into `DISABLE_LOCAL_LOGIN`, `DISABLE_LOCAL_REGISTRATION`, and `DISABLE_PASSWORD_CHANGE` for fine-grained control over authentication methods
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
---
## Synology Photos: OTP, SSL Skip & Session Management
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
- **Skip SSL verification** — toggle for self-signed certificates
- **Device ID persistence** — prevents repeated 2FA prompts
- **Session-cleared notification** — routed through unified notification system
- **Provider URL hint** — contextual help text for Synology URL format
- **Thumbnail size bump** — default thumbnail size raised from `sm` (240 px) to `m` (320 px) so grids no longer look pixelated on retina
- **Passphrase support** — shared-album links with passphrases work from the browse UI (#689)
---
## Atlas Improvements
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
---
## i18n: Full 15-Language Coverage
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
- **Comprehensive audit** — every key translated natively, no English fallbacks
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
- **Mapbox GL settings** — localized labels for renderer toggle, style picker, 3D / quality switches
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
---
## Vacay Improvements
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
- **Holiday overlap** — vacations can now be placed on public holidays
- **Today marker** — visual indicator for the current day in the calendar
- **Unified toolbar** — same header style as planner/dashboard/journey
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
---
## iCal Export Improvements
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
---
## Budget Improvements
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
- **Category legend redesign** — prevents overflow on small screens (#564)
- **Comma decimal support** — pasting numbers with comma separators works correctly
- **Table alignment fix** — budget data rows and the "New Entry" row now share column widths (#759)
---
## Packing List Improvements
- **Bulk import + template apply without full reload** — new items appear in place instead of triggering the trip loading screen (#760)
- **Reservation link cleanup** — packing items linked to deleted reservations stay in the list without the dangling reference
- **Bag tracking** — keep track of which items live in which bag, with optional weight tracking and per-bag totals
---
## Planner & UX Improvements
- **Emil-style polish pass** — consistent transitions/animations across cards, hover states, and drawer sheets; shared components for toolbars and section headers
- **Planner drag-and-drop jank fix** — dragging places across days is smooth again on long trips
- **Unified toolbar header** — dashboard, planner, vacay, and journey share a single toolbar style for visual consistency
- **Places sidebar polish** — filter counts, compact select UI, tooltip component, "No Category" / "Uncategorized" filter (#607)
- **Dayplan toolbar polish** — cleaner alignment, weather archive fallback for past trips
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
- **File download button** — all file views now include a download button
- **Note modal** — no longer closes on outside click (#480)
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
- **Packing list menu** — no longer cut off by overflow (#557)
- **Trip date change** — preserving day content when date range changes
- **PDF export** — render restaurant, event, tour, and other reservation types
---
## Admin Panel Improvements
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
- **Naver List Import** — now always enabled, removed from addon toggles
- **Shared PageSidebar** — admin pages use the same sidebar layout as Settings
---
## Mobile Improvements
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes, drop hero / inline tab-bar, eager map tiles, trimmed picker labels
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
- **Bottom nav dark mode** — consistent dark mode styling
- **Safe area support** — proper insets for iOS PWA
---
## Documentation & Wiki
- **Full GitHub Wiki** — 74 pages covering setup, deployment, addon docs, troubleshooting, API reference, and MCP
- **CI sync workflow** — `./wiki/**` in the main repo is auto-synced to the GitHub Wiki on push to `main`
- **README redesign** — Apple-style hero with animated video, feature tiles, and a screenshot gallery; hero video hosted externally so the repo stays lightweight
- **MCP compound tools doc** — `MCP.md` documents the compound / multi-step tools
---
## Security
Fifth-pass internal audit. Critical + High + Medium findings addressed in one bundled PR:
- **JWT password_version gate** — a single `verifyJwtAndLoadUser` helper is now used by every auth surface (web session, MCP bearer, file download token, photo route, MFA policy). A password reset bumps `password_version` and invalidates every outstanding session/token for the user in one shot.
- **MFA policy via cookie** — `require_mfa` now applies to cookie-authenticated SPA sessions too (previously only the `Authorization` header was checked, so the whole SPA bypassed it).
- **OIDC id_token verification** — full JWKS-based signature verification (iss, aud, exp, nbf) plus `userinfo.sub == id_token.sub` cross-check. `kid` match is strict — no fallback to an arbitrary key.
- **OIDC invite redemption** — invite-token increment and user INSERT run in a single `db.transaction`; concurrent callbacks cannot double-redeem a single-use invite.
- **OAuth 2.1 DCR** — redirect_uri allowlist rejects `javascript:` / `data:` / `vbscript:` / `file:` / `blob:` / `about:` / `chrome:` and requires private-use schemes to be reverse-DNS (RFC 8252 §7.1).
- **OAuth audience binding** — `audience` defaults to the MCP endpoint when no `resource` parameter is sent, so new tokens always carry the correct audience claim.
- **HSTS on in production** — `NODE_ENV=production` is enough to enable HSTS (previously required `FORCE_HTTPS=true`). `includeSubDomains` stays off by default to avoid breaking apex-domain setups; opt in with `HSTS_INCLUDE_SUBDOMAINS=true`.
- **Cookie Secure behind proxies** — `trek_session` Secure flag is now derived from `req.secure` (Express's `trust proxy`-aware field), so instances behind Traefik / Caddy / Cloudflare Tunnel get Secure cookies without `FORCE_HTTPS`.
- **Share-token expiry** — public share tokens default to 90-day TTL. Existing tokens stay NULL (no expiry) so already-distributed links keep working.
- **Photo route scoping** — share tokens can only unlock photos that belong to the same trip as the token.
- **Bcrypt MFA backup codes** — backup codes are now bcrypt-hashed at rest. Legacy SHA-256 codes keep working until the user regenerates.
- **Demo-mode guards** — single `DEMO_EMAILS` registry fixes the drift where `demoUploadBlock` only matched the pre-rename `demo@nomad.app` string.
- **Filesystem safety** — `permanentDeleteFile` / `emptyTrash` / avatar cleanup use async `fs.promises.rm({ force: true })` and only drop the DB row when the on-disk unlink actually succeeded.
- **Idempotency store hardening** — key length capped at 128 chars, response bodies over 256 KiB not cached, primary key widened to `(key, user_id, method, path)` so the same key on a different endpoint does not replay an unrelated response.
- **Permissions cache invalidation** — `restoreFromZip` now drops the permissions cache after a DB swap.
- **Reset-URL source** — password-reset email URL is built from server-side `APP_URL` / `ALLOWED_ORIGINS`, never from request headers.
- **Critical DB indexes** — added `trips(user_id)`, `trips(created_at DESC)`, `photos(day_id/place_id)`, `reservations(day_id)`, `share_tokens(token)` and conditional `day_accommodations` / `notifications` indexes.
Upstream CVEs patched:
- **hono** 4.12.9 to 4.12.12 — directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), IP restriction bypass (CVE-2026-39409)
- **@hono/node-server** 1.19.11 to 1.19.13 — directory traversal (CVE-2026-39406)
- **nodemailer** 8.0.4 to 8.0.5 — CRLF injection
---
## Bug Fixes
- Fixed OIDC-only mode login/logout loop (#491)
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
- Fixed booking date handling and file auth bugs
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
- Fixed streaming response end on client disconnect during asset pipe
- Fixed per-day transport positions for multi-day reservations
- Fixed stale budget category reset when category no longer exists
- Fixed trip redirect to plan tab when active tab addon is disabled
- Fixed reservation price/budget field visibility when budget addon disabled
- Fixed HEIC photo rendering on non-Safari browsers
- Fixed CSP path matching for paths ending in /
- Fixed avatar URLs in notifications, admin panel, and budget
- Fixed budget member avatars lost after updating item fields
- Fixed budget table column alignment broken by `display: flex` on `<td>` (#759)
- Fixed collab notes line break preservation (#608)
- Fixed weather archive date handling for future trips (#599)
- Fixed duplicate skeleton entries for multi-day places (#606)
- Fixed ghost Gallery / `[Trip Photos]` entries in journal timeline and public share (#764)
- Fixed journey reorder arrows rendering on skeleton suggestions (#763)
- Fixed journey map OSM tile warning (#627)
- Fixed journey gallery picker grid collapse on Safari (#717)
- Fixed content divider placement in journal entries (#624)
- Fixed local photos wrong provider label (#625)
- Fixed Synology pagination and album scroll leak (#644)
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
- Fixed Nominatim User-Agent and error diagnostics
- Fixed map tooltips, journey creation, and contributor avatars
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
- Fixed stale accommodation_id on reservation update (#522)
- Fixed hardcoded Immich in toast — now uses provider_name
- Fixed MCP safeBroadcast recursive call bug
- Fixed MCP Zod v4 `z.record()` API compatibility in transport tool schemas
- Fixed Vite module preload polyfill CSP inline script violation
- Fixed PWA offline session redirect and file download auth (#505, #541)
- Fixed `FORCE_HTTPS` redirect applying to `/api/health`, breaking container health-checks
- Fixed journey bugs reported by @roel-de-vries (#722#736)
---
## Infrastructure
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
- **Helm chart** — moved to `charts/trek/`, published via helm-publisher action to `gh-pages`, `appVersion` used as default image tag
- **Docker** — workflow improvements, tag management cleanup, `server/data/airports.json` properly included in image after assets refactor
- **CI** — contributor workflow automation, `npm audit` removal from install steps, manual trigger for prerelease, client test job added alongside server tests with split coverage artifacts
---
## Test Coverage
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
- **Journey** — 89.5% new code coverage
---
## Contributors
Thanks to everyone who contributed to this release:
- @mauriceboe
- @jubnl
- @gravitysc
- @luojiyin1987
- @marco783
- @isaiastavares
- @tiquis0290
- @xenocent
- @gfrcsd
- @roel-de-vries
---
## Stats
| Metric | Value |
|--------|-------|
| Commits | 500+ |
| Merged PRs | 130+ |
| Files changed | 700+ |
| Lines added | 120,000+ |
| Contributors | 12+ |
---
## Upgrading
```bash
docker pull mauriceboe/trek:3.0.0
docker compose up -d
```
Migrations run automatically on startup. No manual steps required.
**Checklist:**
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
3. Enable the Journey addon in Settings > Addons to start using the travel journal
4. Try the Mapbox GL renderer in Settings > Map if you want 3D terrain and a proper globe view (requires a free Mapbox access token)
-405
View File
@@ -1,405 +0,0 @@
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
# TREK 3.0.0
> **This is the biggest TREK release to date.** Journey turns your trips into rich travel journals. MCP gets full OAuth 2.1 security. The dashboard has been redesigned for mobile-first. And every corner of the app now speaks 15 languages natively.
---
## Breaking Changes
### Photos moved from Trip Planner to Journey
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
**What this means for you:**
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
### New Immich API Key Permissions Required
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
**Previous versions required:**
| Permission | Used for |
|---|---|
| `user.read` | Connection test |
| `asset.read` | Browse photos by date, search |
| `asset.view` | Stream thumbnails |
| `asset.download` | Stream originals |
| `album.read` | List and browse albums |
| `timeline.read` | Browse timeline buckets |
**New in 3.0.0 — additionally required:**
| Permission | Used for |
|---|---|
| `asset.upload` | Sync uploaded Journey photos to Immich |
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
### OIDC_ONLY deprecated
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
---
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
## Journey Addon — Travel Journal
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
### Core
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
### Photos
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
- **EXIF metadata** — displayed in lightbox for Immich photos
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
### Sharing & Export
- **Public share links** — token-based access with language picker, no login required
- **Public photo proxy** — validates share token instead of auth for photo streaming
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
### Collaboration
- **Contributors** — invite users as editors or viewers
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
- **Cover image** — upload or pick from journey photos
### Frontend
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
- **JourneyPublicPage** — public share view with language picker and read-only timeline
---
## MCP: OAuth 2.1 & Granular Scopes
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at POST /oauth/register for browser-initiated and public clients
- **Consent screen** — user-facing scope selection with grouped permission display
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
- **Per-client rate limiting** — configurable rate limits per OAuth client
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
- **Security hardening** — Critical + High + Medium findings addressed (token storage, PKCE enforcement, scope validation)
---
## Dashboard Redesign
The dashboard has been rebuilt with a mobile-first design language.
### Mobile
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
### Desktop
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
### Both
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
- **Dark mode** — full dark mode support across all new components
---
## PWA Offline Mode
TREK now works offline as a Progressive Web App with full data synchronization.
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
- **Offline trip planner** — full planner functionality with cached data
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
- **Idempotency keys** — prevents duplicate mutations on replay (Migration 100)
---
## Reservations Redesign
The reservations panel has been completely redesigned with a modern, unified layout.
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new check_in_end field (#366)
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
---
## Collab Sub-Feature Toggles
Individual collab sections can now be toggled on/off from the admin addons page (#604).
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
- **Mobile** — disabled tabs are hidden from the tab bar
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
---
## Place Import: KMZ/KML & Naver Maps
Two new ways to import places into your trips.
### KMZ/KML Import
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
### Naver Maps List Import
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
- **Pagination support** — handles large Naver Maps lists with automatic pagination
---
## Search Autocomplete
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
- **Google Places API** — primary autocomplete provider with location bias
- **Nominatim fallback** — free fallback when Google API key is not configured
- **Bounding box bias** — search results biased to the current map viewport
---
## ntfy Notification Channel
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
- **Full i18n** — ntfy strings translated in all 15 languages
---
## Login & Language
- **Language dropdown on login page** — users can select their preferred language before logging in
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
---
## Granular Auth Toggles
- **OIDC_ONLY replaced** — split into DISABLE_LOCAL_LOGIN, DISABLE_LOCAL_REGISTRATION, and DISABLE_PASSWORD_CHANGE for fine-grained control over authentication methods
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
---
## Synology Photos: OTP, SSL Skip & Session Management
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
- **Skip SSL verification** — toggle for self-signed certificates
- **Device ID persistence** — prevents repeated 2FA prompts
- **Session-cleared notification** — routed through unified notification system
- **Provider URL hint** — contextual help text for Synology URL format
---
## Atlas Improvements
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
---
## i18n: Full 15-Language Coverage
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
- **Comprehensive audit** — every key translated natively, no English fallbacks
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
---
## Vacay Improvements
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
- **Holiday overlap** — vacations can now be placed on public holidays
- **Today marker** — visual indicator for the current day in the calendar
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
---
## iCal Export Improvements
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
---
## Budget Improvements
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
- **Category legend redesign** — prevents overflow on small screens (#564)
- **Comma decimal support** — pasting numbers with comma separators works correctly
---
## Planner & UX Improvements
- **Collapsible day detail panel** — day detail panel can be collapsed/expanded in the planner
- **Uncategorized filter** — "No Category" option in category dropdown to find places without a category (#607)
- **Map multi-category filter** — filter syncs with map view for uncategorized places
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
- **File download button** — all file views now include a download button
- **Note modal** — no longer closes on outside click (#480)
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
- **Packing list menu** — no longer cut off by overflow (#557)
- **Trip date change** — preserving day content when date range changes
- **PDF export** — render restaurant, event, tour, and other reservation types
---
## Admin Panel Improvements
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
- **Naver List Import** — now always enabled, removed from addon toggles
---
## Mobile Improvements
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
- **Bottom nav dark mode** — consistent dark mode styling
- **Safe area support** — proper insets for iOS PWA
---
## Test Coverage
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
- **Journey** — 89.5% new code coverage
- **CI** — client test job added alongside server tests with split coverage artifacts
---
## Bug Fixes
- Fixed OIDC-only mode login/logout loop (#491)
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
- Fixed booking date handling and file auth bugs
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
- Fixed streaming response end on client disconnect during asset pipe
- Fixed per-day transport positions for multi-day reservations
- Fixed stale budget category reset when category no longer exists
- Fixed trip redirect to plan tab when active tab addon is disabled
- Fixed reservation price/budget field visibility when budget addon disabled
- Fixed HEIC photo rendering on non-Safari browsers
- Fixed CSP path matching for paths ending in /
- Fixed avatar URLs in notifications, admin panel, and budget
- Fixed budget member avatars lost after updating item fields
- Fixed collab notes line break preservation (#608)
- Fixed weather archive date handling for future trips (#599)
- Fixed duplicate skeleton entries for multi-day places (#606)
- Fixed ghost Gallery entries in journal timeline and public share
- Fixed journey map OSM tile warning (#627)
- Fixed content divider placement in journal entries (#624)
- Fixed local photos wrong provider label (#625)
- Fixed Synology pagination and album scroll leak (#644)
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
- Fixed Nominatim User-Agent and error diagnostics
- Fixed map tooltips, journey creation, and contributor avatars
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
- Fixed stale accommodation_id on reservation update (#522)
- Fixed hardcoded Immich in toast — now uses provider_name
- Fixed MCP safeBroadcast recursive call bug
- Fixed Vite module preload polyfill CSP inline script violation
- Fixed PWA offline session redirect and file download auth (#505, #541)
---
## Security
- **hono** 4.12.9 to 4.12.12 — fixes directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), and IP restriction bypass (CVE-2026-39409)
- **@hono/node-server** 1.19.11 to 1.19.13 — fixes directory traversal (CVE-2026-39406)
- **nodemailer** 8.0.4 to 8.0.5 — fixes CRLF injection
- **OAuth 2.1 hardening** — token storage, PKCE enforcement, scope intersection validation
- **Google Maps regex** — replaced too-permissive regex with safer utility function
---
## Infrastructure
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
- **Helm chart** — moved to charts/trek/, published via helm-publisher action to gh-pages, appVersion used as default image tag
- **Docker** — workflow improvements, tag management cleanup
- **CI** — contributor workflow automation, npm audit removal from install steps, manual trigger for prerelease
---
## Contributors
Thanks to everyone who contributed to this release:
- @mauriceboe
- @jubnl
- @gravitysc
- @luojiyin1987
- @marco783
- @isaiastavares
- @tiquis0290
- @xenocent
- @gfrcsd
---
## Stats
| Metric | Value |
|--------|-------|
| Commits | 280+ |
| Merged PRs | 49 |
| Files changed | 500+ |
| Lines added | 108,000+ |
| Contributors | 12 |
---
## Upgrading
```bash
docker pull mauriceboe/trek:3.0.0
docker compose up -d
```
Migrations run automatically on startup. No manual steps required.
**Checklist:**
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
3. Enable the Journey addon in Settings > Addons to start using the travel journal
-46
View File
@@ -1,46 +0,0 @@
# Contributing to TREK
Thanks for your interest in contributing! Please read these guidelines before opening a pull request.
## Ground Rules
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
### Your PR should include:
- **Summary** — What does this change and why? (1-3 bullet points)
- **Test plan** — How did you verify it works?
- **Linked issue** — Reference the issue (e.g. `Fixes #123`)
### Your PR will be closed if it:
- Wasn't discussed and approved in `#github-pr` on Discord first
- Introduces breaking changes
- Adds unnecessary complexity or features beyond scope
- Reformats or refactors unrelated code
- Adds dependencies without clear justification
### Commit messages
Use [conventional commits](https://www.conventionalcommits.org/):
```
fix(maps): correct zoom level on Safari
feat(budget): add CSV export for expenses
```
## Development Environment
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
-72
View File
@@ -1,72 +0,0 @@
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
RUN npm ci --workspace=shared
COPY shared/ ./shared/
RUN npm run build --workspace=shared
# ── Stage 2: client ──────────────────────────────────────────────────────────
FROM node:24-alpine AS client-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY server/ ./server/
RUN npm run build --workspace=server
# ── Stage 4: production runtime ──────────────────────────────────────────────
FROM node:24-alpine
WORKDIR /app
# Workspace manifests only — source never enters this stage.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools; purged after install.
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --workspace=server --omit=dev && \
apk del python3 make g++ && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
COPY --from=server-builder /app/server/dist ./server/dist
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app
ENV NODE_ENV=production
ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
-661
View File
@@ -1,661 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
-577
View File
@@ -1,577 +0,0 @@
# MCP Integration
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
structured API.
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
## Table of Contents
- [Setup](#setup)
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
- [Authentication](#authentication)
- [OAuth Scopes](#oauth-scopes)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Compound Tools](#compound-tools)
- [Prompts](#prompts)
- [Example](#example)
---
## Setup
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `404` and the MCP section does not appear in user settings.
### 2. Connect your MCP client
#### Option A: OAuth 2.1 (recommended)
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
management required — just provide the server URL:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp"
]
}
}
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
(`trekcs_` prefix) and fixed scopes that you define up front.
#### Option B: Static API Token (deprecated)
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
1. Go to **Settings > Integrations > MCP** and create an API token.
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
3. Add it to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp",
"--header",
"Authorization: Bearer trek_your_token_here"
]
}
}
}
```
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
static token will receive deprecation warnings in the AI client via server instructions and tool results.
Each user can create up to **10 static tokens**.
---
## Authentication
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
| Method | Token prefix | Access level | TTL | Notes |
|--------|-------------|-------------|-----|-------|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
---
## OAuth Scopes
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
that match your granted scopes for that session.
| Scope | Permission | Group |
|-------|-----------|-------|
| `trips:read` | View trips & itineraries | Trips |
| `trips:write` | Edit trips & itineraries | Trips |
| `trips:delete` | Delete trips (irreversible) | Trips |
| `trips:share` | Manage share links | Trips |
| `places:read` | View places & map data | Places |
| `places:write` | Manage places | Places |
| `atlas:read` | View Atlas | Atlas |
| `atlas:write` | Manage Atlas | Atlas |
| `packing:read` | View packing lists | Packing |
| `packing:write` | Manage packing lists | Packing |
| `todos:read` | View to-do lists | To-dos |
| `todos:write` | Manage to-do lists | To-dos |
| `budget:read` | View budget | Budget |
| `budget:write` | Manage budget | Budget |
| `reservations:read` | View reservations | Reservations |
| `reservations:write` | Manage reservations | Reservations |
| `collab:read` | View collaboration | Collaboration |
| `collab:write` | Manage collaboration | Collaboration |
| `notifications:read` | View notifications | Notifications |
| `notifications:write` | Manage notifications | Notifications |
| `vacay:read` | View vacation plans | Vacation |
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
| `journey:read` | View journeys | Journey |
| `journey:write` | Manage journeys | Journey |
| `journey:share` | Manage journey share links | Journey |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
---
## Limitations & Important Notes
| Limitation | Details |
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
---
## Resources (read-only)
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
making changes.
### Core Resources
| Resource | URI | Description |
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
### Addon-Gated Resources
These resources are only available when the corresponding addon is enabled by an admin.
| Resource | URI | Addon | Description |
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
---
## Tools (read-write)
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
trip in a single call.
### Trip Summary
| Tool | Description |
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
### Compound Tools
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
| Tool | Wraps | Description |
|---|---|---|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
---
### Trips
| Tool | Description |
|----------------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
| `list_trip_members` | List the owner and all collaborators of a trip. |
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
| `delete_share_link` | Revoke the public share link for a trip. |
### Places
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
| Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
| `list_categories` | List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
### Day Planning
| Tool | Description |
|-----------------------------|--------------------------------------------------------------------------------------|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
| `create_day` | Add a new day to a trip with optional date and notes. |
| `delete_day` | Delete a day from a trip. |
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" "11:30"). Pass `null` to clear. |
| `move_assignment` | Move a place assignment to a different day. |
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
### Accommodations
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
| Tool | Description |
|------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. |
### Transport
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
| Tool | Description |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
| `delete_transport` | Delete a transport booking from a trip. |
### Reservations
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
### Packing
| Tool | Description |
|-------------------------------|-----------------------------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
| `reorder_packing_items` | Set the display order of packing items within a trip. |
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
| `save_packing_template` | Save the current packing list as a reusable template. |
| `list_packing_bags` | List all packing bags for a trip. |
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
| `update_packing_bag` | Rename or recolor a packing bag. |
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
| `set_bag_members` | Assign trip members to a packing bag. |
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
| `set_packing_category_assignees` | Assign trip members to a packing category. |
### Day Notes
| Tool | Description |
|-------------------|------------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### To-Dos
| Tool | Description |
|-------------------------------|---------------------------------------------------------------------------------------------------|
| `list_todos` | List all to-do items for a trip, ordered by position. |
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
| `toggle_todo` | Mark a to-do item as done or undone. |
| `delete_todo` | Delete a to-do item. |
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
### Tags
| Tool | Description |
|--------------|--------------------------------------------------------------------------|
| `list_tags` | List all tags belonging to the current user. |
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
| `update_tag` | Update the name or color of an existing tag. |
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
### Notifications
| Tool | Description |
|---------------------------------|------------------------------------------------------|
| `list_notifications` | List in-app notifications with pagination and unread filter. |
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
| `mark_notification_read` | Mark a single notification as read. |
| `mark_notification_unread` | Mark a single notification as unread. |
| `mark_all_notifications_read` | Mark all notifications as read. |
### Maps & Weather
| Tool | Description |
|-----------------------|-----------------------------------------------------------------------------------------------------|
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
| `reverse_geocode` | Get a human-readable address for given coordinates. |
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Airports
| Tool | Description |
|-------------------|-------------------------------------------------------------------------------------------------------------------|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
### Collab Notes _(Collab addon required)_
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note. |
### Collab Polls & Chat _(Collab addon required)_
| Tool | Description |
|-----------------------|------------------------------------------------------------------------------------------|
| `list_collab_polls` | List all polls for a trip. |
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
| `close_collab_poll` | Close a poll so no more votes can be cast. |
| `delete_collab_poll` | Delete a poll and all its votes. |
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List _(Atlas addon required)_
| Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas _(Atlas addon required)_
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
### Atlas Extended _(Atlas addon required)_
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------|
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
| `unmark_region_visited` | Remove a region from the visited list. |
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
### Vacay _(Vacay addon required)_
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
| `add_vacay_year` | Add a calendar year to the vacation plan. |
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
| `list_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. |
### Journey _(Journey addon required)_
| Tool | Description |
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
| `update_journey` | Update a journey's title, subtitle, or status. |
| `delete_journey` | Delete a journey. |
| `add_journey_trip` | Link an existing trip to a journey. |
| `remove_journey_trip` | Remove a trip from a journey. |
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
| `delete_journey_entry` | Remove an entry from a journey. |
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
| `remove_journey_contributor` | Remove a contributor from a journey. |
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
| `get_journey_share_link` | Get the current public share link for a journey. |
| `create_journey_share_link` | Create or update the public share link for a journey. |
| `delete_journey_share_link` | Revoke the public share link for a journey. |
---
## Prompts
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description |
|----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
---
## Example
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
Initial prompt (1st message):
```
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
and leaving April 11 2027. It's cherry blossom season so please keep that
in mind when picking spots.
Before writing anything to TREK, do some research: look up what's worth
visiting, figure out a logical day-by-day flow (group nearby spots together
to avoid unnecessary travel), find a well-reviewed hotel in a central
neighbourhood, and think about what kind of food and restaurant experiences
are worth including.
Once you have a solid plan, write the whole thing to TREK:
- Create the trip
- Add all the places you've researched with their real coordinates
- Build out the daily itinerary with sensible visiting times
- Book the hotel as a reservation and link it properly to the accommodation days
- Add any notable restaurant reservations
- Put together a realistic budget in EUR
- Build a packing list suited to April in Kyoto
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
- Add a day note for each day with any important heads-up (early start, crowd
tips, booking requirements, etc.)
- Mark Japan as visited in my Atlas
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
of everything that was added.
```
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
![trip](./docs/screenshot-trip-mcp.png)
-443
View File
@@ -1,443 +0,0 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" 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 trips. Your plan. Your server." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br />
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
<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>
&nbsp;
<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/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/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/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
</div>
---
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
</picture>
<details>
<summary><b>See all features</b></summary>
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Collab suite** — group chat, shared notes, polls, day check-ins
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td>
</tr>
</table>
</details>
<br />
## Get started in 30 seconds
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
<div align="center">
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
</div>
<br />
## Tech stack
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
![Leaflet](https://img.shields.io/badge/Leaflet-199900?style=flat-square&logo=leaflet&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)
</div>
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br />
<h2 id="docker-compose-production">Docker Compose (production)</h2>
<details>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
Then:
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
<br />
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
TREK works as a Progressive Web App — no App Store needed.
1. Open TREK in the browser (HTTPS required)
2. **iOS**: Share ▸ *Add to Home Screen*
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
TREK then launches fullscreen with its own icon, just like a native app.
<br />
## Updating
**Docker Compose:**
```bash
docker compose pull && docker compose up -d
```
**Docker run** — reuse the original volume paths:
```bash
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
<h2 id="reverse-proxy">Reverse Proxy</h2>
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
<details>
<summary>Nginx</summary>
```nginx
server {
listen 80;
server_name trek.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name trek.yourdomain.com;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
</details>
<details>
<summary>Caddy</summary>
```caddy
trek.yourdomain.com {
reverse_proxy localhost:3000
}
```
Caddy handles TLS and WebSockets automatically.
</details>
<br />
## Environment variables
<details>
<summary><b>Full reference</b></summary>
<br />
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
</details>
<br />
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Logs** — `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
-26
View File
@@ -1,26 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---|---|
| Latest | Yes |
| Older | No |
Only the latest version receives security updates. Please update to the latest release.
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue
2. Email: **report@liketrek.com**
3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
## Scope
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
Third-party dependencies are monitored via GitHub Dependabot.
-121
View File
@@ -1,121 +0,0 @@
# Trademark Policy
## Introduction
This is the TREK project's policy for the use of our trademarks. While TREK is
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
license does not include a license to use our trademarks.
This policy describes how you may use our trademarks. Our goal is to strike a
balance between: 1) our need to ensure that our trademarks remain reliable
indicators of the software we release; and 2) our community members' desire to
be full participants in the TREK project.
## Our trademarks
This policy covers the name "TREK" as well as any associated logos, trade dress,
goodwill, or designs (our "Marks").
## In general
Whenever you use our Marks, you must always do so in a way that does not mislead
anyone about exactly who is the source of the software. For example, you cannot
say you are distributing TREK when you're distributing a modified version of it,
because people would think they would be getting the same software that they
can get directly from us when they aren't. You also cannot use our Marks on
your website in a way that suggests that your website is an official TREK
website or that we endorse your website. But, if true, you can say you like
TREK, that you participate in the TREK community, that you are providing an
unmodified version of TREK, or that you wrote a guide describing how to use
TREK.
This fundamental requirement, that it is always clear to people what they are
getting and from whom, is reflected throughout this policy. It should also
serve as your guide if you are not sure about how you are using the Marks.
In addition:
* You may not use or register, in whole or in part, the Marks as part of your
own trademark, service mark, domain name, company name, trade name, product
name or service name.
* Trademark law does not allow your use of names or trademarks that are too
similar to ours. You therefore may not use an obvious variation of any of our
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
abbreviation for a similar or compatible product or service.
* You agree that you will not acquire any rights in the Marks and that any
goodwill generated by your use of the Marks and participation in our
community inures solely to our benefit.
## Distribution of unmodified source code or unmodified executable code we have compiled
When you redistribute an unmodified copy of TREK, you are not changing the
quality or nature of it. Therefore, you may retain the Marks we have placed on
the software to identify your redistribution. This kind of use only applies if
you are redistributing an official TREK distribution that has not been changed
in any way.
## Distribution of executable code that you have compiled, or modified code
You may use the word mark "TREK", but not any TREK logos, to truthfully
describe the origin of the software that you are providing, that is, that the
code you are distributing is a modification of TREK. You may say, for example,
that "this software is derived from the source code for TREK."
Of course, you can place your own trademarks or logos on versions of the
software to which you have made substantive modifications, because by modifying
the software, you have become the origin of that exact version. In that case,
you should not use our Marks.
However, you may use our Marks for the distribution of code (source or
executable) on the condition that any executable is built from an official TREK
source code release and that any modifications are limited to switching on or
off features already included in the software, translations into other
languages, and incorporating minor bug-fix patches. Use of our Marks on any
further modification is not permitted.
## Mobile wrappers, hosted instances, and forks
The following clarifications apply specifically to common ways TREK is
redistributed:
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
as "a TREK instance" or "running TREK." You may not name the service itself
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
Official," etc.).
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
TREK.** You may describe your app as "a mobile client for TREK" or "for use
with TREK." You may not publish it on app stores under the name "TREK" or a
confusingly similar name, and you may not use the TREK logo as the app icon
unless your wrapper distributes only an unmodified, official TREK instance
and you have obtained permission.
* **Forks of the TREK source code.** Forks that diverge from upstream must use
a different name. You may state that your fork is "based on TREK" or "a fork
of TREK," but the project name itself must be your own.
## Statements about your software's relation to TREK
You may use the word mark, but not TREK logos, to truthfully describe the
relationship between your software and ours. The word mark "TREK" should be
used after a verb or preposition that describes the relationship between your
software and ours. So you may say, for example, "Bob's app for TREK" but may
not say "Bob's TREK app." Some other examples that may work for you are:
* [Your software] uses TREK
* [Your software] is powered by TREK
* [Your software] runs on TREK
* [Your software] for use with TREK
* [Your software] for TREK
## Questions and permission requests
If you are not sure whether your intended use of the Marks is permitted under
this policy, or if you would like to request explicit permission for a use that
is not covered, please open an issue on the TREK GitHub repository or contact
the maintainers directly.
---
These guidelines are based on the
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
under a
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
-50
View File
@@ -1,50 +0,0 @@
# TREK Helm Chart
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`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Health checks on `/api/health`
## Helm Repository
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
## Usage
Or install directly from the local chart:
```sh
helm install trek ./chart \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
See `values.yaml` for more options.
## Files
- `Chart.yaml` — chart metadata
- `values.yaml` — configuration values
- `templates/` — Kubernetes manifests
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- `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.
- 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.
- 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.
- `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.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `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.**
- 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.
-5
View File
@@ -1,5 +0,0 @@
apiVersion: v2
name: trek
version: 3.0.22
description: Minimal Helm chart for TREK app
appVersion: "3.0.22"
-23
View File
@@ -1,23 +0,0 @@
1. ENCRYPTION_KEY handling:
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
2. JWT_SECRET is managed entirely by the server:
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
- Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret).
- No Helm configuration needed or supported.
3. Example usage:
- Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key`
- Generate a random key at install: `--set generateEncryptionKey=true`
- Use an existing secret: `--set existingSecret=my-k8s-secret`
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
-18
View File
@@ -1,18 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "trek.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "trek.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
-66
View File
@@ -1,66 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "trek.fullname" . }}-config
labels:
app: {{ include "trek.name" . }}
data:
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
PORT: {{ .Values.env.PORT | quote }}
{{- if .Values.env.TZ }}
TZ: {{ .Values.env.TZ | quote }}
{{- end }}
{{- if .Values.env.LOG_LEVEL }}
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
{{- if .Values.env.APP_URL }}
APP_URL: {{ .Values.env.APP_URL | quote }}
{{- end }}
{{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }}
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
{{- if .Values.env.TRUST_PROXY }}
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
{{- end }}
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
{{- if .Values.env.OIDC_CLIENT_ID }}
OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISPLAY_NAME }}
OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }}
{{- end }}
{{- if .Values.env.OIDC_ONLY }}
OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }}
{{- end }}
{{- if .Values.env.OIDC_ADMIN_CLAIM }}
OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }}
{{- end }}
{{- if .Values.env.OIDC_ADMIN_VALUE }}
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
{{- end }}
{{- if .Values.env.OIDC_SCOPE }}
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISCOVERY_URL }}
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
{{- end }}
{{- if .Values.env.DEMO_MODE }}
DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }}
{{- end }}
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
-89
View File
@@ -1,89 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ include "trek.name" . }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
labels:
app: {{ include "trek.name" . }}
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- range .Values.imagePullSecrets }}
- name: {{ .name }}
{{- end }}
{{- end }}
securityContext:
fsGroup: 1000
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
env:
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
optional: true
- name: ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: ADMIN_EMAIL
optional: true
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: ADMIN_PASSWORD
optional: true
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
volumeMounts:
- name: data
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-data
- name: uploads
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-uploads
-35
View File
@@ -1,35 +0,0 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
pathType: Prefix
backend:
service:
name: {{ include "trek.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
-27
View File
@@ -1,27 +0,0 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.data.size }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
{{- end }}
-47
View File
@@ -1,47 +0,0 @@
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
labels:
app: {{ include "trek.name" . }}
type: Opaque
data:
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
labels:
app: {{ include "trek.name" . }}
type: Opaque
stringData:
{{- if and $existingSecret $existingSecret.data }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
{{- else }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- end }}
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
protocol: TCP
name: http
selector:
app: {{ include "trek.name" . }}
-119
View File
@@ -1,119 +0,0 @@
image:
repository: mauriceboe/trek
# tag: latest
pullPolicy: IfNotPresent
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
service:
type: ClusterIP
port: 3000
env:
NODE_ENV: production
PORT: 3000
# TZ: "UTC"
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts 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.
# FORCE_HTTPS: "false"
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# HSTS_INCLUDE_SUBDOMAINS: "false"
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1"
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
# OIDC client ID.
# OIDC_DISPLAY_NAME: "SSO"
# Label shown on the SSO login button.
# OIDC_ONLY: "false"
# Set to "true" to force SSO-only mode: disables password login and password registration.
# Overrides the granular toggles in Admin > Settings and cannot be changed at runtime.
# First SSO login becomes admin on a fresh instance.
# OIDC_ADMIN_CLAIM: ""
# OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: ""
# Value of the OIDC claim that grants admin role.
# OIDC_SCOPE: "openid email profile groups"
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
# OIDC_DISCOVERY_URL: ""
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# DEMO_MODE: "false"
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "300"
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# Secret environment variables stored in a Kubernetes Secret.
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
# rotatable via the admin panel) — it is not configured here.
secretEnv:
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
# Recommended: set to a random 32-byte hex value (openssl rand -hex 32).
# If left empty the server resolves the key automatically:
# 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade)
# 2. data/.encryption_key auto-generated on first start (fresh installs)
ENCRYPTION_KEY: ""
# Initial admin account — only used on first boot when no users exist yet.
# If both values are non-empty the admin account is created with these credentials.
# If either is empty a random password is generated and printed to the server log.
ADMIN_EMAIL: ""
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
persistence:
enabled: true
data:
size: 1Gi
uploads:
size: 1Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: chart-example.local
paths:
- /
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
-27
View File
@@ -1,27 +0,0 @@
{
"printWidth": 120,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "es5",
"semi": true,
"singleQuote": true,
"bracketSpacing": true,
"arrowParens": "always",
"jsxSingleQuote": false,
"bracketSameLine": false,
"endOfLine": "lf",
"plugins": [
"prettier-plugin-organize-imports",
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"importOrder": [
"^[a-zA-Z]",
"^@/.*"
],
"importOrderSeparation": true,
"importOrderParserPlugins": [
"typescript",
"decorators-legacy"
]
}
-39
View File
@@ -1,39 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import gitignore from 'eslint-config-flat-gitignore'
export default defineConfig([
gitignore({ strict: false }),
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
// Route files always export both `Route` (non-component) and the page component — expected pattern.
{
files: ['src/routes/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
// shadcn UI primitives export variant helpers alongside components — generated files, don't modify.
// ThemeProvider exports both the provider component and the useTheme hook — standard pattern.
{
files: ['src/components/ui/**/*.{ts,tsx}', 'src/components/theme/ThemeProvider.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
])
-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="TREK" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-76
View File
@@ -1,76 +0,0 @@
{
"name": "@trek/client",
"version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@trek/shared": "*",
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"marked": "^18.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.4.1",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"typescript-eslint": "^8.58.2"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-15
View File
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1e293b"/>
<stop offset="100%" stop-color="#0f172a"/>
</linearGradient>
<clipPath id="icon">
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
</clipPath>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<g transform="translate(56,51) scale(0.267)">
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="c5c1a398e1"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="9b226024c5"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#c5c1a398e1)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#9b226024c5)"><g fill="#000000" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="7fc4e3f80b"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="086ce69399"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#7fc4e3f80b)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#086ce69399)"><g fill="#ffffff" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

-29
View File
@@ -1,29 +0,0 @@
/**
* Generates PNG icons for PWA from the master SVG icon.
* Run: node scripts/generate-icons.mjs
* Called automatically via the "prebuild" npm script.
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
const __dirname = dirname(fileURLToPath(import.meta.url));
const iconsDir = join(__dirname, '..', 'public', 'icons');
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'));
const sizes = [
{ name: 'apple-touch-icon-180x180.png', size: 180 },
{ name: 'icon-192x192.png', size: 192 },
{ name: 'icon-512x512.png', size: 512 },
];
for (const { name, size } of sizes) {
await sharp(svgBuffer, { density: 300 })
.resize(size, size)
.png({ compressionLevel: 9 })
.toFile(join(iconsDir, name));
console.log(` \u2713 ${name} (${size}x${size})`);
}
console.log('PWA icons generated.');
-301
View File
@@ -1,301 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSettings, buildUser } from '../tests/helpers/factories';
import { server } from '../tests/helpers/msw/server';
import { resetAllStores } from '../tests/helpers/store';
import App from './App';
import { useAuthStore } from './store/authStore';
import { useSettingsStore } from './store/settingsStore';
// ── Mock page components ───────────────────────────────────────────────────────
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }));
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }));
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }));
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }));
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }));
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }));
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }));
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }));
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }));
// Prevent WebSocket side effects from the notification listener
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}));
// ── Helpers ────────────────────────────────────────────────────────────────────
function renderApp(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
);
}
/**
* Seeds authStore with sensible defaults for a test, replacing loadUser with a
* no-op spy so the MSW /api/auth/me response does not overwrite the seeded state.
*/
function seedAuth(overrides: Record<string, unknown> = {}) {
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
user: null,
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
...overrides,
});
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
document.documentElement.classList.remove('dark');
});
// ── RootRedirect ───────────────────────────────────────────────────────────────
describe('RootRedirect', () => {
it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false });
renderApp('/');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() });
renderApp('/');
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
});
it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => {
seedAuth({ isLoading: true, isAuthenticated: false });
renderApp('/');
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
expect(screen.queryByText('Login')).not.toBeInTheDocument();
});
});
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
describe('ProtectedRoute — unauthenticated', () => {
it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => {
seedAuth({ isAuthenticated: false });
renderApp('/dashboard');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false });
renderApp('/trips/42');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
});
// ── ProtectedRoute — loading ───────────────────────────────────────────────────
describe('ProtectedRoute — loading state', () => {
it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => {
seedAuth({ isLoading: true, isAuthenticated: false });
renderApp('/dashboard');
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
});
});
// ── ProtectedRoute — MFA enforcement ──────────────────────────────────────────
describe('ProtectedRoute — MFA enforcement', () => {
it('FE-COMP-APP-007: redirects to /settings?mfa=required when appRequireMfa is true and MFA is disabled', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
});
renderApp('/dashboard');
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument());
});
it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
});
renderApp('/settings');
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument());
expect(screen.queryByText('Login')).not.toBeInTheDocument();
});
it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: true }),
});
renderApp('/dashboard');
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
});
});
// ── ProtectedRoute — admin role ────────────────────────────────────────────────
describe('ProtectedRoute — admin role check', () => {
it('FE-COMP-APP-010: /admin redirects to /dashboard for non-admin user', async () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'user' }),
});
renderApp('/admin');
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
it('FE-COMP-APP-011: /admin is accessible for admin user', async () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'admin' }),
});
renderApp('/admin');
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument());
});
});
// ── Public routes ──────────────────────────────────────────────────────────────
describe('Public routes', () => {
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false });
renderApp('/login');
expect(screen.getByText('Login')).toBeInTheDocument();
});
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false });
renderApp('/shared/sometoken');
expect(screen.getByText('SharedTrip')).toBeInTheDocument();
});
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
seedAuth({ isAuthenticated: false });
renderApp('/does-not-exist');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
});
// ── App — on-mount effects ─────────────────────────────────────────────────────
describe('App — on-mount effects', () => {
it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser });
renderApp('/dashboard');
expect(loadUser).toHaveBeenCalled();
});
it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser });
renderApp('/shared/token123');
expect(loadUser).not.toHaveBeenCalled();
});
it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => {
let configCalled = false;
server.use(
http.get('/api/auth/app-config', () => {
configCalled = true;
return HttpResponse.json({});
})
);
seedAuth();
renderApp('/');
await waitFor(() => expect(configCalled).toBe(true));
});
it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => {
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true })));
const setDemoMode = vi.fn();
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setDemoMode,
});
renderApp('/');
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true));
});
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() });
useSettingsStore.setState({ loadSettings });
renderApp('/dashboard');
await waitFor(() => expect(loadSettings).toHaveBeenCalled());
});
});
// ── Dark mode effects ──────────────────────────────────────────────────────────
describe('Dark mode effects', () => {
it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() });
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) });
renderApp('/dashboard');
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true));
});
it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => {
document.documentElement.classList.add('dark');
seedAuth({ isAuthenticated: true, user: buildUser() });
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) });
renderApp('/dashboard');
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
});
it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => {
document.documentElement.classList.add('dark');
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) });
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) });
renderApp('/shared/tok');
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
});
it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => {
// matchMedia stub returns matches: false by default (from setup.ts)
seedAuth({ isAuthenticated: true, user: buildUser() });
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) });
renderApp('/dashboard');
// With matches: false, dark should NOT be added
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
});
});
// ── Version cache-busting ──────────────────────────────────────────────────────
describe('Version cache-busting', () => {
it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => {
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })));
seedAuth();
renderApp('/');
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'));
});
it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => {
localStorage.setItem('trek_app_version', '2.9.9');
const reload = vi.fn();
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, reload },
});
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })));
seedAuth();
renderApp('/');
await waitFor(() => expect(reload).toHaveBeenCalled());
});
});
-340
View File
@@ -1,340 +0,0 @@
import { ReactNode, useEffect } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { authApi } from './api/client';
import BottomNav from './components/Layout/BottomNav';
import OfflineBanner from './components/Layout/OfflineBanner';
import { ToastContainer } from './components/shared/Toast';
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js';
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts';
import { TranslationProvider, useTranslation } from './i18n';
import AdminPage from './pages/AdminPage';
import AtlasPage from './pages/AtlasPage';
import DashboardPage from './pages/DashboardPage';
import FilesPage from './pages/FilesPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx';
import JourneyDetailPage from './pages/JourneyDetailPage';
import JourneyPage from './pages/JourneyPage';
import JourneyPublicPage from './pages/JourneyPublicPage';
import LoginPage from './pages/LoginPage';
import OAuthAuthorizePage from './pages/OAuthAuthorizePage';
import ResetPasswordPage from './pages/ResetPasswordPage';
import SettingsPage from './pages/SettingsPage';
import SharedTripPage from './pages/SharedTripPage';
import TripPlannerPage from './pages/TripPlannerPage';
import VacayPage from './pages/VacayPage';
import { useAddonStore } from './store/addonStore';
import { useAuthStore } from './store/authStore';
import { PermissionLevel, usePermissionsStore } from './store/permissionsStore';
import { useSettingsStore } from './store/settingsStore';
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers';
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js';
interface ProtectedRouteProps {
children: ReactNode;
adminRequired?: boolean;
addonId?: string;
}
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const appRequireMfa = useAuthStore((s) => s.appRequireMfa);
const addonStore = useAddonStore();
const { t } = useTranslation();
const location = useLocation();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
<p className="text-sm text-slate-500">{t('common.loading')}</p>
</div>
</div>
);
}
if (!isAuthenticated) {
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash);
return <Navigate to={`/login?redirect=${redirectParam}`} replace />;
}
if (appRequireMfa && user && !user.mfa_enabled && location.pathname !== '/settings') {
return <Navigate to="/settings?mfa=required" replace />;
}
if (adminRequired && user && user.role !== 'admin') {
return <Navigate to="/dashboard" replace />;
}
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
return <Navigate to="/dashboard" replace />;
}
return (
<div className="flex h-screen flex-col md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
);
}
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
</div>
);
}
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />;
}
export default function App() {
const {
loadUser,
isAuthenticated,
demoMode,
setDemoMode,
setDevMode,
setIsPrerelease,
setAppVersion,
setHasMapsKey,
setServerTimezone,
setAppRequireMfa,
setTripRemindersEnabled,
setPlacesPhotosEnabled,
setPlacesAutocompleteEnabled,
setPlacesDetailsEnabled,
} = useAuthStore();
const { loadSettings } = useSettingsStore();
const { loadAddons } = useAddonStore();
useEffect(() => {
if (
!location.pathname.startsWith('/shared/') &&
!location.pathname.startsWith('/public/') &&
!location.pathname.startsWith('/login')
) {
// If the persist snapshot already has an authenticated user, validate
// silently so the PWA shell renders immediately without a spinner.
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated;
if (alreadyAuthenticated) {
useAuthStore.setState({ isLoading: false });
loadUser({ silent: true });
} else {
loadUser();
}
}
authApi
.getAppConfig()
.then(
async (config: {
demo_mode?: boolean;
dev_mode?: boolean;
is_prerelease?: boolean;
has_maps_key?: boolean;
version?: string;
timezone?: string;
require_mfa?: boolean;
trip_reminders_enabled?: boolean;
places_photos_enabled?: boolean;
places_autocomplete_enabled?: boolean;
places_details_enabled?: boolean;
permissions?: Record<string, PermissionLevel>;
}) => {
if (config?.demo_mode) setDemoMode(true);
if (config?.dev_mode) setDevMode(true);
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease);
if (config?.version) setAppVersion(config.version);
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key);
if (config?.timezone) setServerTimezone(config.timezone);
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa);
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled);
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled);
if (config?.places_autocomplete_enabled !== undefined)
setPlacesAutocompleteEnabled(config.places_autocomplete_enabled);
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled);
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions);
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version');
if (storedVersion && storedVersion !== config.version) {
try {
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(names.map((n) => caches.delete(n)));
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
} catch {}
localStorage.setItem('trek_app_version', config.version);
window.location.reload();
return;
}
localStorage.setItem('trek_app_version', config.version);
}
}
)
.catch(() => {});
}, []);
const { settings } = useSettingsStore();
useInAppNotificationListener();
useEffect(() => {
if (isAuthenticated) {
loadSettings();
loadAddons();
}
}, [isAuthenticated]);
useEffect(() => {
registerSyncTriggers();
return () => unregisterSyncTriggers();
}, []);
const location = useLocation();
const isSharedPage = location.pathname.startsWith('/shared/');
useEffect(() => {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark');
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', '#ffffff');
return;
}
const mode = settings.dark_mode;
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff');
};
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
applyDark(mq.matches);
const handler = (e: MediaQueryListEvent) => applyDark(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}
applyDark(mode === true || mode === 'dark');
}, [settings.dark_mode, isSharedPage]);
const isAuthPage =
location.pathname.startsWith('/login') ||
location.pathname.startsWith('/register') ||
location.pathname.startsWith('/forgot-password') ||
location.pathname.startsWith('/reset-password');
return (
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
<ProtectedRoute>
<TripPlannerPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id/files"
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute adminRequired>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route
path="/vacay"
element={
<ProtectedRoute>
<VacayPage />
</ProtectedRoute>
}
/>
<Route
path="/atlas"
element={
<ProtectedRoute>
<AtlasPage />
</ProtectedRoute>
}
/>
<Route
path="/journey"
element={
<ProtectedRoute addonId="journey">
<JourneyPage />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute addonId="journey">
<JourneyDetailPage />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<InAppNotificationsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
);
}
-58
View File
@@ -1,58 +0,0 @@
export async function getAuthUrl(url: string, purpose: 'download'): Promise<string> {
if (!url) return url
try {
const resp = await fetch('/api/auth/resource-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ purpose }),
})
if (!resp.ok) return url
const { token } = await resp.json()
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
} catch {
return url
}
}
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
const MAX_CONCURRENT = 6
let active = 0
const queue: Array<() => void> = []
function dequeue() {
while (active < MAX_CONCURRENT && queue.length > 0) {
active++
queue.shift()!()
}
}
export function clearImageQueue() {
queue.length = 0
}
export async function fetchImageAsBlob(url: string): Promise<string> {
if (!url) return ''
return new Promise<string>((resolve) => {
const run = async () => {
try {
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) { resolve(''); return }
const blob = await resp.blob()
resolve(URL.createObjectURL(blob))
} catch {
resolve('')
} finally {
active--
dequeue()
}
}
if (active < MAX_CONCURRENT) {
active++
run()
} else {
queue.push(run)
}
})
}
-614
View File
@@ -1,614 +0,0 @@
import axios, { AxiosInstance } from 'axios'
import type { WeatherResult } from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
import en from '../i18n/translations/en'
import br from '../i18n/translations/br'
import de from '../i18n/translations/de'
import es from '../i18n/translations/es'
import fr from '../i18n/translations/fr'
import it from '../i18n/translations/it'
import nl from '../i18n/translations/nl'
import pl from '../i18n/translations/pl'
import cs from '../i18n/translations/cs'
import hu from '../i18n/translations/hu'
import ru from '../i18n/translations/ru'
import zh from '../i18n/translations/zh'
import zhTw from '../i18n/translations/zhTw'
import ar from '../i18n/translations/ar'
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
}
function translateRateLimit(): string {
const fallback = 'Too many attempts. Please try again later.'
try {
const lang = localStorage.getItem('app_language') || 'en'
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
} catch {
return fallback
}
}
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
})
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
// Request interceptor - add socket ID + idempotency key for mutating requests
apiClient.interceptors.request.use(
(config) => {
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
// Attach a per-request idempotency key to all write operations so the
// server can deduplicate retried requests (e.g. network blips).
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
}
return config
},
(error) => Promise.reject(error)
)
export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Unregisters the SW before reloading so the navigation reaches the network.
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
async function unregisterSWAndReload(): Promise<void> {
try {
const reg = await navigator.serviceWorker?.getRegistration()
if (reg) await reg.unregister()
} catch { /* ignore */ }
window.location.reload()
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
apiClient.interceptors.response.use(
(response) => {
sessionStorage.removeItem('proxy_reauth_attempted')
return response
},
async (error) => {
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
// as a CORS error with no response object. Probe the health endpoint to
// distinguish a proxy auth challenge from a genuine outage. If the server
// is reachable, a top-level reload lets the edge proxy run its auth flow.
if (!error.response && navigator.onLine) {
await probeNow()
// Both the original request and the health probe failed while the device
// has a network interface. This matches the proxy-auth-challenge pattern
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
// Guard with sessionStorage to prevent reload loops (server genuinely
// down would also land here, but only reloads once).
if (!isReachable()) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
// Pangolin header-auth extended compatibility mode: returns 401 with an
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
// always application/json, so checking for text/html is unambiguous.
if (error.response?.status === 401) {
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
if (ct.includes('text/html')) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
}
error.message = translated
}
return Promise.reject(error)
}
)
export const authApi = {
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
updateSettings: (data: Record<string, unknown>) => apiClient.put('/auth/me/settings', data).then(r => r.data),
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
uploadAvatar: (formData: FormData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: {
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
},
}
export const oauthApi = {
/** Validate OAuth authorize params — called by consent page on load */
validate: (params: {
response_type: string
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
resource?: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
authorize: (body: {
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
approved: boolean
resource?: string
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
},
sessions: {
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
},
}
export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
}
export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
}
export const placesApi = {
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
}
export const assignmentsApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
}
export const packingApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
}
export const todoApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
}
export const categoriesApi = {
list: () => apiClient.get('/categories').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
}
export const adminApi = {
users: () => apiClient.get('/admin/users').then(r => r.data),
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').then(r => r.data),
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data),
deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data),
addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data),
updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data),
deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data),
addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data),
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
auditLog: (params?: { limit?: number; offset?: number }) =>
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data),
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
sendTestNotification: (data: Record<string, unknown>) =>
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
}
export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data),
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
// Trips (sync sources)
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
// Entries
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
// Photos
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
// Cover
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
// Contributors
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
// Preferences
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
}
export const airportsApi = {
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
}
export const filesApi = {
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
}
export const weatherApi = {
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
}
export const configApi = {
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
apiClient.get('/config').then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
}
export const accommodationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
}
export const dayNotesApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const collabApi = {
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
}
export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),
download: async (filename: string): Promise<void> => {
const res = await fetch(`/api/backup/download/${filename}`, {
credentials: 'include',
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
},
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
uploadRestore: (file: File) => {
const form = new FormData()
form.append('backup', file)
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
}
export const shareApi = {
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
}
export const inAppNotificationsApi = {
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
unreadCount: () =>
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
markRead: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
markUnread: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
markAllRead: () =>
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
delete: (id: number) =>
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
deleteAll: () =>
apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: 'positive' | 'negative') =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
-102
View File
@@ -1,102 +0,0 @@
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
import { describe, it, expect } from 'vitest'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
describe('SCOPE_GROUPS', () => {
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
const expected = [
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
'places:read', 'places:write',
'atlas:read', 'atlas:write',
'packing:read', 'packing:write',
'todos:read', 'todos:write',
'budget:read', 'budget:write',
'reservations:read', 'reservations:write',
'collab:read', 'collab:write',
'notifications:read', 'notifications:write',
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
}
})
it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => {
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy()
expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy()
expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy()
}
})
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS))
})
})
describe('SCOPE_GROUP_NAMES', () => {
it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => {
expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size)
})
it('FE-OAUTH-SCOPES-006: contains expected groups', () => {
const expected = [
'oauth.scope.group.trips',
'oauth.scope.group.places',
'oauth.scope.group.packing',
'oauth.scope.group.budget',
]
for (const g of expected) {
expect(SCOPE_GROUP_NAMES).toContain(g)
}
})
})
describe('getScopesByGroup', () => {
const identity = (key: string) => key
it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => {
const groups = getScopesByGroup(identity)
// Every scope must appear exactly once across all groups
const allScopesInGroups = Object.values(groups).flat().map(s => s.scope)
expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length)
for (const scope of ALL_SCOPES) {
expect(allScopesInGroups).toContain(scope)
}
})
it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => {
const groups = getScopesByGroup(identity)
for (const items of Object.values(groups)) {
for (const item of items) {
expect(item.scope).toBeTruthy()
expect(item.label).toBeTruthy()
expect(item.description).toBeTruthy()
expect(item.group).toBeTruthy()
}
}
})
it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => {
const groups = getScopesByGroup(identity)
const tripsGroup = groups['oauth.scope.group.trips']
expect(tripsGroup).toBeDefined()
const scopeNames = tripsGroup.map(s => s.scope)
expect(scopeNames).toContain('trips:read')
expect(scopeNames).toContain('trips:write')
})
it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => {
const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key
const groups = getScopesByGroup(t)
expect(groups['Trips']).toBeDefined()
expect(groups['oauth.scope.group.trips']).toBeUndefined()
})
})
-59
View File
@@ -1,59 +0,0 @@
// Human-readable scope definitions for the OAuth consent page.
// Must stay in sync with server/src/mcp/scopes.ts
export interface ScopeInfo {
label: string
description: string
group: string
}
export interface ScopeKeys {
labelKey: string
descriptionKey: string
groupKey: string
}
export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'trips:read': { labelKey: 'oauth.scope.trips:read.label', descriptionKey: 'oauth.scope.trips:read.description', groupKey: 'oauth.scope.group.trips' },
'trips:write': { labelKey: 'oauth.scope.trips:write.label', descriptionKey: 'oauth.scope.trips:write.description', groupKey: 'oauth.scope.group.trips' },
'trips:delete': { labelKey: 'oauth.scope.trips:delete.label', descriptionKey: 'oauth.scope.trips:delete.description', groupKey: 'oauth.scope.group.trips' },
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
'packing:write': { labelKey: 'oauth.scope.packing:write.label', descriptionKey: 'oauth.scope.packing:write.description', groupKey: 'oauth.scope.group.packing' },
'todos:read': { labelKey: 'oauth.scope.todos:read.label', descriptionKey: 'oauth.scope.todos:read.description', groupKey: 'oauth.scope.group.todos' },
'todos:write': { labelKey: 'oauth.scope.todos:write.label', descriptionKey: 'oauth.scope.todos:write.description', groupKey: 'oauth.scope.group.todos' },
'budget:read': { labelKey: 'oauth.scope.budget:read.label', descriptionKey: 'oauth.scope.budget:read.description', groupKey: 'oauth.scope.group.budget' },
'budget:write': { labelKey: 'oauth.scope.budget:write.label', descriptionKey: 'oauth.scope.budget:write.description', groupKey: 'oauth.scope.group.budget' },
'reservations:read': { labelKey: 'oauth.scope.reservations:read.label', descriptionKey: 'oauth.scope.reservations:read.description', groupKey: 'oauth.scope.group.reservations' },
'reservations:write': { labelKey: 'oauth.scope.reservations:write.label', descriptionKey: 'oauth.scope.reservations:write.description', groupKey: 'oauth.scope.group.reservations' },
'collab:read': { labelKey: 'oauth.scope.collab:read.label', descriptionKey: 'oauth.scope.collab:read.description', groupKey: 'oauth.scope.group.collab' },
'collab:write': { labelKey: 'oauth.scope.collab:write.label', descriptionKey: 'oauth.scope.collab:write.description', groupKey: 'oauth.scope.group.collab' },
'notifications:read': { labelKey: 'oauth.scope.notifications:read.label', descriptionKey: 'oauth.scope.notifications:read.description', groupKey: 'oauth.scope.group.notifications' },
'notifications:write': { labelKey: 'oauth.scope.notifications:write.label', descriptionKey: 'oauth.scope.notifications:write.description', groupKey: 'oauth.scope.group.notifications' },
'vacay:read': { labelKey: 'oauth.scope.vacay:read.label', descriptionKey: 'oauth.scope.vacay:read.description', groupKey: 'oauth.scope.group.vacay' },
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
// Group all scopes for the client registration form
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
export function getScopesByGroup(t: (key: string) => string): Record<string, Array<{ scope: string } & ScopeInfo>> {
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
const group = t(keys.groupKey)
if (!groups[group]) groups[group] = []
groups[group].push({ scope, label: t(keys.labelKey), description: t(keys.descriptionKey), group })
}
return groups
}
-190
View File
@@ -1,190 +0,0 @@
// Singleton WebSocket manager for real-time collaboration
type WebSocketListener = (event: Record<string, unknown>) => void
type RefetchCallback = (tripId: string) => void
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
let connecting = false
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
let preReconnectHook: (() => Promise<void>) | null = null
export function getSocketId(): string | null {
return mySocketId
}
export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
/**
* Register a hook that runs (and is awaited) before the refetch callback
* fires on WS reconnect. Use this to flush the mutation queue so queued
* local writes reach the server before the app reads back canonical state.
* Pass null to clear.
*/
export function setPreReconnectHook(fn: (() => Promise<void>) | null): void {
preReconnectHook = fn
}
function getWsUrl(wsToken: string): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${location.host}/ws?token=${wsToken}`
}
async function fetchWsToken(): Promise<string | null> {
try {
const resp = await fetch('/api/auth/ws-token', {
method: 'POST',
credentials: 'include',
})
if (resp.status === 401) {
// Session expired — stop reconnecting
shouldReconnect = false
return null
}
if (!resp.ok) return null
const { token } = await resp.json()
return token as string
} catch {
return null
}
}
function handleMessage(event: MessageEvent): void {
try {
const parsed = JSON.parse(event.data)
if (parsed.type === 'welcome') {
mySocketId = parsed.socketId
return
}
listeners.forEach(fn => {
try { fn(parsed) } catch (err: unknown) { console.error('WebSocket listener error:', err) }
})
} catch (err: unknown) {
console.error('WebSocket message parse error:', err)
}
}
function scheduleReconnect(): void {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (shouldReconnect) {
connectInternal(true)
}
}, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}
async function connectInternal(_isReconnect = false): Promise<void> {
if (connecting) return
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return
}
connecting = true
const wsToken = await fetchWsToken()
connecting = false
if (!wsToken) {
if (shouldReconnect) scheduleReconnect()
return
}
const url = getWsUrl(wsToken)
socket = new WebSocket(url)
socket.onopen = () => {
reconnectDelay = 1000
if (activeTrips.size > 0) {
activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'join', tripId }))
}
})
if (refetchCallback) {
const doRefetch = () => {
activeTrips.forEach(tripId => {
try { refetchCallback!(tripId) } catch (err: unknown) {
console.error('Failed to refetch trip data on reconnect:', err)
}
})
}
// Flush queued mutations first so local writes land before server read-back.
// If the hook fails, still refetch to keep the UI correct.
if (preReconnectHook) {
preReconnectHook().catch(console.error).then(doRefetch)
} else {
doRefetch()
}
}
}
}
socket.onmessage = handleMessage
socket.onclose = () => {
socket = null
if (shouldReconnect) {
scheduleReconnect()
}
}
socket.onerror = () => {
// onclose will fire after onerror, reconnect handled there
}
}
export function connect(): void {
shouldReconnect = true
reconnectDelay = 1000
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
connectInternal(false)
}
export function disconnect(): void {
shouldReconnect = false
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
activeTrips.clear()
if (socket) {
socket.onclose = null
socket.close()
socket = null
}
}
export function joinTrip(tripId: number | string): void {
activeTrips.add(String(tripId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) }))
}
}
export function leaveTrip(tripId: number | string): void {
activeTrips.delete(String(tripId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) }))
}
}
export function addListener(fn: WebSocketListener): void {
listeners.add(fn)
}
export function removeListener(fn: WebSocketListener): void {
listeners.delete(fn)
}
@@ -1,227 +0,0 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { ToastContainer } from '../shared/Toast';
import AddonManager from './AddonManager';
function buildAddon(overrides = {}) {
return {
id: 'todo',
name: 'Todo List',
description: 'Track tasks',
icon: 'ListChecks',
type: 'trip',
enabled: false,
...overrides,
};
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
});
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })));
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('AddonManager', () => {
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
server.use(
http.get('/api/admin/addons', async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
})
);
render(<AddonManager />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
render(<AddonManager />);
await screen.findByText('No addons available');
});
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', name: 'Todo List', type: 'trip' })] })
)
);
render(<AddonManager />);
await screen.findByText('Todo List');
// Section header contains "Trip" and "Available as a tab within each trip"
expect(screen.getAllByText(/Trip/).length).toBeGreaterThan(0);
expect(screen.getByText(/Available as a tab within each trip/)).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-004: global and integration sections render when present', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({
addons: [
buildAddon({ id: 'global1', name: 'Global Feature', type: 'global' }),
buildAddon({ id: 'int1', name: 'Integration Feature', type: 'integration' }),
],
})
)
);
render(<AddonManager />);
await screen.findByText('Global Feature');
expect(screen.getAllByText(/Global/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Integration/).length).toBeGreaterThan(0);
});
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AddonManager />
</>
);
await screen.findByText('Todo List');
// Get toggle button - use getAllByRole since there might be multiple buttons
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full'));
expect(toggleBtn).toBeInTheDocument();
// Before click - disabled state (border-primary bg)
await user.click(toggleBtn!);
// After click - success toast
await screen.findByText('Addon updated');
});
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
http.put('/api/admin/addons/todo', () => HttpResponse.error())
);
render(
<>
<ToastContainer />
<AddonManager />
</>
);
await screen.findByText('Todo List');
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full'));
await user.click(toggleBtn!);
// Error toast appears
await screen.findByText('Failed to update addon');
// The disabled text should be back after rollback
await waitFor(() => {
const disabledTexts = screen.getAllByText('Disabled');
expect(disabledTexts.length).toBeGreaterThan(0);
});
});
it('FE-ADMIN-ADDON-007: bag tracking sub-toggle renders when packing addon is enabled', async () => {
const user = userEvent.setup();
const mockToggle = vi.fn();
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
);
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />);
await screen.findByText('Bag Tracking');
const bagTrackingToggle = screen
.getAllByRole('button')
.find(
(b) =>
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
);
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
const allBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full'));
// There should be two toggle buttons: one for the addon, one for bag tracking
await user.click(allBtns[allBtns.length - 1]);
expect(mockToggle).toHaveBeenCalled();
});
it('FE-ADMIN-ADDON-008: bag tracking hidden when packing addon is disabled', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
)
);
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
);
render(<AddonManager bagTrackingEnabled={false} />);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({
addons: [
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
],
})
)
);
render(<AddonManager />);
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
expect(screen.getByText('Pexels')).toBeInTheDocument();
// Journey addon is rendered
expect(screen.getByText('Journey')).toBeInTheDocument();
// Toggle buttons: journey toggle + 2 provider toggles
const toggleBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full'));
expect(toggleBtns.length).toBe(3);
});
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({
addons: [buildAddon({ id: 'mystery', name: 'Mystery Addon', icon: 'NonExistentIcon', type: 'trip' })],
})
)
);
// Should not throw; Puzzle icon is used as fallback
expect(() => render(<AddonManager />)).not.toThrow();
await screen.findByText('Mystery Addon');
});
});
@@ -1,569 +0,0 @@
import {
BarChart3,
BookOpen,
Briefcase,
CalendarDays,
Compass,
FileText,
Globe,
Image,
Link2,
ListChecks,
Luggage,
MessageCircle,
Puzzle,
Sparkles,
StickyNote,
Terminal,
Wallet,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useToast } from '../shared/Toast';
const ICON_MAP = {
ListChecks,
Wallet,
FileText,
CalendarDays,
Puzzle,
Globe,
Briefcase,
Image,
Terminal,
Link2,
Compass,
BookOpen,
};
function ImmichIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path
d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321"
fill="currentColor"
/>
</svg>
);
}
function SynologyIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path
d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z"
fill="currentColor"
/>
</svg>
);
}
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
};
interface Addon {
id: string;
name: string;
description: string;
icon: string;
type: string;
enabled: boolean;
config?: Record<string, unknown>;
}
interface ProviderOption {
key: string;
label: string;
description: string;
enabled: boolean;
toggle: () => Promise<void>;
}
interface AddonIconProps {
name: string;
size?: number;
}
function AddonIcon({ name, size = 20 }: AddonIconProps) {
const Icon = ICON_MAP[name] || Puzzle;
return <Icon size={size} />;
}
interface CollabFeatures {
chat: boolean;
notes: boolean;
polls: boolean;
whatsnext: boolean;
}
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{
key: 'whatsnext',
icon: Sparkles,
titleKey: 'admin.collab.whatsnext.title',
subtitleKey: 'admin.collab.whatsnext.subtitle',
},
] as const;
export default function AddonManager({
bagTrackingEnabled,
onToggleBagTracking,
collabFeatures,
onToggleCollabFeature,
}: {
bagTrackingEnabled?: boolean;
onToggleBagTracking?: () => void;
collabFeatures?: CollabFeatures;
onToggleCollabFeature?: (key: string) => void;
}) {
const { t } = useTranslation();
const dm = useSettingsStore((s) => s.settings.dark_mode);
const dark =
dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const toast = useToast();
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadAddons();
}, []);
const loadAddons = async () => {
setLoading(true);
try {
const data = await adminApi.addons();
setAddons(data.addons);
} catch (err: unknown) {
toast.error(t('admin.addons.toast.error'));
} finally {
setLoading(false);
}
};
const handleToggle = async (addon: Addon) => {
const newEnabled = !addon.enabled;
// Optimistic update
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: newEnabled } : a)));
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled });
refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
} catch (err: unknown) {
// Rollback
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: !newEnabled } : a)));
toast.error(t('admin.addons.toast.error'));
}
};
const isPhotoProviderAddon = (addon: Addon) => {
return addon.type === 'photo_provider';
};
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase();
return (
addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
);
};
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
const enableProvider = !providerAddon.enabled;
const prev = addons;
setAddons((current) => current.map((a) => (a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)));
try {
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider });
refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
} catch {
setAddons(prev);
toast.error(t('admin.addons.toast.error'));
}
};
const photoProviderAddons = addons.filter(isPhotoProviderAddon);
const photosAddon = addons.filter((a) => a.type === 'trip').find(isPhotosAddon);
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
const globalAddons = addons.filter((a) => a.type === 'global');
const integrationAddons = addons.filter((a) => a.type === 'integration');
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleTogglePhotoProvider(provider),
}));
const photosDerivedEnabled = providerOptions.some((p) => p.enabled);
if (loading) {
return (
<div className="p-8 text-center">
<div
className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900"
style={{ borderTopColor: 'var(--text-primary)' }}
></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="border-b px-6 py-4" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.addons.title')}
</h2>
<p
className="mt-1 text-xs"
style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}
>
{t('admin.addons.subtitleBefore')}
<img
src={dark ? '/text-light.svg' : '/text-dark.svg'}
alt="TREK"
style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }}
/>
{t('admin.addons.subtitleAfter')}
</p>
</div>
{addons.length === 0 ? (
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
{t('admin.addons.noAddons')}
</div>
) : (
<div>
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div
className="flex items-center gap-2 border-b px-6 py-2.5"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
>
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.trip')} {t('admin.addons.tripHint')}
</span>
</div>
{tripAddons.map((addon) => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div
className="flex items-center gap-4 border-b px-6 py-3"
style={{
borderColor: 'var(--border-secondary)',
background: 'var(--bg-secondary)',
paddingLeft: 70,
}}
>
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('admin.bagTracking.title')}
</div>
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.bagTracking.subtitle')}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={onToggleBagTracking}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
</div>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div
className="border-b px-6 py-3"
style={{
borderColor: 'var(--border-secondary)',
background: 'var(--bg-secondary)',
paddingLeft: 70,
}}
>
<div className="space-y-2">
{COLLAB_SUB_FEATURES.map((feat) => {
const enabled = collabFeatures[feat.key];
const Icon = feat.icon;
return (
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t(feat.titleKey)}
</div>
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{t(feat.subtitleKey)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={() => onToggleCollabFeature(feat.key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div
className="flex items-center gap-2 border-b border-t px-6 py-2.5"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
>
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.global')} {t('admin.addons.globalHint')}
</span>
</div>
{globalAddons.map((addon) => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div
className="border-b px-6 py-3"
style={{
borderColor: 'var(--border-secondary)',
background: 'var(--bg-secondary)',
paddingLeft: 70,
}}
>
<div className="space-y-2">
{providerOptions.map((provider) => {
const ProviderIcon = PROVIDER_ICONS[provider.key];
return (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && (
<span style={{ color: 'var(--text-faint)' }}>
<ProviderIcon size={14} />
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{provider.label}
</div>
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{provider.description}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{
background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Integration Addons */}
{integrationAddons.length > 0 && (
<div>
<div
className="flex items-center gap-2 border-b border-t px-6 py-2.5"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
>
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.integration')} {t('admin.addons.integrationHint')}
</span>
</div>
{integrationAddons.map((addon) => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
interface AddonRowProps {
addon: Addon;
onToggle: (addon: Addon) => void;
t: (key: string) => string;
statusOverride?: boolean;
hideToggle?: boolean;
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
const nameKey = `admin.addons.catalog.${addon.id}.name`;
const descKey = `admin.addons.catalog.${addon.id}.description`;
const translatedName = t(nameKey);
const translatedDescription = t(descKey);
return {
name: translatedName !== nameKey ? translatedName : addon.name,
description: translatedDescription !== descKey ? translatedDescription : addon.description,
};
}
function AddonRow({
addon,
onToggle,
t,
nameOverride,
descriptionOverride,
statusOverride,
hideToggle,
}: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
const isComingSoon = false;
const label = getAddonLabel(t, addon);
const displayName = nameOverride || label.name;
const displayDescription = descriptionOverride || label.description;
const enabledState = statusOverride ?? addon.enabled;
return (
<div
className="flex items-center gap-4 border-b px-6 py-4 transition-colors hover:opacity-95"
style={{
borderColor: 'var(--border-secondary)',
opacity: isComingSoon ? 0.5 : 1,
pointerEvents: isComingSoon ? 'none' : 'auto',
}}
>
{/* Icon */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
>
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{displayName}
</span>
{isComingSoon && (
<span
className="rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}
>
Coming Soon
</span>
)}
<span
className="rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
{addon.type === 'global'
? t('admin.addons.type.global')
: addon.type === 'integration'
? t('admin.addons.type.integration')
: t('admin.addons.type.trip')}
</span>
</div>
<p className="mt-0.5 text-xs" style={{ color: 'var(--text-muted)' }}>
{displayDescription}
</p>
</div>
{/* Toggle */}
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: enabledState && !isComingSoon ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{isComingSoon
? t('admin.addons.disabled')
: enabledState
? t('admin.addons.enabled')
: t('admin.addons.disabled')}
</span>
{!hideToggle && (
<button
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{
background: enabledState && !isComingSoon ? 'var(--text-primary)' : 'var(--border-primary)',
cursor: isComingSoon ? 'not-allowed' : 'pointer',
}}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: 'var(--bg-card)',
transform: enabledState && !isComingSoon ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
)}
</div>
</div>
);
}
@@ -1,326 +0,0 @@
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
const TOKEN_1 = {
id: 1,
name: 'CI Token',
token_prefix: 'trek_abc',
created_at: '2025-01-15T00:00:00Z',
last_used_at: null,
user_id: 10,
username: 'alice',
};
const TOKEN_2 = {
id: 2,
name: 'Ops Token',
token_prefix: 'trek_xyz',
created_at: '2025-03-01T00:00:00Z',
last_used_at: '2025-04-01T00:00:00Z',
user_id: 11,
username: 'bob',
};
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/mcp-tokens', async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ tokens: [] });
})
);
render(<AdminMcpTokensPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
render(<AdminMcpTokensPanel />);
await screen.findByText('No MCP tokens have been created yet');
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Ops Token')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
// token_prefix is rendered as `{token.token_prefix}...` — two adjacent text nodes
expect(screen.getByText(/trek_abc/)).toBeInTheDocument();
expect(screen.getByText(/trek_xyz/)).toBeInTheDocument();
});
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Never')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
const user = userEvent.setup();
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
expect(screen.getByText('Delete Token')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
// Dialog Delete button has visible text "Delete"; trash icon buttons have no text content
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
const user = userEvent.setup();
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
expect(screen.getByText('Delete Token')).toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
expect(screen.getByText('CI Token')).toBeInTheDocument();
expect(screen.getByText('Ops Token')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
const user = userEvent.setup();
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
expect(screen.getByText('Delete Token')).toBeInTheDocument();
const backdrop = document.querySelector('.fixed.inset-0');
expect(backdrop).toBeInTheDocument();
await user.click(backdrop!);
await waitFor(() => {
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
await user.click(screen.getByText('Delete'));
await waitFor(() => {
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
});
expect(screen.queryByText('CI Token')).not.toBeInTheDocument();
expect(screen.getByText('Ops Token')).toBeInTheDocument();
await screen.findByText('Token deleted');
});
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
await user.click(screen.getByText('Delete'));
await screen.findByText('Failed to delete token');
expect(screen.getByText('CI Token')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
await screen.findByText('Failed to load tokens');
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/oauth-sessions', async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ sessions: [] });
})
);
render(<AdminMcpTokensPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
});
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 1,
client_name: 'Claude Desktop',
username: 'alice',
scopes: ['trips:read', 'budget:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('Claude Desktop');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('trips:read')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
const user = userEvent.setup();
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
const scopes = [
'trips:read',
'trips:write',
'places:read',
'places:write',
'budget:read',
'budget:write',
'packing:read',
];
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('App');
// "+1 more" button should appear
const moreBtn = await screen.findByText(/\+1 more/);
expect(moreBtn).toBeInTheDocument();
await user.click(moreBtn);
// After expand, "show less" appears
expect(await screen.findByText('show less')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 5,
client_name: 'Revoke Me',
username: 'carol',
scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
),
http.delete('/api/admin/oauth-sessions/5', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
await screen.findByText('Revoke Me');
// Click the revoke (trash) button next to the session
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
// Confirmation modal opens
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find((b) => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 6,
client_name: 'Error Session',
username: 'dave',
scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
),
http.delete('/api/admin/oauth-sessions/6', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
await screen.findByText('Error Session');
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find((b) => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
});
});
@@ -1,364 +0,0 @@
import { Key, Loader2, Shield, Trash2, User } from 'lucide-react';
import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useToast } from '../shared/Toast';
interface AdminOAuthSession {
id: number;
client_id: string;
client_name: string;
user_id: number;
username: string;
scopes: string[];
access_token_expires_at: string;
refresh_token_expires_at: string;
created_at: string;
}
interface AdminMcpToken {
id: number;
name: string;
token_prefix: string;
created_at: string;
last_used_at: string | null;
user_id: number;
username: string;
}
const SCOPES_PREVIEW = 6;
export default function AdminMcpTokensPanel() {
const [sessions, setSessions] = useState<AdminOAuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [tokens, setTokens] = useState<AdminMcpToken[]>([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set());
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
const toggleScopes = (id: number) =>
setExpandedScopes((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toast = useToast();
const { t, locale } = useTranslation();
useEffect(() => {
adminApi
.oauthSessions()
.then((d) => setSessions(d.sessions || []))
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
.finally(() => setSessionsLoading(false));
adminApi
.mcpTokens()
.then((d) => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
.finally(() => setTokensLoading(false));
}, []);
const handleRevoke = async (id: number) => {
try {
await adminApi.revokeOAuthSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setRevokeConfirmId(null);
toast.success(t('admin.oauthSessions.revokeSuccess'));
} catch {
toast.error(t('admin.oauthSessions.revokeError'));
}
};
const handleDelete = async (id: number) => {
try {
await adminApi.deleteMcpToken(id);
setTokens((prev) => prev.filter((tk) => tk.id !== id));
setDeleteConfirmId(null);
toast.success(t('admin.mcpTokens.deleteSuccess'));
} catch {
toast.error(t('admin.mcpTokens.deleteError'));
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.mcpTokens.title')}
</h2>
<p className="mt-0.5 text-sm" style={{ color: 'var(--text-tertiary)' }}>
{t('admin.mcpTokens.subtitle')}
</p>
</div>
{/* OAuth Sessions */}
<div>
<h3 className="mb-2 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.oauthSessions.sectionTitle')}
</h3>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{sessionsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<Shield className="h-8 w-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
{t('admin.oauthSessions.empty')}
</p>
</div>
) : (
<>
<div
className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 border-b px-4 py-2.5 text-xs font-medium"
style={{
color: 'var(--text-tertiary)',
borderColor: 'var(--border-primary)',
background: 'var(--bg-secondary)',
}}
>
<span>{t('admin.oauthSessions.clientName')}</span>
<span>{t('admin.oauthSessions.owner')}</span>
<span className="text-right">{t('admin.oauthSessions.created')}</span>
<span></span>
</div>
{sessions.map((session, i) => {
const expanded = expandedScopes.has(session.id);
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW);
const hidden = session.scopes.length - SCOPES_PREVIEW;
return (
<div
key={session.id}
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{session.client_name}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{visible.map((scope) => (
<span
key={scope}
className="inline-flex items-center rounded px-1.5 py-0.5 font-mono text-xs"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-tertiary)',
border: '1px solid var(--border-primary)',
}}
>
{scope}
</span>
))}
{!expanded && hidden > 0 && (
<button
onClick={() => toggleScopes(session.id)}
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium transition-colors hover:opacity-80"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border-primary)',
}}
>
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button
onClick={() => toggleScopes(session.id)}
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium transition-colors hover:opacity-80"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border-primary)',
}}
>
show less
</button>
)}
</div>
</div>
<div
className="flex items-center gap-1.5 pt-0.5 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
<User className="h-3.5 w-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{session.username}</span>
</div>
<span
className="whitespace-nowrap pt-0.5 text-right text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button
onClick={() => setRevokeConfirmId(session.id)}
className="rounded-lg p-1.5 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</>
)}
</div>
</div>
{/* MCP Tokens */}
<div>
<h3 className="mb-2 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.mcpTokens.sectionTitle')}
</h3>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{tokensLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<Key className="h-8 w-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
{t('admin.mcpTokens.empty')}
</p>
</div>
) : (
<>
<div
className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 border-b px-4 py-2.5 text-xs font-medium"
style={{
color: 'var(--text-tertiary)',
borderColor: 'var(--border-primary)',
background: 'var(--bg-secondary)',
}}
>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div>
{tokens.map((token, i) => (
<div
key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{token.name}
</p>
<p className="mt-0.5 font-mono text-xs" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}...
</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="h-3.5 w-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="whitespace-nowrap text-right text-xs" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="whitespace-nowrap text-right text-xs" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at
? new Date(token.last_used_at).toLocaleDateString(locale)
: t('admin.mcpTokens.never')}
</span>
<button
onClick={() => setDeleteConfirmId(token.id)}
className="rounded-lg p-1.5 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</>
)}
</div>
</div>
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={(e) => {
if (e.target === e.currentTarget) setRevokeConfirmId(null);
}}
>
<div className="w-full max-w-sm space-y-4 rounded-xl p-6 shadow-xl" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.oauthSessions.revokeTitle')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('admin.oauthSessions.revokeMessage')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setRevokeConfirmId(null)}
className="rounded-lg border px-4 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => handleRevoke(revokeConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={(e) => {
if (e.target === e.currentTarget) setDeleteConfirmId(null);
}}
>
<div className="w-full max-w-sm space-y-4 rounded-xl p-6 shadow-xl" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.mcpTokens.deleteTitle')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('admin.mcpTokens.deleteMessage')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteConfirmId(null)}
className="rounded-lg border px-4 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => handleDelete(deleteConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,201 +0,0 @@
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import AuditLogPanel from './AuditLogPanel';
const ENTRY_1 = {
id: 1,
created_at: '2025-06-01T10:30:00Z',
user_id: 5,
username: 'alice',
user_email: 'alice@example.com',
action: 'trip.create',
resource: '/trips/42',
details: { title: 'Test' },
ip: '127.0.0.1',
};
const ENTRY_2 = {
id: 2,
created_at: '2025-06-02T11:00:00Z',
user_id: 6,
username: 'bob',
user_email: 'bob@example.com',
action: 'trip.delete',
resource: '/trips/43',
details: null,
ip: '10.0.0.1',
};
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('AuditLogPanel', () => {
it('FE-ADMIN-AUDIT-001: loading state shown on mount', async () => {
server.use(
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
})
);
render(<AuditLogPanel serverTimezone="UTC" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('No audit entries yet.');
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('Time')).toBeInTheDocument();
expect(screen.getByText('User')).toBeInTheDocument();
expect(screen.getByText('Action')).toBeInTheDocument();
expect(screen.getByText('Resource')).toBeInTheDocument();
expect(screen.getByText('IP')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('/trips/42')).toBeInTheDocument();
expect(screen.getByText('127.0.0.1')).toBeInTheDocument();
expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-004: userLabel fallback chain', async () => {
const entries = [
{ ...ENTRY_1, id: 10, username: 'alice', user_email: null, user_id: 5, action: 'a.username' },
{ ...ENTRY_1, id: 11, username: null, user_email: 'bob@example.com', user_id: 6, action: 'a.email' },
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
];
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.username');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
expect(screen.getByText('#7')).toBeInTheDocument();
// '—' appears multiple times (null resource, null ip for some, null user) — just check it exists
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
});
it('FE-ADMIN-AUDIT-005: dash shown for null resource, ip, and details', async () => {
const entry = {
...ENTRY_1,
id: 20,
action: 'a.nulls',
resource: null,
ip: null,
details: null,
};
const entryEmptyDetails = {
...ENTRY_1,
id: 21,
action: 'a.emptyobj',
resource: '/ok',
ip: '1.2.3.4',
details: {},
};
server.use(
http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }))
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.nulls');
// null resource, null ip, null details → three '—' for entry; empty obj details → another '—'
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(4);
});
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 50 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-007: "Load more" appends entries', async () => {
let callCount = 0;
server.use(
http.get('/api/admin/audit-log', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
}
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
})
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
const loadMoreBtn = screen.getByText('Load more');
expect(loadMoreBtn).toBeInTheDocument();
await user.click(loadMoreBtn);
await screen.findByText('trip.delete');
expect(screen.getByText('trip.create')).toBeInTheDocument();
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-009: Refresh resets list to page 1', async () => {
const PAGE1_ENTRY = { ...ENTRY_1, id: 100, action: 'phase1.action' };
const PAGE2_ENTRY = { ...ENTRY_2, id: 101, action: 'phase2.action' };
const REFRESH_ENTRY = { ...ENTRY_2, id: 102, action: 'phase3.refresh' };
let callCount = 0;
server.use(
http.get('/api/admin/audit-log', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ entries: [PAGE1_ENTRY], total: 2 });
}
if (callCount === 2) {
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
}
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
})
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
// Initial load: PAGE1_ENTRY visible, load more
await screen.findByText('phase1.action');
const loadMoreBtn = screen.getByText('Load more');
await user.click(loadMoreBtn);
await screen.findByText('phase2.action');
// Now refresh
const refreshBtn = screen.getByText('Refresh');
await user.click(refreshBtn);
// After refresh, only REFRESH_ENTRY should be visible
await screen.findByText('phase3.refresh');
await waitFor(() => expect(screen.queryByText('phase1.action')).not.toBeInTheDocument());
expect(screen.queryByText('phase2.action')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-010: Refresh button is disabled while loading', async () => {
server.use(
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
})
);
render(<AuditLogPanel serverTimezone="UTC" />);
const refreshBtn = screen.getByText('Refresh');
expect(refreshBtn.closest('button')).toBeDisabled();
});
});
@@ -1,204 +0,0 @@
import { ClipboardList, RefreshCw } from 'lucide-react';
import React, { useCallback, useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
interface AuditEntry {
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: Record<string, unknown> | null;
ip: string | null;
}
interface AuditLogPanelProps {
serverTimezone?: string;
}
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
const { t, locale } = useTranslation();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const limit = 100;
const loadFirstPage = useCallback(async () => {
setLoading(true);
try {
const data = (await adminApi.auditLog({ limit, offset: 0 })) as {
entries: AuditEntry[];
total: number;
};
setEntries(data.entries || []);
setTotal(data.total ?? 0);
setOffset(0);
} catch {
setEntries([]);
setTotal(0);
setOffset(0);
} finally {
setLoading(false);
}
}, []);
const loadMore = useCallback(async () => {
const nextOffset = offset + limit;
setLoading(true);
try {
const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as {
entries: AuditEntry[];
total: number;
};
setEntries((prev) => [...prev, ...(data.entries || [])]);
setTotal(data.total ?? 0);
setOffset(nextOffset);
} catch {
/* keep existing */
} finally {
setLoading(false);
}
}, [offset]);
useEffect(() => {
loadFirstPage();
}, [loadFirstPage]);
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
});
} catch {
return iso;
}
};
const fmtDetails = (d: Record<string, unknown> | null) => {
if (!d || Object.keys(d).length === 0) return '—';
try {
return JSON.stringify(d);
} catch {
return '—';
}
};
const userLabel = (e: AuditEntry) => {
if (e.username) return e.username;
if (e.user_email) return e.user_email;
if (e.user_id != null) return `#${e.user_id}`;
return '—';
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="m-0 flex items-center gap-2 text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
<ClipboardList size={20} />
{t('admin.tabs.audit')}
</h2>
<p className="m-0 mt-1 text-sm" style={{ color: 'var(--text-muted)' }}>
{t('admin.audit.subtitle')}
</p>
</div>
<button
type="button"
disabled={loading}
onClick={() => loadFirstPage()}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('admin.audit.refresh')}
</button>
</div>
<p className="m-0 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.audit.showing', { count: entries.length, total })}
</p>
{loading && entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>
{t('common.loading')}
</div>
) : entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>
{t('admin.audit.empty')}
</div>
) : (
<div
className="overflow-x-auto rounded-xl border"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead>
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.time')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.user')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.action')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.resource')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.ip')}
</th>
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.details')}
</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
<td className="whitespace-nowrap p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>
{fmtTime(e.created_at)}
</td>
<td className="p-3" style={{ color: 'var(--text-primary)' }}>
{userLabel(e)}
</td>
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>
{e.action}
</td>
<td className="max-w-[140px] break-all p-3 font-mono text-xs" style={{ color: 'var(--text-muted)' }}>
{e.resource || '—'}
</td>
<td className="whitespace-nowrap p-3 font-mono text-xs" style={{ color: 'var(--text-muted)' }}>
{e.ip || '—'}
</td>
<td className="max-w-[280px] break-all p-3 font-mono text-xs" style={{ color: 'var(--text-faint)' }}>
{fmtDetails(e.details)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{entries.length < total && (
<button
type="button"
disabled={loading}
onClick={() => loadMore()}
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
style={{ color: 'var(--text-secondary)' }}
>
{t('admin.audit.loadMore')}
</button>
)}
</div>
);
}
@@ -1,325 +0,0 @@
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { ToastContainer } from '../shared/Toast';
import BackupPanel from './BackupPanel';
const manualBackup = {
filename: 'backup-2025-01-15.zip',
created_at: '2025-01-15T10:00:00Z',
size: 2048000,
};
const autoBackup = {
filename: 'auto-backup-2025-02-01.zip',
created_at: '2025-02-01T02:00:00Z',
size: 1024000,
};
function defaultBackupHandlers() {
return [
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
})
),
];
}
function getToggleButton() {
// The enable toggle is a <button> inside a <label> that contains "Enable auto-backup"
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement;
return label.querySelector('button') as HTMLElement;
}
describe('BackupPanel', () => {
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any);
vi.spyOn(window, 'confirm').mockReturnValue(true);
server.use(...defaultBackupHandlers());
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
server.resetHandlers();
});
// BKP-001: Loading state
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
server.use(
http.get('/api/backup/list', async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return HttpResponse.json({ backups: [] });
})
);
render(<BackupPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
// BKP-002: Empty state
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })));
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('No backups yet')).toBeInTheDocument();
});
expect(screen.getByText('Create first backup')).toBeInTheDocument();
});
// BKP-003: Backup list renders filename, size, and date
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
expect(screen.getByText('2.0 MB')).toBeInTheDocument();
});
// BKP-004: Auto-backup badge shown for auto-backup filenames
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })));
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument();
});
expect(screen.getByText('Auto')).toBeInTheDocument();
});
// BKP-005: Create backup success
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] }))
);
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getByTitle('Create Backup'));
await waitFor(() => {
expect(screen.getByText('Backup created successfully')).toBeInTheDocument();
});
});
// BKP-006: Restore opens confirmation modal
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
});
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Yes, restore')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
// BKP-007: Cancel dismisses modal without calling restore API
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
const user = userEvent.setup();
let restoreCalled = false;
server.use(
http.post('/api/backup/restore/:filename', () => {
restoreCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
});
await user.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
});
expect(restoreCalled).toBe(false);
});
// BKP-008: Backdrop click dismisses modal
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
});
// Click the backdrop overlay (the fixed-position div)
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop!);
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
});
});
// BKP-009: Successful restore calls API and reloads after 1500ms
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
const user = userEvent.setup();
server.use(http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })));
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
const reloadMock = vi.fn();
vi.stubGlobal('location', { ...window.location, reload: reloadMock });
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument());
await user.click(screen.getByText('Yes, restore'));
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument());
// Wait for the 1500ms reload timer to fire
await new Promise((resolve) => setTimeout(resolve, 1600));
expect(reloadMock).toHaveBeenCalled();
vi.unstubAllGlobals();
}, 20000);
// BKP-010: Delete backup with confirm dialog
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
const user = userEvent.setup();
server.use(http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })));
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
const trashBtn = Array.from(document.querySelectorAll('button')).find((b) =>
b.querySelector('svg.lucide-trash2')
) as HTMLElement;
expect(trashBtn).toBeTruthy();
await user.click(trashBtn!);
await waitFor(() => {
expect(screen.getByText('Backup deleted')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument();
});
});
// BKP-011: Auto-backup enable toggle shows interval controls
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
});
expect(screen.queryByText('Hourly')).not.toBeInTheDocument();
await user.click(getToggleButton());
await waitFor(() => {
expect(screen.getByText('Hourly')).toBeInTheDocument();
expect(screen.getByText('Daily')).toBeInTheDocument();
expect(screen.getByText('Weekly')).toBeInTheDocument();
expect(screen.getByText('Monthly')).toBeInTheDocument();
});
});
// BKP-012: Weekly interval shows day-of-week picker
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
})
)
);
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument();
});
expect(screen.queryByText('Sun')).not.toBeInTheDocument();
await user.click(screen.getByText('Weekly'));
await waitFor(() => {
expect(screen.getByText('Sun')).toBeInTheDocument();
expect(screen.getByText('Mon')).toBeInTheDocument();
expect(screen.getByText('Sat')).toBeInTheDocument();
});
expect(screen.queryByText('Day of month')).not.toBeInTheDocument();
});
// BKP-013: Save auto-settings calls API and shows toast
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
})
),
http.put('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
})
)
);
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument();
});
await user.click(screen.getByText('Weekly'));
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /^save$/i });
expect(saveBtn).not.toBeDisabled();
});
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => {
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument();
});
});
// BKP-014: Save button disabled until settings changed
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
});
const saveBtn = screen.getByRole('button', { name: /^save$/i });
expect(saveBtn).toBeDisabled();
await user.click(getToggleButton());
await waitFor(() => {
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
});
});
});
-605
View File
@@ -1,605 +0,0 @@
import {
AlertTriangle,
Check,
Clock,
Download,
HardDrive,
Plus,
RefreshCw,
RotateCcw,
Trash2,
Upload,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
import CustomSelect from '../shared/CustomSelect';
import { useToast } from '../shared/Toast';
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
];
const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
];
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
{ value: 1, labelKey: 'backup.dow.monday' },
{ value: 2, labelKey: 'backup.dow.tuesday' },
{ value: 3, labelKey: 'backup.dow.wednesday' },
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
];
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1);
export default function BackupPanel() {
const [backups, setBackups] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [restoringFile, setRestoringFile] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [autoSettings, setAutoSettings] = useState({
enabled: false,
interval: 'daily',
keep_days: 7,
hour: 2,
day_of_week: 0,
day_of_month: 1,
});
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false);
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false);
const [serverTimezone, setServerTimezone] = useState('');
const [restoreConfirm, setRestoreConfirm] = useState(null); // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null);
const toast = useToast();
const { t, language, locale } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
setIsLoading(true);
try {
const data = await backupApi.list();
setBackups(data.backups || []);
} catch {
toast.error(t('backup.toast.loadError'));
} finally {
setIsLoading(false);
}
};
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings();
setAutoSettings(data.settings);
if (data.timezone) setServerTimezone(data.timezone);
} catch {}
};
useEffect(() => {
loadBackups();
loadAutoSettings();
}, []);
const handleCreate = async () => {
setIsCreating(true);
try {
await backupApi.create();
toast.success(t('backup.toast.created'));
await loadBackups();
} catch {
toast.error(t('backup.toast.createError'));
} finally {
setIsCreating(false);
}
};
const handleRestore = (filename) => {
setRestoreConfirm({ type: 'file', filename });
};
const handleUploadRestore = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
e.target.value = '';
setRestoreConfirm({ type: 'upload', filename: file.name, file });
};
const executeRestore = async () => {
if (!restoreConfirm) return;
const { type, filename, file } = restoreConfirm;
setRestoreConfirm(null);
if (type === 'file') {
setRestoringFile(filename);
try {
await backupApi.restore(filename);
toast.success(t('backup.toast.restored'));
setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')));
setRestoringFile(null);
}
} else {
setIsUploading(true);
try {
await backupApi.uploadRestore(file);
toast.success(t('backup.toast.restored'));
setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')));
setIsUploading(false);
}
}
};
const handleDelete = async (filename) => {
if (!confirm(t('backup.confirm.delete', { name: filename }))) return;
try {
await backupApi.delete(filename);
toast.success(t('backup.toast.deleted'));
setBackups((prev) => prev.filter((b) => b.filename !== filename));
} catch {
toast.error(t('backup.toast.deleteError'));
}
};
const handleAutoSettingsChange = (key, value) => {
setAutoSettings((prev) => ({ ...prev, [key]: value }));
setAutoSettingsDirty(true);
};
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true);
try {
const data = await backupApi.setAutoSettings(autoSettings);
setAutoSettings(data.settings);
setAutoSettingsDirty(false);
toast.success(t('backup.toast.settingsSaved'));
} catch {
toast.error(t('backup.toast.settingsError'));
} finally {
setAutoSettingsSaving(false);
}
};
const formatSize = (bytes) => {
if (!bytes) return '-';
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const formatDate = (dateStr) => {
if (!dateStr) return '-';
try {
const opts: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
} catch {
return dateStr;
}
};
const isAuto = (filename) => filename.startsWith('auto-backup-');
return (
<div className="flex flex-col gap-6">
{/* Manual Backups */}
<div className="rounded-2xl border border-gray-200 bg-white p-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<HardDrive className="h-5 w-5 text-gray-400" />
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('backup.title')}
</h2>
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
{t('backup.subtitle')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadBackups}
disabled={isLoading}
className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100"
title={t('backup.refresh')}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{/* Upload & Restore */}
<input ref={fileInputRef} type="file" accept=".zip" className="hidden" onChange={handleUploadRestore} />
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60"
title={isUploading ? t('backup.uploading') : t('backup.upload')}
>
{isUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Upload className="h-4 w-4" />
)}
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
</button>
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900 disabled:opacity-60 dark:bg-slate-100 dark:text-slate-900 sm:px-4"
title={isCreating ? t('backup.creating') : t('backup.create')}
>
{isCreating ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<Plus className="h-4 w-4" />
)}
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
</button>
</div>
</div>
{isLoading && backups.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-400">
<div className="mr-2 h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-slate-700" />
{t('common.loading')}
</div>
) : backups.length === 0 ? (
<div className="py-12 text-center text-gray-400">
<HardDrive className="mx-auto mb-3 h-10 w-10 opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-sm text-slate-700 hover:underline">
{t('backup.createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{backups.map((backup) => (
<div key={backup.filename} className="flex items-center gap-4 py-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-100">
{isAuto(backup.filename) ? (
<RefreshCw className="h-4 w-4 text-blue-500" />
) : (
<HardDrive className="h-4 w-4 text-gray-500" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-gray-900">{backup.filename}</p>
{isAuto(backup.filename) && (
<span className="whitespace-nowrap rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
Auto
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-3">
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
onClick={() =>
backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))
}
className="flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
>
<Download className="h-3.5 w-3.5" />
{t('backup.download')}
</button>
<button
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 rounded-lg border border-amber-200 px-3 py-1.5 text-xs text-amber-700 hover:bg-amber-50 disabled:opacity-60"
>
{restoringFile === backup.filename ? (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-amber-400 border-t-transparent" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
{t('backup.restore')}
</button>
<button
onClick={() => handleDelete(backup.filename)}
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Auto-Backup Settings */}
<div className="rounded-2xl border border-gray-200 bg-white p-6">
<div className="mb-6 flex items-center gap-3">
<Clock className="h-5 w-5 text-gray-400" />
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('backup.auto.title')}
</h2>
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
{t('backup.auto.subtitle')}
</p>
</div>
</div>
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex cursor-pointer items-center justify-between gap-4">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="mt-0.5 text-xs text-gray-500">{t('backup.auto.enableHint')}</p>
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors"
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</label>
{autoSettings.enabled && (
<>
{/* Interval */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
autoSettings.interval === opt.value
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
{/* Hour picker (for daily, weekly, monthly) */}
{autoSettings.interval !== 'hourly' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={(v) => handleAutoSettingsChange('hour', parseInt(v, 10))}
size="sm"
options={HOURS.map((h) => {
let label: string;
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
label = `${h12}:00 ${period}`;
} else {
label = `${String(h).padStart(2, '0')}:00`;
}
return { value: String(h), label };
})}
/>
<p className="mt-1 text-xs text-gray-400">
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}
{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
</p>
</div>
)}
{/* Day of week (for weekly) */}
{autoSettings.interval === 'weekly' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map((opt) => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
autoSettings.day_of_week === opt.value
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
)}
{/* Day of month (for monthly) */}
{autoSettings.interval === 'monthly' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={(v) => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
size="sm"
options={DAYS_OF_MONTH.map((d) => ({ value: String(d), label: String(d) }))}
/>
<p className="mt-1 text-xs text-gray-400">{t('backup.auto.dayOfMonthHint')}</p>
</div>
)}
{/* Keep duration */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
autoSettings.keep_days === opt.value
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
</>
)}
{/* Save button */}
<div className="flex justify-end border-t border-gray-100 pt-2">
<button
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 rounded-lg bg-slate-900 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-900 disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900"
>
{autoSettingsSaving ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<Check className="h-4 w-4" />
)}
{autoSettingsSaving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
onClick={() => setRestoreConfirm(null)}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
{/* Red header */}
<div
style={{
background: 'linear-gradient(135deg, #dc2626, #b91c1c)',
padding: '20px 24px',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
{restoreConfirm.filename}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')}
</p>
<div
style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="border border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300"
>
{t('backup.restoreTip')}
</div>
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
style={{
padding: '9px 20px',
borderRadius: 10,
fontSize: 13,
fontWeight: 600,
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button
onClick={executeRestore}
style={{
padding: '9px 20px',
borderRadius: 10,
fontSize: 13,
fontWeight: 600,
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
background: '#dc2626',
color: 'white',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#b91c1c')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#dc2626')}
>
{t('backup.restoreConfirm')}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,158 +0,0 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-012
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildCategory, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import CategoryManager from './CategoryManager';
beforeEach(() => {
resetAllStores();
server.use(http.get('/api/categories', () => HttpResponse.json({ categories: [] })));
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
});
describe('CategoryManager', () => {
it('FE-COMP-CAT-001: renders without crashing', () => {
render(<CategoryManager />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-CAT-002: shows Categories title', async () => {
render(<CategoryManager />);
await screen.findByText('Categories');
});
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
render(<CategoryManager />);
await screen.findByText('No categories yet');
});
it('FE-COMP-CAT-004: shows New Category button', async () => {
render(<CategoryManager />);
await screen.findByText('New Category');
});
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
});
it('FE-COMP-CAT-006: shows existing categories from API', async () => {
server.use(
http.get('/api/categories', () =>
HttpResponse.json({
categories: [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Restaurant' })],
})
)
);
render(<CategoryManager />);
await screen.findByText('Museum');
expect(screen.getByText('Restaurant')).toBeInTheDocument();
});
it('FE-COMP-CAT-007: clicking Create submits POST API', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/categories', async ({ request }) => {
postCalled = true;
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
category: buildCategory({ name: String(body.name) }),
});
})
);
render(
<>
<ToastContainer />
<CategoryManager />
</>
);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
const nameInput = screen.getByPlaceholderText('Category name');
await user.type(nameInput, 'Parks');
await user.click(screen.getByText('Create'));
await waitFor(() => expect(postCalled).toBe(true));
});
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] }))
);
render(<CategoryManager />);
await screen.findByText('Hotels');
// Edit button is icon-only (no title) — find all buttons and click the first action button
const buttons = screen.getAllByRole('button');
// Buttons: [New Category, ...action buttons for the category]
// The edit button is the first action button in the category row (Edit2 icon)
const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
await user.click(actionBtns[0]);
// Name input pre-filled with category name
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
});
it('FE-COMP-CAT-009: delete button triggers DELETE API', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(
<>
<ToastContainer />
<CategoryManager />
</>
);
await screen.findByText('Parks');
// Delete button is icon-only (Trash2, no title) — find the second action button
const buttons = screen.getAllByRole('button');
const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
await waitFor(() => expect(deleteCalled).toBe(true));
vi.restoreAllMocks();
});
it('FE-COMP-CAT-010: shows subtitle text', async () => {
render(<CategoryManager />);
await screen.findByText('Manage categories for places');
});
it('FE-COMP-CAT-011: category count is shown', async () => {
server.use(
http.get('/api/categories', () =>
HttpResponse.json({
categories: [buildCategory({ name: 'Cat1' }), buildCategory({ name: 'Cat2' })],
})
)
);
render(<CategoryManager />);
await screen.findByText('Cat1');
await screen.findByText('Cat2');
// Both categories rendered
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
it('FE-COMP-CAT-012: Cancel button in form hides the form', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
});
});
@@ -1,295 +0,0 @@
import { Edit2, Pipette, Plus, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { categoriesApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../types';
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons';
import { useToast } from '../shared/Toast';
const PRESET_COLORS = [
'#6366f1',
'#8b5cf6',
'#ec4899',
'#ef4444',
'#f97316',
'#f59e0b',
'#10b981',
'#06b6d4',
'#3b82f6',
'#84cc16',
'#6b7280',
'#1f2937',
];
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP);
export default function CategoryManager() {
const [categories, setCategories] = useState([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' });
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const colorInputRef = useRef(null);
const toast = useToast();
const { t } = useTranslation();
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
setIsLoading(true);
try {
const data = await categoriesApi.list();
setCategories(data.categories || []);
} catch (err: unknown) {
toast.error(t('categories.toast.loadError'));
} finally {
setIsLoading(false);
}
};
const handleStartEdit = (cat) => {
setEditingId(cat.id);
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' });
setShowForm(false);
};
const handleStartCreate = () => {
setEditingId(null);
setForm({ name: '', color: '#6366f1', icon: 'MapPin' });
setShowForm(true);
};
const handleCancel = () => {
setShowForm(false);
setEditingId(null);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error(t('categories.toast.nameRequired'));
return;
}
setIsSaving(true);
try {
if (editingId) {
const result = await categoriesApi.update(editingId, form);
setCategories((prev) => prev.map((c) => (c.id === editingId ? result.category : c)));
setEditingId(null);
toast.success(t('categories.toast.updated'));
} else {
const result = await categoriesApi.create(form);
setCategories((prev) => [...prev, result.category]);
setShowForm(false);
toast.success(t('categories.toast.created'));
}
setForm({ name: '', color: '#6366f1', icon: 'MapPin' });
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')));
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id) => {
if (!confirm(t('categories.confirm.delete'))) return;
try {
await categoriesApi.delete(id);
setCategories((prev) => prev.filter((c) => c.id !== id));
toast.success(t('categories.toast.deleted'));
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')));
}
};
const isPresetColor = PRESET_COLORS.includes(form.color);
const PreviewIcon = getCategoryIcon(form.icon);
const categoryForm = (
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('categories.namePlaceholder')}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
autoFocus
/>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">{t('categories.icon')}</label>
<div className="max-h-48 overflow-y-auto">
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
{ICON_NAMES.map((name) => {
const Icon = CATEGORY_ICON_MAP[name];
const isSelected = form.icon === name;
return (
<button
key={name}
type="button"
title={ICON_LABELS[name] || name}
onClick={() => setForm((prev) => ({ ...prev, icon: name }))}
className={`flex h-9 w-9 items-center justify-center rounded-lg transition-all ${
isSelected ? 'ring-2 ring-slate-700 ring-offset-1' : 'hover:bg-gray-200'
}`}
style={{ background: isSelected ? `${form.color}18` : undefined }}
>
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
</button>
);
})}
</div>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600">{t('categories.color')}</label>
<div className="flex flex-wrap items-center gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setForm((prev) => ({ ...prev, color }))}
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'scale-110 ring-2 ring-gray-400 ring-offset-2' : ''}`}
style={{ backgroundColor: color }}
/>
))}
{/* Custom color button */}
<input
ref={colorInputRef}
type="color"
value={form.color}
onChange={(e) => setForm((prev) => ({ ...prev, color: e.target.value }))}
className="sr-only"
/>
<button
type="button"
title={t('categories.customColor')}
onClick={() => colorInputRef.current?.click()}
className={`flex h-7 w-7 items-center justify-center rounded-full border-2 transition-transform hover:scale-110 ${
!isPresetColor
? 'scale-110 border-transparent ring-2 ring-gray-400 ring-offset-2'
: 'border-dashed border-gray-300 hover:border-gray-400'
}`}
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
>
{isPresetColor && <Pipette className="h-3 w-3 text-gray-400" />}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium"
style={{ backgroundColor: `${form.color}20`, color: form.color }}
>
<PreviewIcon size={14} strokeWidth={1.8} />
{form.name || t('categories.defaultName')}
</span>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleCancel}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={isSaving || !form.name.trim()}
className="rounded-lg bg-slate-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-60"
>
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
</button>
</div>
</div>
);
return (
<div className="rounded-2xl border border-gray-200 bg-white p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('categories.title')}
</h2>
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
{t('categories.subtitle')}
</p>
</div>
<button
onClick={handleStartCreate}
className="flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700 sm:px-4"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t('categories.new')}</span>
</button>
</div>
{showForm && <div className="mb-4">{categoryForm}</div>}
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-400">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-slate-600" />
</div>
) : categories.length === 0 ? (
<div className="py-8 text-center text-gray-400">
<p className="text-sm">{t('categories.empty')}</p>
</div>
) : (
<div className="space-y-2">
{categories.map((cat) => {
const Icon = getCategoryIcon(cat.icon);
return (
<div key={cat.id}>
{editingId === cat.id ? (
<div className="mb-2">{categoryForm}</div>
) : (
<div className="group flex items-center gap-3 rounded-xl border border-gray-100 p-3 hover:border-gray-200">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
style={{ backgroundColor: `${cat.color}20` }}
>
<Icon size={18} strokeWidth={1.8} color={cat.color} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{cat.name}</span>
<span
className="rounded-full px-2 py-0.5 text-xs"
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}
>
{cat.color}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => handleStartEdit(cat)}
className="rounded-lg p-1.5 text-gray-400 hover:bg-slate-100 hover:text-slate-700"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(cat.id)}
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -1,357 +0,0 @@
import { Settings2 } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import type { Place } from '../../types';
import { MapView } from '../Map/MapView';
import Section from '../Settings/Section';
import CustomSelect from '../shared/CustomSelect';
import { useToast } from '../shared/Toast';
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
];
type Defaults = {
temperature_unit?: string;
dark_mode?: string | boolean;
time_format?: string;
route_calculation?: boolean;
blur_booking_codes?: boolean;
map_tile_url?: string;
};
function OptionRow({ label, hint, children }: { label: React.ReactNode; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-2 block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{hint && (
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
{hint}
</p>
)}
<div className="flex flex-wrap gap-3">{children}</div>
</div>
);
}
function OptionButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 20px',
borderRadius: 10,
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: 14,
fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{children}
</button>
);
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t } = useTranslation();
const toast = useToast();
const [defaults, setDefaults] = useState<Defaults>({});
const [loaded, setLoaded] = useState(false);
const [mapTileUrl, setMapTileUrl] = useState('');
useEffect(() => {
adminApi
.getDefaultUserSettings()
.then((data: Defaults) => {
setDefaults(data);
setMapTileUrl(data.map_tile_url || '');
setLoaded(true);
})
.catch(() => setLoaded(true));
}, []);
const save = async (patch: Partial<Defaults>) => {
try {
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>);
setDefaults(updated);
toast.success(t('admin.defaultSettings.saved'));
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'));
}
};
const reset = async (key: keyof Defaults) => {
try {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null });
setDefaults(updated);
if (key === 'map_tile_url') setMapTileUrl('');
toast.success(t('admin.defaultSettings.reset'));
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'));
}
};
const isSet = (key: keyof Defaults) => defaults[key] !== undefined;
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
onClick={() => reset(field)}
className="ml-2 text-xs"
style={{
color: 'var(--text-faint)',
textDecoration: 'underline',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null;
const mapPreviewPlaces = useMemo(
(): Place[] => [
{
id: 1,
trip_id: 1,
name: 'Preview center',
description: null,
notes: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
duration_minutes: null,
transport_mode: null,
website: null,
phone: null,
created_at: Date(),
},
],
[]
);
if (!loaded) {
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p>;
}
const darkMode = defaults.dark_mode;
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
{t('admin.defaultSettings.description')}
</p>
{/* Color Mode */}
<OptionRow
label={
<>
{t('settings.colorMode')} <ResetButton field="dark_mode" />
</>
}
>
{(
[
{ value: 'light', label: t('settings.light') },
{ value: 'dark', label: t('settings.dark') },
{ value: 'auto', label: t('settings.auto') },
] as const
).map((opt) => (
<OptionButton
key={opt.value}
active={
darkMode === opt.value ||
(opt.value === 'light' && darkMode === false) ||
(opt.value === 'dark' && darkMode === true)
}
onClick={() => save({ dark_mode: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Temperature */}
<OptionRow
label={
<>
{t('settings.temperature')} <ResetButton field="temperature_unit" />
</>
}
>
{(
[
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
] as const
).map((opt) => (
<OptionButton
key={opt.value}
active={defaults.temperature_unit === opt.value}
onClick={() => save({ temperature_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow
label={
<>
{t('settings.timeFormat')} <ResetButton field="time_format" />
</>
}
>
{(
[
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
] as const
).map((opt) => (
<OptionButton
key={opt.value}
active={defaults.time_format === opt.value}
onClick={() => save({ time_format: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Route Calculation */}
<OptionRow
label={
<>
{t('settings.routeCalculation')} <ResetButton field="route_calculation" />
</>
}
>
{(
[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const
).map((opt) => (
<OptionButton
key={String(opt.value)}
active={defaults.route_calculation === opt.value}
onClick={() => save({ route_calculation: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Blur Booking Codes */}
<OptionRow
label={
<>
{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" />
</>
}
>
{(
[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const
).map((opt) => (
<OptionButton
key={String(opt.value)}
active={defaults.blur_booking_codes === opt.value}
onClick={() => save({ blur_booking_codes: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Map Tile URL */}
<div>
<label className="mb-1.5 block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('settings.mapTemplate')}
<ResetButton field="map_tile_url" />
</label>
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => {
if (value) {
setMapTileUrl(value);
save({ map_tile_url: value });
}
}}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
onBlur={() => save({ map_tile_url: mapTileUrl })}
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-slate-400"
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.mapDefaultHint')}
</p>
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
places: mapPreviewPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
center: [48.8566, 2.3522],
zoom: 10,
tileUrl: mapTileUrl,
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})}
</div>
</div>
</Section>
);
}
@@ -1,202 +0,0 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import DevNotificationsPanel from './DevNotificationsPanel';
const ADMIN_USER = buildUser({ id: 1, username: 'testadmin', role: 'admin' });
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: ADMIN_USER, isAuthenticated: true });
});
afterEach(() => {
server.resetHandlers();
});
describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
// Wait for async data to populate conditional sections
await screen.findByText('Trip-Scoped Events');
await screen.findByText('User-Scoped Events');
expect(screen.getByText('Type Testing')).toBeInTheDocument();
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const options = Array.from(tripSelect.querySelectorAll('option'));
const labels = options.map((o) => o.textContent);
expect(labels).toContain('Paris Adventure');
expect(labels).toContain('Tokyo Trip');
});
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('User-Scoped Events');
const selects = screen.getAllByRole('combobox');
// Second combobox is the user selector (first is trip selector)
const userSelect = selects[1];
const options = Array.from(userSelect.querySelectorAll('option'));
const labels = options.map((o) => o.textContent ?? '');
expect(labels.some((l) => l.includes('admin'))).toBe(true);
expect(labels.some((l) => l.includes('alice'))).toBe(true);
});
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ok: true });
})
);
const user = userEvent.setup();
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
expect(capturedBody).toMatchObject({
event: 'test_simple',
scope: 'user',
targetId: ADMIN_USER.id,
});
});
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
server.use(http.post('/api/admin/dev/test-notification', () => HttpResponse.json({ ok: true })));
const user = userEvent.setup();
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Sent: simple-me');
});
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
server.use(
http.post('/api/admin/dev/test-notification', async () => {
await new Promise(() => {}); // never resolves — simulates in-flight
return HttpResponse.json({ ok: true });
})
);
const user = userEvent.setup();
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
// Fire the click but do not await — handler never resolves so sending stays true
void user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => expect(btn).toBeDisabled());
});
});
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 })
)
);
const user = userEvent.setup();
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText(/failed|error/i);
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ok: true });
})
);
const user = userEvent.setup();
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find((o) => o.textContent === 'Tokyo Trip')!;
const tokyoId = Number(tokyoOption.value);
await user.selectOptions(tripSelect, 'Tokyo Trip');
await user.click(screen.getByText('booking_change').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
expect(capturedBody!.targetId).toBe(tokyoId);
});
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
// Wait for user data to confirm async effects have settled
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
});
});
@@ -1,430 +0,0 @@
import {
Bell,
Calendar,
CheckCircle,
Clock,
Download,
Image,
MapPin,
MessageSquare,
Navigation,
Tag,
UserPlus,
Zap,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { adminApi, tripsApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { useToast } from '../shared/Toast';
interface Trip {
id: number;
title: string;
}
interface AppUser {
id: number;
username: string;
email: string;
}
export default function DevNotificationsPanel(): React.ReactElement {
const toast = useToast();
const user = useAuthStore((s) => s.user);
const [sending, setSending] = useState<string | null>(null);
const [trips, setTrips] = useState<Trip[]>([]);
const [selectedTripId, setSelectedTripId] = useState<number | null>(null);
const [users, setUsers] = useState<AppUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
useEffect(() => {
tripsApi
.list()
.then((data) => {
const list = (data.trips || data || []) as Trip[];
setTrips(list);
if (list.length > 0) setSelectedTripId(list[0].id);
})
.catch(() => {});
adminApi
.users()
.then((data) => {
const list = (data.users || data || []) as AppUser[];
setUsers(list);
if (list.length > 0) setSelectedUserId(list[0].id);
})
.catch(() => {});
}, []);
const fire = async (label: string, payload: Record<string, unknown>) => {
setSending(label);
try {
await adminApi.sendTestNotification(payload);
toast.success(`Sent: ${label}`);
} catch (err: any) {
toast.error(err.message || 'Failed');
} finally {
setSending(null);
}
};
const selectedTrip = trips.find((t) => t.id === selectedTripId);
const selectedUser = users.find((u) => u.id === selectedUserId);
const username = user?.username || 'Admin';
const tripTitle = selectedTrip?.title || 'Test Trip';
// ── Helpers ──────────────────────────────────────────────────────────────
const Btn = ({
id,
label,
sub,
icon: Icon,
color,
onClick,
}: {
id: string;
label: string;
sub: string;
icon: React.ElementType;
color: string;
onClick: () => void;
}) => (
<button
onClick={onClick}
disabled={sending !== null}
className="flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-card)';
}}
>
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg"
style={{ background: `${color}20`, color }}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{label}
</p>
<p className="truncate text-xs" style={{ color: 'var(--text-faint)' }}>
{sub}
</p>
</div>
{sending === id && (
<div className="h-4 w-4 flex-shrink-0 animate-spin rounded-full border-2 border-slate-200 border-t-indigo-500" />
)}
</button>
);
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="mb-3 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
{children}
</h3>
);
const TripSelector = () => (
<select
value={selectedTripId ?? ''}
onChange={(e) => setSelectedTripId(Number(e.target.value))}
className="mb-3 w-full rounded-lg border px-3 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map((trip) => (
<option key={trip.id} value={trip.id}>
{trip.title}
</option>
))}
</select>
);
const UserSelector = () => (
<select
value={selectedUserId ?? ''}
onChange={(e) => setSelectedUserId(Number(e.target.value))}
className="mb-3 w-full rounded-lg border px-3 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.email})
</option>
))}
</select>
);
return (
<div className="space-y-8">
<div className="flex items-center gap-2">
<div
className="rounded px-2 py-0.5 font-mono text-xs font-bold"
style={{ background: '#fbbf24', color: '#000' }}
>
DEV ONLY
</div>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Notification Testing
</span>
</div>
{/* ── Type Testing ─────────────────────────────────────────────────── */}
<div>
<SectionTitle>Type Testing</SectionTitle>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Test how each in-app notification type renders, sent to yourself.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id="simple-me"
label="Simple → Me"
sub="test_simple · user"
icon={Bell}
color="#6366f1"
onClick={() =>
fire('simple-me', {
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
})
}
/>
<Btn
id="boolean-me"
label="Boolean → Me"
sub="test_boolean · user"
icon={CheckCircle}
color="#10b981"
onClick={() =>
fire('boolean-me', {
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
})
}
/>
<Btn
id="navigate-me"
label="Navigate → Me"
sub="test_navigate · user"
icon={Navigation}
color="#f59e0b"
onClick={() =>
fire('navigate-me', {
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
})
}
/>
<Btn
id="simple-admins"
label="Simple → All Admins"
sub="test_simple · admin"
icon={Zap}
color="#ef4444"
onClick={() =>
fire('simple-admins', {
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
})
}
/>
</div>
</div>
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
{trips.length > 0 && (
<div>
<SectionTitle>Trip-Scoped Events</SectionTitle>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Fires each trip event to all members of the selected trip (excluding yourself).
</p>
<TripSelector />
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id="booking_change"
label="booking_change"
sub="navigate · trip"
icon={Calendar}
color="#6366f1"
onClick={() =>
selectedTripId &&
fire('booking_change', {
event: 'booking_change',
scope: 'trip',
targetId: selectedTripId,
params: {
actor: username,
trip: tripTitle,
booking: 'Test Hotel',
type: 'hotel',
tripId: String(selectedTripId),
},
})
}
/>
<Btn
id="trip_reminder"
label="trip_reminder"
sub="navigate · trip"
icon={Clock}
color="#10b981"
onClick={() =>
selectedTripId &&
fire('trip_reminder', {
event: 'trip_reminder',
scope: 'trip',
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
})
}
/>
<Btn
id="photos_shared"
label="photos_shared"
sub="navigate · trip"
icon={Image}
color="#f59e0b"
onClick={() =>
selectedTripId &&
fire('photos_shared', {
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
})
}
/>
<Btn
id="collab_message"
label="collab_message"
sub="navigate · trip"
icon={MessageSquare}
color="#8b5cf6"
onClick={() =>
selectedTripId &&
fire('collab_message', {
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: {
actor: username,
trip: tripTitle,
preview: 'This is a test message preview.',
tripId: String(selectedTripId),
},
})
}
/>
<Btn
id="packing_tagged"
label="packing_tagged"
sub="navigate · trip"
icon={Tag}
color="#ec4899"
onClick={() =>
selectedTripId &&
fire('packing_tagged', {
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
})
}
/>
</div>
</div>
)}
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
{users.length > 0 && (
<div>
<SectionTitle>User-Scoped Events</SectionTitle>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Fires each user event to the selected recipient.
</p>
<UserSelector />
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id={`trip_invite-${selectedUserId}`}
label="trip_invite"
sub="navigate · user"
icon={UserPlus}
color="#06b6d4"
onClick={() =>
selectedUserId &&
fire(`trip_invite-${selectedUserId}`, {
event: 'trip_invite',
scope: 'user',
targetId: selectedUserId,
params: {
actor: username,
trip: tripTitle,
invitee: selectedUser?.email || '',
tripId: String(selectedTripId ?? 0),
},
})
}
/>
<Btn
id={`vacay_invite-${selectedUserId}`}
label="vacay_invite"
sub="navigate · user"
icon={MapPin}
color="#f97316"
onClick={() =>
selectedUserId &&
fire(`vacay_invite-${selectedUserId}`, {
event: 'vacay_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, planId: '1' },
})
}
/>
</div>
</div>
)}
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
<div>
<SectionTitle>Admin-Scoped Events</SectionTitle>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Fires to all admin users.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id="version_available"
label="version_available"
sub="navigate · admin"
icon={Download}
color="#64748b"
onClick={() =>
fire('version_available', {
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
})
}
/>
</div>
</div>
</div>
);
}
@@ -1,308 +0,0 @@
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import GitHubPanel from './GitHubPanel';
function buildRelease(overrides = {}) {
const id = Math.random();
return {
id,
tag_name: 'v1.0.0',
name: 'Initial Release',
body: '## Changes\n- Fixed bug\n- **Bold improvement**\n- `code snippet`',
published_at: '2025-01-15T12:00:00Z',
created_at: '2025-01-15T12:00:00Z',
prerelease: false,
author: { login: 'mauriceboe' },
...overrides,
};
}
const PAGE_1 = Array.from({ length: 10 }, (_, i) => buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }));
const PAGE_2 = Array.from({ length: 5 }, (_, i) => buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }));
beforeEach(() => {
resetAllStores();
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([])));
});
afterEach(() => {
server.resetHandlers();
});
describe('GitHubPanel', () => {
it('FE-ADMIN-GH-001: support link cards always render', async () => {
render(<GitHubPanel />);
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('Report a Bug')).toBeInTheDocument();
expect(screen.getByText('Feature Request')).toBeInTheDocument();
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('FE-ADMIN-GH-002: all support links have correct href and target=_blank', async () => {
render(<GitHubPanel />);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const kofi = screen.getByText('Ko-fi').closest('a')!;
expect(kofi).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
expect(kofi).toHaveAttribute('target', '_blank');
expect(kofi).toHaveAttribute('rel', 'noopener noreferrer');
const bmc = screen.getByText('Buy Me a Coffee').closest('a')!;
expect(bmc).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe');
expect(bmc).toHaveAttribute('target', '_blank');
expect(bmc).toHaveAttribute('rel', 'noopener noreferrer');
const discord = screen.getByText('Discord').closest('a')!;
expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
expect(discord).toHaveAttribute('target', '_blank');
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-ADMIN-GH-003: loading spinner shown while fetching releases', () => {
server.use(
http.get('/api/admin/github-releases', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json([]);
})
);
render(<GitHubPanel />);
// The Loader2 spinner is rendered while loading=true
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
server.use(
http.get('/api/admin/github-releases', () =>
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 })
)
);
render(<GitHubPanel />);
await screen.findByText('Failed to load releases');
// Timeline should not be rendered
expect(screen.queryByText('Release History')).not.toBeInTheDocument();
});
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
render(<GitHubPanel />);
await screen.findByText('v1.0.0');
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
// Author label
const authorLabels = screen.getAllByText(/mauriceboe/);
expect(authorLabels.length).toBeGreaterThan(0);
// Some date should be visible (non-empty)
const dateEls = document.querySelectorAll('[class*="text-"]');
const dateTexts = Array.from(dateEls)
.map((el) => el.textContent)
.filter((t) => t && t.match(/\d{4}/));
expect(dateTexts.length).toBeGreaterThan(0);
});
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
render(<GitHubPanel />);
await screen.findByText('v2.0.0');
const latestBadges = screen.getAllByText('Latest');
expect(latestBadges).toHaveLength(1);
});
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
render(<GitHubPanel isPrerelease={true} />);
await screen.findByText('v3.0.0-beta.1');
expect(screen.getByText('Pre-release')).toBeInTheDocument();
});
it('FE-ADMIN-GH-008: expand/collapse release notes', async () => {
const r = buildRelease({
id: 20,
tag_name: 'v1.5.0',
body: '- Fixed bug\n- Another fix',
});
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.5.0');
const showBtn = screen.getByText('Show details');
expect(showBtn).toBeInTheDocument();
// Body not visible yet
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument();
// Expand
await user.click(showBtn);
await screen.findByText('Fixed bug');
expect(screen.getByText('Hide details')).toBeInTheDocument();
// Collapse
await user.click(screen.getByText('Hide details'));
await waitFor(() => expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument());
expect(screen.getByText('Show details')).toBeInTheDocument();
});
it('FE-ADMIN-GH-009: release body renders markdown: lists, bold, code', async () => {
const r = buildRelease({
id: 30,
tag_name: 'v1.6.0',
body: '- list item\n- **bold text**\n- `inline code`',
});
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.6.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('list item');
// list item is inside a <li>
const listItem = screen.getByText('list item');
expect(listItem.closest('li')).toBeInTheDocument();
// Bold text rendered as <strong>
const container = document.querySelector('.mt-2.p-3.rounded-lg')!;
expect(container.querySelector('strong')).toBeInTheDocument();
expect(container.querySelector('strong')!.textContent).toBe('bold text');
// Code rendered as <code>
expect(container.querySelector('code')).toBeInTheDocument();
expect(container.querySelector('code')!.textContent).toBe('inline code');
});
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)));
render(<GitHubPanel />);
await screen.findByText(`v1.0.0`);
expect(screen.getByText('Load more')).toBeInTheDocument();
});
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)));
render(<GitHubPanel />);
await screen.findByText('v0.0.0');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-GH-013: release body renders plain paragraph text', async () => {
const r = buildRelease({
id: 40,
tag_name: 'v1.7.0',
body: 'This is a plain paragraph without any markdown syntax.',
});
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.7.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('This is a plain paragraph without any markdown syntax.');
});
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
const r = buildRelease({
id: 41,
tag_name: 'v1.8.0',
body: '- [click here](https://example.com)',
});
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.8.0');
await user.click(screen.getByText('Show details'));
const link = await screen.findByText('click here');
expect(link.closest('a') || link.tagName.toLowerCase() === 'a' ? link : null).not.toBeNull();
});
it('FE-ADMIN-GH-015: javascript: link is sanitized to #', async () => {
const r = buildRelease({
id: 42,
tag_name: 'v1.9.0',
body: '- [evil](javascript:alert(1))',
});
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.9.0');
await user.click(screen.getByText('Show details'));
const link = await screen.findByText('evil');
const anchor = link.closest('a') ?? link;
// The unsafe href is replaced with '#'
expect(anchor).toHaveAttribute('href', '#');
});
it('FE-ADMIN-GH-016: support card hover effects fire without error', async () => {
render(<GitHubPanel />);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const kofiLink = screen.getByText('Ko-fi').closest('a')!;
fireEvent.mouseEnter(kofiLink);
fireEvent.mouseLeave(kofiLink);
const discordLink = screen.getByText('Discord').closest('a')!;
fireEvent.mouseEnter(discordLink);
fireEvent.mouseLeave(discordLink);
const bugLink = screen.getByText('Report a Bug').closest('a')!;
fireEvent.mouseEnter(bugLink);
fireEvent.mouseLeave(bugLink);
const featureLink = screen.getByText('Feature Request').closest('a')!;
fireEvent.mouseEnter(featureLink);
fireEvent.mouseLeave(featureLink);
const wikiLink = screen.getByText('Wiki').closest('a')!;
fireEvent.mouseEnter(wikiLink);
fireEvent.mouseLeave(wikiLink);
const bmcLink = screen.getByText('Buy Me a Coffee').closest('a')!;
fireEvent.mouseEnter(bmcLink);
fireEvent.mouseLeave(bmcLink);
// All links still visible
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
});
it('FE-ADMIN-GH-012: clicking "Load more" appends next page', async () => {
server.use(
http.get('/api/admin/github-releases', ({ request }) => {
const url = new URL(request.url);
const page = url.searchParams.get('page');
if (page === '2') {
return HttpResponse.json(PAGE_2);
}
return HttpResponse.json(PAGE_1);
})
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.0.0');
// All 10 items from page 1 visible
expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10);
// Click Load more
await user.click(screen.getByText('Load more'));
// Wait for page 2 items to appear
await screen.findByText('v0.0.0');
// Total: 10 from page 1 + 5 from page 2 = 15
const tagEls = screen.getAllByText(/^v[01]\.\d\.0$/);
expect(tagEls.length).toBe(15);
// Load more should be hidden (PAGE_2 < 10)
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
});
-570
View File
@@ -1,570 +0,0 @@
import {
BookOpen,
Bug,
Calendar,
ChevronDown,
ChevronUp,
Coffee,
ExternalLink,
Heart,
Lightbulb,
Loader2,
Tag,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import apiClient from '../../api/client';
import { getLocaleForLanguage, useTranslation } from '../../i18n';
const REPO = 'mauriceboe/TREK';
const PER_PAGE = 10;
interface GithubRelease {
id: number;
prerelease: boolean;
[key: string]: unknown;
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation();
const [releases, setReleases] = useState<GithubRelease[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const fetchReleases = async (pageNum = 1, append = false) => {
try {
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
const data = Array.isArray(res.data) ? res.data : [];
setReleases((prev) => (append ? [...prev, ...data] : data));
setHasMore(data.length === PER_PAGE);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
useEffect(() => {
setLoading(true);
fetchReleases(1).finally(() => setLoading(false));
}, []);
const handleLoadMore = async () => {
const next = page + 1;
setLoadingMore(true);
await fetchReleases(next, true);
setPage(next);
setLoadingMore(false);
};
const toggleExpand = (id) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
const formatDate = (dateStr) => {
const d = new Date(dateStr);
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
};
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
const renderBody = (body) => {
if (!body) return null;
const lines = body.split('\n');
const elements = [];
let listItems = [];
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
<span
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
style={{ background: 'var(--text-faint)' }}
/>
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
))}
</ul>
);
listItems = [];
}
};
const escapeHtml = (str) =>
str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const inlineFormat = (text) => {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(
/`(.+?)`/g,
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
});
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
flushList();
continue;
}
if (trimmed.startsWith('### ')) {
flushList();
elements.push(
<h4
key={elements.length}
className="mb-1 mt-3 text-xs font-semibold"
style={{ color: 'var(--text-primary)' }}
>
{trimmed.slice(4)}
</h4>
);
} else if (trimmed.startsWith('## ')) {
flushList();
elements.push(
<h3
key={elements.length}
className="mb-1 mt-3 text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
{trimmed.slice(3)}
</h3>
);
} else if (/^[-*] /.test(trimmed)) {
listItems.push(trimmed.slice(2));
} else {
flushList();
elements.push(
<p
key={elements.length}
className="my-1 text-xs"
style={{ color: 'var(--text-muted)' }}
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/>
);
}
}
flushList();
return elements;
};
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff5e5b';
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#ff5e5b15',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Coffee size={20} style={{ color: '#ff5e5b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Ko-fi
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.support')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ffdd00';
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#ffdd0015',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Heart size={20} style={{ color: '#ffdd00' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Buy Me a Coffee
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.support')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#5865F2';
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#5865F215',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Discord
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
Join the community
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ef4444';
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#ef444415',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Bug size={20} style={{ color: '#ef4444' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('settings.about.reportBug')}
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.about.reportBugHint')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#f59e0b';
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#f59e0b15',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('settings.about.featureRequest')}
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.about.featureRequestHint')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#6366f115',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<BookOpen size={20} style={{ color: '#6366f1' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Wiki
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.about.wikiHint')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
{/* Loading / Error / Releases */}
{loading ? (
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
</div>
</div>
) : error ? (
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="p-6 text-center">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
{t('admin.github.error')}
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--text-faint)' }}>
{error}
</p>
</div>
</div>
) : (
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div
className="flex items-center justify-between border-b px-5 py-4"
style={{ borderColor: 'var(--border-secondary)' }}
>
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.github.title')}
</h2>
<p className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.subtitle').replace('{repo}', REPO)}
</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div
className="absolute bottom-3 left-[11px] top-3 w-px"
style={{ background: 'var(--border-primary)' }}
/>
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0;
const isExpanded = expanded[release.id];
return (
<div key={release.id} className="relative pb-5 pl-8">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{release.tag_name}
</span>
{isLatest && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}
>
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}
>
{t('admin.github.prerelease')}
</span>
)}
</div>
{release.name && release.name !== release.tag_name && (
<p className="mt-0.5 text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
{release.name}
</p>
)}
<div className="mt-1 flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
style={{ color: 'var(--text-muted)' }}
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 rounded-lg p-3" style={{ background: 'var(--bg-secondary)' }}>
{renderBody(release.body)}
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="pt-2 text-center">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -1,460 +0,0 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import PackingTemplateManager from './PackingTemplateManager';
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' };
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' };
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 };
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 };
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 };
beforeEach(() => {
resetAllStores();
});
describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
server.use(
http.get('/api/admin/packing-templates', async () => {
await new Promise((r) => setTimeout(r, 100));
return HttpResponse.json({ templates: [] });
})
);
render(<PackingTemplateManager />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-002: shows empty state when no templates', async () => {
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
expect(screen.queryAllByRole('button', { name: /chevron/i })).toHaveLength(0);
});
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })));
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
// tmpl1 has 2 categories and 5 items
expect(screen.getByText(/2 categories · 5 items/i)).toBeInTheDocument();
});
it('FE-ADMIN-PKG-004: clicking "+" shows create input', async () => {
const user = userEvent.setup();
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
const createBtn = screen.getByRole('button', { name: /new template/i });
await user.click(createBtn);
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-005: creates template on Enter and shows success toast', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/admin/packing-templates', async () => {
postCalled = true;
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
})
);
render(
<>
<ToastContainer />
<PackingTemplateManager />
</>
);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
await user.type(input, 'New Template{Enter}');
await waitFor(() => expect(postCalled).toBe(true));
// "New Template" may appear both as the button label and the new list item
await waitFor(() => expect(screen.getAllByText('New Template').length).toBeGreaterThanOrEqual(1));
await screen.findByText('Template created');
});
it('FE-ADMIN-PKG-006: Escape dismisses create input without API call', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/admin/packing-templates', async () => {
postCalled = true;
return HttpResponse.json({ template: { id: 99, name: 'Should Not Appear' } });
})
);
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
await user.type(input, 'Test{Escape}');
await waitFor(() => {
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument();
});
expect(postCalled).toBe(false);
});
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
expect(screen.getByText('T-shirt')).toBeInTheDocument();
expect(screen.getByText('Shorts')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
// Collapse by clicking again
await user.click(screen.getByText('Beach Trip'));
await waitFor(() => {
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-PKG-009: deleting a template removes it from the list and shows toast', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })),
http.delete('/api/admin/packing-templates/1', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<PackingTemplateManager />
</>
);
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
// Find all Trash2 (delete) buttons — there are 2 (one per template)
const deleteButtons = screen
.getAllByRole('button')
.filter((b) => b.className.includes('hover:bg-red-50') || b.querySelector('svg'));
// Click the delete button for "Beach Trip" (first template row's trash button)
// The buttons layout in each row: [chevron, edit, delete]
// We find rows first
const beachTripRow = screen.getByText('Beach Trip').closest('div');
const trashBtn = beachTripRow!.parentElement!.querySelector('button.hover\\:bg-red-50') as HTMLElement | null;
if (trashBtn) {
await user.click(trashBtn);
} else {
// Fallback: find all red-hover buttons and click first
const allBtns = screen.getAllByRole('button');
const redBtns = allBtns.filter((b) => b.className.includes('hover:bg-red-50'));
await user.click(redBtns[0]);
}
await waitFor(() => expect(deleteCalled).toBe(true));
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
expect(screen.getByText('City Break')).toBeInTheDocument();
await screen.findByText('Template deleted');
});
it('FE-ADMIN-PKG-010: renaming a template inline updates the list', async () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
// Find the Edit2 button on the template row
const beachTripText = screen.getByText('Beach Trip');
const row = beachTripText.closest('div')!.parentElement!;
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
if (editBtn) {
await user.click(editBtn);
} else {
// Fallback: find all slate-100-hover buttons
const allBtns = screen.getAllByRole('button');
const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Summer Packing{Enter}');
await waitFor(() => expect(putCalled).toBe(true));
await screen.findByText('Summer Packing');
});
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', async () =>
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
// Wait for expanded state (Add category button should appear)
await screen.findByText('Add category');
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Electronics{Enter}');
await screen.findByText('Electronics');
});
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
// Click the "+" button on the Clothing category row
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
await user.click(addItemBtn);
const itemInput = screen.getByPlaceholderText('Item name');
await user.type(itemInput, 'Sandals');
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
await user.type(itemInput, '{Enter}');
await screen.findByText('Sandals');
});
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.put('/api/admin/packing-templates/1/categories/10', async () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
// Find the Edit2 button in the Clothing category header
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter((b) =>
b.className.includes('hover:text-slate-700')
);
// Second button (after Plus) is Edit2
await user.click(editBtns[1]);
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
await screen.findByText('Shoes');
});
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
expect(screen.getByText('T-shirt')).toBeInTheDocument();
// Find the Trash2 button in the Clothing category header
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const trashBtn = clothingHeader.querySelector('button.hover\\:text-red-500') as HTMLElement;
await user.click(trashBtn);
await waitFor(() => {
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.put('/api/admin/packing-templates/1/items/100', async () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('T-shirt');
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
const itemRow = screen.getByText('T-shirt').closest('div')!;
const editBtn = Array.from(itemRow.querySelectorAll('button')).find((b) => b.className.includes('opacity-0')) as
| HTMLElement
| undefined;
if (editBtn) {
await user.click(editBtn);
} else {
// Directly click the first button in the item row
const btns = itemRow.querySelectorAll('button');
await user.click(btns[0] as HTMLElement);
}
const input = screen.getByDisplayValue('T-shirt');
await user.clear(input);
await user.type(input, 'Tank Top{Enter}');
await screen.findByText('Tank Top');
});
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('T-shirt');
expect(screen.getByText('Shorts')).toBeInTheDocument();
// Find the Trash2 button in the T-shirt row
const itemRow = screen.getByText('T-shirt').closest('div')!;
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter((b) => b.className.includes('opacity-0'));
// Second opacity-0 button is the delete (trash) button
const trashBtn = trashBtns[1] || trashBtns[0];
await user.click(trashBtn as HTMLElement);
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
expect(screen.getByText('Shorts')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-017: Escape cancels add category without saving', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', async () => {
postCalled = true;
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Add category');
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Test{Escape}');
await waitFor(() => expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument());
expect(postCalled).toBe(false);
});
it('FE-ADMIN-PKG-018: Escape cancels add item without saving', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
postCalled = true;
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
await user.click(addItemBtn);
const itemInput = screen.getByPlaceholderText('Item name');
await user.type(itemInput, 'Test{Escape}');
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
expect(postCalled).toBe(false);
});
it('FE-ADMIN-PKG-019: Escape cancels template rename without saving', async () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
const beachTripText = screen.getByText('Beach Trip');
const row = beachTripText.closest('div')!.parentElement!;
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
if (editBtn) {
await user.click(editBtn);
} else {
const allBtns = screen.getAllByRole('button');
const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
const input = screen.getByDisplayValue('Beach Trip');
await user.type(input, '{Escape}');
await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument());
expect(putCalled).toBe(false);
// Original name should be restored
expect(screen.getByText('Beach Trip')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-020: X button on create template input dismisses it', async () => {
const user = userEvent.setup();
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
// Find the X (cancel) button in the create row — it's the last button in the create row
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
await user.click(cancelBtn);
await waitFor(() =>
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
);
});
});
@@ -1,504 +0,0 @@
import { Check, ChevronDown, ChevronRight, Edit2, FolderPlus, Package, Plus, Trash2, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useToast } from '../shared/Toast';
interface TemplateCategory {
id: number;
template_id: number;
name: string;
sort_order: number;
}
interface TemplateItem {
id: number;
category_id: number;
name: string;
sort_order: number;
}
interface Template {
id: number;
name: string;
item_count: number;
category_count: number;
created_by_name: string;
}
export default function PackingTemplateManager() {
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState('');
// Expanded template state
const [expandedId, setExpandedId] = useState<number | null>(null);
const [categories, setCategories] = useState<TemplateCategory[]>([]);
const [items, setItems] = useState<TemplateItem[]>([]);
// Editing states
const [editingTemplate, setEditingTemplate] = useState<number | null>(null);
const [editTemplateName, setEditTemplateName] = useState('');
const [editingCatId, setEditingCatId] = useState<number | null>(null);
const [editCatName, setEditCatName] = useState('');
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [editItemName, setEditItemName] = useState('');
// Adding states
const [addingCategory, setAddingCategory] = useState(false);
const [newCatName, setNewCatName] = useState('');
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null);
const [newItemName, setNewItemName] = useState('');
const addItemRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t } = useTranslation();
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
setIsLoading(true);
try {
const data = await adminApi.packingTemplates();
setTemplates(data.templates || []);
} catch {
toast.error(t('admin.packingTemplates.loadError'));
} finally {
setIsLoading(false);
}
};
const toggleExpand = async (id: number) => {
if (expandedId === id) {
setExpandedId(null);
return;
}
setExpandedId(id);
setAddingCategory(false);
setAddingItemToCatId(null);
try {
const data = await adminApi.getPackingTemplate(id);
setCategories(data.categories || []);
setItems(data.items || []);
} catch {
toast.error(t('admin.packingTemplates.loadError'));
}
};
// Template CRUD
const handleCreateTemplate = async () => {
if (!createName.trim()) return;
try {
const data = await adminApi.createPackingTemplate({ name: createName.trim() });
setTemplates((prev) => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev]);
setCreateName('');
setShowCreate(false);
setExpandedId(data.template.id);
setCategories([]);
setItems([]);
toast.success(t('admin.packingTemplates.created'));
} catch {
toast.error(t('admin.packingTemplates.createError'));
}
};
const handleDeleteTemplate = async (id: number) => {
try {
await adminApi.deletePackingTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
if (expandedId === id) setExpandedId(null);
toast.success(t('admin.packingTemplates.deleted'));
} catch {
toast.error(t('admin.packingTemplates.deleteError'));
}
};
const handleRenameTemplate = async (id: number) => {
if (!editTemplateName.trim()) {
setEditingTemplate(null);
return;
}
try {
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() });
setTemplates((prev) => prev.map((t) => (t.id === id ? { ...t, name: editTemplateName.trim() } : t)));
setEditingTemplate(null);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
// Category CRUD
const handleAddCategory = async () => {
if (!newCatName.trim() || !expandedId) return;
try {
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() });
setCategories((prev) => [...prev, data.category]);
setNewCatName('');
setAddingCategory(false);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleRenameCategory = async (catId: number) => {
if (!editCatName.trim() || !expandedId) {
setEditingCatId(null);
return;
}
try {
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() });
setCategories((prev) => prev.map((c) => (c.id === catId ? { ...c, name: editCatName.trim() } : c)));
setEditingCatId(null);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleDeleteCategory = async (catId: number) => {
if (!expandedId) return;
try {
await adminApi.deleteTemplateCategory(expandedId, catId);
setCategories((prev) => prev.filter((c) => c.id !== catId));
setItems((prev) => prev.filter((i) => i.category_id !== catId));
} catch {
toast.error(t('admin.packingTemplates.deleteError'));
}
};
// Item CRUD
const handleAddItem = async (catId: number) => {
if (!newItemName.trim() || !expandedId) return;
try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() });
setItems((prev) => [...prev, data.item]);
setNewItemName('');
setTimeout(() => addItemRef.current?.focus(), 30);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleRenameItem = async (itemId: number) => {
if (!editItemName.trim() || !expandedId) {
setEditingItemId(null);
return;
}
try {
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() });
setItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, name: editItemName.trim() } : i)));
setEditingItemId(null);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleDeleteItem = async (itemId: number) => {
if (!expandedId) return;
try {
await adminApi.deleteTemplateItem(expandedId, itemId);
setItems((prev) => prev.filter((i) => i.id !== itemId));
} catch {
toast.error(t('admin.packingTemplates.deleteError'));
}
};
const inputStyle =
'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none';
const btnIcon = 'p-1.5 rounded-lg transition-colors';
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-100 p-5">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="mt-1 text-xs text-slate-400">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm text-white transition-colors hover:bg-slate-700"
>
<Plus className="h-4 w-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
</button>
</div>
{/* Create template */}
{showCreate && (
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<Package size={16} className="flex-shrink-0 text-slate-400" />
<input
autoFocus
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateTemplate();
if (e.key === 'Escape') setShowCreate(false);
}}
placeholder={t('admin.packingTemplates.namePlaceholder')}
className={inputStyle}
/>
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}>
<Check size={16} />
</button>
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}>
<X size={16} />
</button>
</div>
)}
{/* Template list */}
{isLoading ? (
<div className="p-8 text-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900" />
</div>
) : templates.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
) : (
<div className="divide-y divide-slate-100">
{templates.map((tmpl) => (
<div key={tmpl.id}>
{/* Template row */}
<div className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-slate-50">
<button
onClick={() => toggleExpand(tmpl.id)}
className="flex-shrink-0 cursor-pointer border-none bg-transparent p-0 text-slate-400"
>
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<Package size={16} className="flex-shrink-0 text-slate-400" />
{editingTemplate === tmpl.id ? (
<input
autoFocus
value={editTemplateName}
onChange={(e) => setEditTemplateName(e.target.value)}
onBlur={() => handleRenameTemplate(tmpl.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameTemplate(tmpl.id);
if (e.key === 'Escape') setEditingTemplate(null);
}}
className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm"
/>
) : (
<span
onClick={() => toggleExpand(tmpl.id)}
className="flex-1 cursor-pointer text-sm font-medium text-slate-700"
>
{tmpl.name}
</span>
)}
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-400">
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count}{' '}
{t('admin.packingTemplates.items')}
</span>
<button
onClick={() => {
setEditingTemplate(tmpl.id);
setEditTemplateName(tmpl.name);
}}
className={`${btnIcon} text-slate-400 hover:bg-slate-100 hover:text-slate-700`}
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDeleteTemplate(tmpl.id)}
className={`${btnIcon} text-slate-400 hover:bg-red-50 hover:text-red-500`}
>
<Trash2 size={14} />
</button>
</div>
{/* Expanded content */}
{expandedId === tmpl.id && (
<div className="ml-8 space-y-3 px-5 pb-4">
{categories.map((cat) => {
const catItems = items.filter((i) => i.category_id === cat.id);
return (
<div key={cat.id} className="overflow-hidden rounded-lg border border-slate-200">
{/* Category header */}
<div className="flex items-center gap-2 bg-slate-50 px-4 py-2.5">
{editingCatId === cat.id ? (
<>
<input
autoFocus
value={editCatName}
onChange={(e) => setEditCatName(e.target.value)}
onBlur={() => handleRenameCategory(cat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameCategory(cat.id);
if (e.key === 'Escape') setEditingCatId(null);
}}
className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm font-semibold"
/>
</>
) : (
<span className="flex-1 text-xs font-bold uppercase tracking-wider text-slate-500">
{cat.name}
</span>
)}
<span className="text-xs text-slate-400">{catItems.length}</span>
<button
onClick={() => {
setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id);
setNewItemName('');
setTimeout(() => addItemRef.current?.focus(), 30);
}}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}
>
<Plus size={13} />
</button>
<button
onClick={() => {
setEditingCatId(cat.id);
setEditCatName(cat.name);
}}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}
>
<Edit2 size={13} />
</button>
<button
onClick={() => handleDeleteCategory(cat.id)}
className={`${btnIcon} text-slate-400 hover:text-red-500`}
>
<Trash2 size={13} />
</button>
</div>
{/* Items */}
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
<div className="divide-y divide-slate-50">
{catItems.map((item) => (
<div key={item.id} className="group flex items-center gap-3 px-4 py-2">
{editingItemId === item.id ? (
<>
<input
autoFocus
value={editItemName}
onChange={(e) => setEditItemName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameItem(item.id);
if (e.key === 'Escape') setEditingItemId(null);
}}
className="flex-1 rounded-lg border border-slate-200 px-2 py-1 text-sm"
/>
<button
onClick={() => handleRenameItem(item.id)}
className="p-1 text-slate-600 hover:text-slate-900"
>
<Check size={13} />
</button>
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400">
<X size={13} />
</button>
</>
) : (
<>
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
<button
onClick={() => {
setEditingItemId(item.id);
setEditItemName(item.name);
}}
className="rounded p-1 text-slate-400 opacity-0 transition-all hover:text-slate-700 group-hover:opacity-100"
>
<Edit2 size={12} />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="rounded p-1 text-slate-400 opacity-0 transition-all hover:text-red-500 group-hover:opacity-100"
>
<Trash2 size={12} />
</button>
</>
)}
</div>
))}
{/* Add item inline */}
{addingItemToCatId === cat.id && (
<div className="flex items-center gap-2 px-4 py-2">
<input
ref={addItemRef}
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id);
if (e.key === 'Escape') {
setAddingItemToCatId(null);
setNewItemName('');
}
}}
placeholder={t('admin.packingTemplates.itemName')}
className="flex-1 rounded-lg border border-slate-200 px-2 py-1 text-sm"
/>
<button
onClick={() => handleAddItem(cat.id)}
disabled={!newItemName.trim()}
className="rounded-lg bg-slate-900 p-1.5 text-white transition-colors hover:bg-slate-700 disabled:bg-slate-300"
>
<Plus size={13} />
</button>
<button
onClick={() => {
setAddingItemToCatId(null);
setNewItemName('');
}}
className="p-1 text-slate-400 hover:text-slate-600"
>
<X size={13} />
</button>
</div>
)}
</div>
)}
</div>
);
})}
{/* Add category button */}
{addingCategory ? (
<div className="flex items-center gap-2">
<input
autoFocus
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddCategory();
if (e.key === 'Escape') {
setAddingCategory(false);
setNewCatName('');
}
}}
placeholder={t('admin.packingTemplates.categoryName')}
className="flex-1 rounded-lg border border-slate-200 px-3 py-2 text-sm"
/>
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}>
<Check size={15} />
</button>
<button
onClick={() => {
setAddingCategory(false);
setNewCatName('');
}}
className={`${btnIcon} text-slate-400`}
>
<X size={15} />
</button>
</div>
) : (
<button
onClick={() => setAddingCategory(true)}
className="flex w-full items-center gap-2 rounded-lg border border-dashed border-slate-200 px-3 py-2.5 text-sm text-slate-400 transition-colors hover:border-slate-400 hover:text-slate-600"
>
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -1,251 +0,0 @@
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import PermissionsPanel from './PermissionsPanel';
// ── Fixture ───────────────────────────────────────────────────────────────────
const ALLOWED = ['admin', 'trip_owner', 'trip_member', 'everybody'] as const;
function buildPermission(key: string, level = 'trip_member', defaultLevel = 'trip_member') {
return { key, level, defaultLevel, allowedLevels: [...ALLOWED] };
}
const SAMPLE_PERMISSIONS = [
buildPermission('trip_create'),
buildPermission('trip_edit'),
buildPermission('trip_delete'),
buildPermission('trip_archive'),
buildPermission('trip_cover_upload'),
buildPermission('member_manage'),
buildPermission('file_upload'),
buildPermission('file_edit'),
buildPermission('file_delete'),
buildPermission('place_edit'),
buildPermission('day_edit'),
buildPermission('reservation_edit'),
buildPermission('budget_edit'),
buildPermission('packing_edit'),
buildPermission('collab_edit'),
buildPermission('share_manage'),
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderPanel() {
return render(
<>
<ToastContainer />
<PermissionsPanel />
</>
);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
resetAllStores();
// Override the default handler (returns object) with correct array shape
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
});
afterEach(() => {
server.resetHandlers();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('PermissionsPanel', () => {
it('FE-ADMIN-PERM-001: loading spinner renders before data arrives', () => {
server.use(
http.get('/api/admin/permissions', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ permissions: [] });
})
);
renderPanel();
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
// The form content (category headings) should not be present
expect(screen.queryByText('Trip Management')).not.toBeInTheDocument();
});
it('FE-ADMIN-PERM-002: permission categories and actions render after load', async () => {
renderPanel();
// Wait until loading is done — a category heading appears
await screen.findByText('Trip Management');
expect(screen.getByText('Member Management')).toBeInTheDocument();
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Content & Schedule')).toBeInTheDocument();
expect(screen.getByText('Budget, Packing & Collaboration')).toBeInTheDocument();
expect(screen.getByText('Create trips')).toBeInTheDocument();
expect(screen.getByText('Add / remove members')).toBeInTheDocument();
});
it('FE-ADMIN-PERM-003: "customized" badge visible when value differs from default', async () => {
const perms = [
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
];
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
renderPanel();
await screen.findByText('Trip Management');
// Badge should appear once (for trip_create)
expect(screen.getByText('customized')).toBeInTheDocument();
expect(screen.getAllByText('customized')).toHaveLength(1);
});
it('FE-ADMIN-PERM-004: Save button is disabled until a value changes', async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
const saveButton = screen.getByRole('button', { name: /^Save$/i });
expect(saveButton).toBeDisabled();
// Open the first CustomSelect trigger (shows current level "Trip members")
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
// Pick an option different from the current one (current is trip_member → pick admin)
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
});
it('FE-ADMIN-PERM-005: changing a value marks form dirty and enables Save', async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
const saveButton = screen.getByRole('button', { name: /^Save$/i });
expect(saveButton).toBeDisabled();
// Open first CustomSelect dropdown and select a different option
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
});
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
const perms = [
buildPermission('trip_create', 'admin', 'trip_member'), // customized
...SAMPLE_PERMISSIONS.filter((p) => p.key !== 'trip_create'),
];
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Customized badge should be visible
expect(screen.getByText('customized')).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: /^Save$/i });
const resetButton = screen.getByRole('button', { name: /Reset to defaults/i });
await user.click(resetButton);
// Badge should disappear (value back to defaultLevel)
await waitFor(() => {
expect(screen.queryByText('customized')).not.toBeInTheDocument();
});
// Save should be enabled (handleReset sets dirty=true)
expect(saveButton).not.toBeDisabled();
});
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Dirty the form
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
const saveButton = screen.getByRole('button', { name: /^Save$/i });
await waitFor(() => expect(saveButton).not.toBeDisabled());
await user.click(saveButton);
await screen.findByText('Permission settings saved');
// After successful save, dirty is cleared → Save disabled again
await waitFor(() => expect(saveButton).toBeDisabled());
});
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Dirty the form
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
const saveButton = screen.getByRole('button', { name: /^Save$/i });
await waitFor(() => expect(saveButton).not.toBeDisabled());
await user.click(saveButton);
await screen.findByText('Error');
// Dirty unchanged → Save stays enabled
expect(saveButton).not.toBeDisabled();
});
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
let resolvePut!: () => void;
server.use(
http.put(
'/api/admin/permissions',
() =>
new Promise<Response>((resolve) => {
resolvePut = () => resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
})
)
);
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Dirty the form
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
const saveButton = screen.getByRole('button', { name: /^Save$/i });
await waitFor(() => expect(saveButton).not.toBeDisabled());
await user.click(saveButton);
// In-flight: button should be disabled and show Loader2 spinner
await waitFor(() => expect(saveButton).toBeDisabled());
const loader = saveButton.querySelector('.animate-spin');
expect(loader).toBeInTheDocument();
// Resolve the request
resolvePut();
await screen.findByText('Permission settings saved');
});
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
renderPanel();
await screen.findByText('Error');
});
});
@@ -1,172 +0,0 @@
import { Loader2, RotateCcw, Save } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { PermissionLevel, usePermissionsStore } from '../../store/permissionsStore';
import CustomSelect from '../shared/CustomSelect';
import { useToast } from '../shared/Toast';
interface PermissionEntry {
key: string;
level: PermissionLevel;
defaultLevel: PermissionLevel;
allowedLevels: PermissionLevel[];
}
const LEVEL_LABELS: Record<string, string> = {
admin: 'perm.level.admin',
trip_owner: 'perm.level.tripOwner',
trip_member: 'perm.level.tripMember',
everybody: 'perm.level.everybody',
};
const CATEGORIES = [
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
];
export default function PermissionsPanel(): React.ReactElement {
const { t } = useTranslation();
const toast = useToast();
const [entries, setEntries] = useState<PermissionEntry[]>([]);
const [values, setValues] = useState<Record<string, PermissionLevel>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
useEffect(() => {
loadPermissions();
}, []);
const loadPermissions = async () => {
setLoading(true);
try {
const data = await adminApi.getPermissions();
setEntries(data.permissions);
const vals: Record<string, PermissionLevel> = {};
for (const p of data.permissions) vals[p.key] = p.level;
setValues(vals);
setDirty(false);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
};
const handleChange = (key: string, level: PermissionLevel) => {
setValues((prev) => ({ ...prev, [key]: level }));
setDirty(true);
};
const handleSave = async () => {
setSaving(true);
try {
const data = await adminApi.updatePermissions(values);
if (data.permissions) {
usePermissionsStore.getState().setPermissions(data.permissions);
}
setDirty(false);
toast.success(t('perm.saved'));
} catch {
toast.error(t('common.error'));
} finally {
setSaving(false);
}
};
const handleReset = () => {
const defaults: Record<string, PermissionLevel> = {};
for (const p of entries) defaults[p.key] = p.defaultLevel;
setValues(defaults);
setDirty(true);
};
const entryMap = useMemo(() => new Map(entries.map((e) => [e.key, e])), [entries]);
if (loading) {
return (
<div className="p-8 text-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900" />
</div>
);
}
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
<p className="mt-0.5 text-xs text-slate-400">{t('perm.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleReset}
disabled={saving}
title={t('perm.resetDefaults')}
aria-label={t('perm.resetDefaults')}
className="flex w-8 items-center justify-center gap-1.5 rounded-lg border border-slate-300 px-0 py-1.5 text-sm transition-colors hover:bg-slate-50 disabled:opacity-40 sm:w-auto sm:px-3"
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
</button>
<button
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm text-white transition-colors hover:bg-slate-700 disabled:bg-slate-400"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{t('common.save')}
</button>
</div>
</div>
<div className="divide-y divide-slate-100">
{CATEGORIES.map((cat) => (
<div key={cat.id} className="px-6 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
{t(`perm.cat.${cat.id}`)}
</h3>
<div className="space-y-3">
{cat.keys.map((key) => {
const entry = entryMap.get(key);
if (!entry) return null;
const currentLevel = values[key] || entry.defaultLevel;
const isDefault = currentLevel === entry.defaultLevel;
return (
<div key={key} className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
<p className="mt-0.5 text-xs text-slate-400">{t(`perm.actionHint.${key}`)}</p>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
{t('perm.customized')}
</span>
)}
<CustomSelect
value={currentLevel}
onChange={(val) => handleChange(key, val as PermissionLevel)}
options={entry.allowedLevels.map((l) => ({
value: l,
label: t(LEVEL_LABELS[l] || l),
}))}
style={{ minWidth: 160 }}
/>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
);
}
@@ -1,464 +0,0 @@
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildBudgetItem, buildSettings, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import BudgetPanel from './BudgetPanel';
beforeEach(() => {
resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel
server.use(
http.get('/api/trips/:id/budget/settlement', () => HttpResponse.json({ balances: [], flows: [] })),
http.get('/api/trips/:id/budget/per-person', () => HttpResponse.json({ summary: [] }))
);
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
});
describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText(/Create categories and entries/i);
});
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
});
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel Paris');
});
it('FE-COMP-BUDGET-005: renders category section header', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
});
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Name');
await screen.findByText('Total');
});
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Budget');
});
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
});
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
});
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
const user = userEvent.setup();
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
return HttpResponse.json({ item });
})
);
render(<BudgetPanel tripId={1} />);
const nameInput = await screen.findByPlaceholderText('New Entry');
await user.type(nameInput, 'Restaurant Dinner');
const addBtn = screen.getByTitle('Add Reservation');
await user.click(addBtn);
await screen.findByText('Restaurant Dinner');
});
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Test Item');
// Delete button has title="Delete"
expect(screen.getByTitle('Delete')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-012: delete item removes it from the UI', async () => {
const user = userEvent.setup();
const item = buildBudgetItem({ id: 42, trip_id: 1, category: 'Food', name: 'Item To Delete' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.delete('/api/trips/1/budget/42', () => HttpResponse.json({ success: true }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Item To Delete');
await user.click(screen.getByTitle('Delete'));
await waitFor(() => {
expect(screen.queryByText('Item To Delete')).not.toBeInTheDocument();
});
});
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel A');
await screen.findByText('Hotel B');
});
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
await screen.findByText('Hotels');
});
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
// Component renders even in empty state
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Misc');
// Row exists - EUR formatting would appear in values
});
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('ToDelete');
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
// The add button is present
expect(screen.getByTitle('Add Reservation')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-019: add item with Enter key submits the form', async () => {
const user = userEvent.setup();
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
return HttpResponse.json({ item });
})
);
render(<BudgetPanel tripId={1} />);
const nameInput = await screen.findByPlaceholderText('New Entry');
await user.type(nameInput, 'Pizza{Enter}');
await screen.findByText('Pizza');
});
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} tripMembers={[]} />);
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Old Name');
await user.click(screen.getByText('Old Name'));
expect(screen.getByDisplayValue('Old Name')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByDisplayValue('Old Name')).not.toBeInTheDocument();
});
it('FE-COMP-BUDGET-022: inline edit name cell — saving new name calls PUT API', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 10, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
let putCalled = false;
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.put('/api/trips/1/budget/10', async ({ request }) => {
const b = (await request.json()) as Record<string, unknown>;
putCalled = true;
return HttpResponse.json({ item: { ...item, name: b.name } });
})
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Old Name');
await user.click(screen.getByText('Old Name'));
const input = screen.getByDisplayValue('Old Name');
await user.clear(input);
await user.type(input, 'New Name');
await user.tab();
await waitFor(() => expect(putCalled).toBe(true));
});
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
const item = {
...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }),
total_price: 45.5,
};
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Dinner');
// The formatted number appears in the InlineEditCell for total price (and grand total card)
expect(screen.getAllByText('45.50').length).toBeGreaterThan(0);
// The currency symbol appears (in category subtotal or grand total card)
expect(screen.getAllByText(/€/).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
const user = userEvent.setup();
const item = {
...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }),
total_price: 200,
};
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
);
render(<BudgetPanel tripId={1} />);
await screen.findAllByText('Flights');
await screen.findByText('Flight to Paris');
await user.click(screen.getByTitle('Delete Category'));
await waitFor(() => {
expect(screen.queryAllByText('Flights').length).toBe(0);
expect(screen.queryByText('Flight to Paris')).not.toBeInTheDocument();
});
});
it('FE-COMP-BUDGET-025: CSV export button triggers download via URL.createObjectURL', async () => {
const createObjectURL = vi.fn(() => 'blob:test');
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
await user.click(screen.getByText('CSV'));
expect(createObjectURL).toHaveBeenCalled();
vi.restoreAllMocks();
});
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Lunch');
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
expect(screen.getAllByText('50.00 €').length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
});
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.post('/api/trips/1/budget', () =>
HttpResponse.json({
item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 },
})
)
);
render(<BudgetPanel tripId={1} />);
const input = await screen.findByPlaceholderText('Enter category name...');
await user.type(input, 'Souvenirs{Enter}');
await screen.findByText('Souvenirs');
});
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 100 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [
{ user_id: 1, username: 'alice', balance: -10, avatar_url: null },
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
],
flows: [
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
],
})
)
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Dinner');
// Click the settlement toggle button (role button with name containing "settlement")
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
await user.click(settlementBtn);
// alice and bob should appear in balances section
await screen.findByText('alice');
await screen.findByText('bob');
});
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
total_price: 75,
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
};
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/summary/per-person', () =>
HttpResponse.json({ summary: [{ user_id: 1, username: 'testuser', avatar_url: null, total_assigned: 75 }] })
)
);
const tripMembers = [
{ id: 1, username: 'testuser', avatar_url: null },
{ id: 2, username: 'other', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Shared Dinner');
await screen.findByText('testuser');
});
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Flight');
await screen.findByText('Hotel');
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
});
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Read Only Item');
// In read-only mode the Delete button should not be visible
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
});
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }),
total_price: 30,
expense_date: '2025-06-15',
};
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
await screen.findByText('2025-06-15');
});
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
flows: [
{
from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
to: { username: 'bob', avatar_url: null },
amount: 30,
},
],
})
),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] }))
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
{ id: 2, username: 'bob', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Lunch');
// Trigger settlement display
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
await user.click(settlementBtn);
await screen.findByText('alice');
// Avatar image should be rendered for alice
const avatarImg = screen.getAllByRole('img');
expect(avatarImg.length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }),
total_price: 5,
expense_date: null,
};
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Snack');
// When expense_date is null, the fallback '—' is shown
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThan(0);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,924 +0,0 @@
// FE-COMP-CHAT-001 to FE-COMP-CHAT-012
// jsdom doesn't implement scrollTo — mock it to prevent uncaught exceptions from CollabChat's scrollToBottom
beforeAll(() => {
Element.prototype.scrollTo = vi.fn() as any;
});
// CollabChat uses addListener/removeListener from websocket — extend the global mock
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { addListener } from '../../api/websocket';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import CollabChat from './CollabChat';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const defaultProps = {
tripId: 1,
currentUser,
};
beforeEach(() => {
resetAllStores();
server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json({ messages: [], total: 0 })));
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('CollabChat', () => {
it('FE-COMP-CHAT-001: renders without crashing', () => {
render(<CollabChat {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
});
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
render(<CollabChat {...defaultProps} />);
// Wait for loading to complete
await screen.findByText('Start the conversation');
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
it('FE-COMP-CHAT-004: shows send button (ArrowUp icon, no title)', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Send button has no title attr — verify buttons exist in the toolbar area
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-005: shows existing messages from API', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: currentUser.id,
username: 'testuser',
avatar_url: null,
text: 'Hello world!',
created_at: '2025-06-01T10:00:00.000Z',
reactions: {},
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
});
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.type(input, 'Test message');
expect((input as HTMLTextAreaElement).value).toBe('Test message');
});
it('FE-COMP-CHAT-007: submitting message via Enter calls POST API', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/trips/1/collab/messages', async () => {
postCalled = true;
return HttpResponse.json({
id: 2,
trip_id: 1,
user_id: 1,
username: 'testuser',
avatar_url: null,
text: 'New message',
created_at: new Date().toISOString(),
reactions: {},
reply_to: null,
deleted: false,
edited: false,
});
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
// Enter key sends message (Shift+Enter = newline, Enter = send)
await user.type(input, 'New message{Enter}');
await waitFor(() => expect(postCalled).toBe(true));
});
it('FE-COMP-CHAT-008: message input area is present after loading', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Share ideas, plans/i);
});
it('FE-COMP-CHAT-010: chat container renders', () => {
render(<CollabChat {...defaultProps} />);
expect(document.body.children.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-011: multiple messages all render', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 1,
username: 'testuser',
avatar_url: null,
text: 'First message',
created_at: '2025-06-01T10:00:00.000Z',
reactions: {},
reply_to: null,
deleted: false,
edited: false,
},
{
id: 2,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Second message',
created_at: '2025-06-01T10:01:00.000Z',
reactions: {},
reply_to: null,
deleted: false,
edited: false,
},
],
total: 2,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('First message');
expect(screen.getByText('Second message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-012: shows emoji button in the toolbar', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Emoji button is a button in the toolbar
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-013: date separator shows "Today" for messages sent today', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Hello world!',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
expect(screen.getByText('Today')).toBeInTheDocument();
});
it('FE-COMP-CHAT-014: Shift+Enter inserts a newline instead of sending', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/trips/1/collab/messages', async () => {
postCalled = true;
return HttpResponse.json({});
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.click(input);
await user.type(input, 'Line1');
await user.keyboard('{Shift>}{Enter}{/Shift}');
await user.type(input, 'Line2');
expect((input as HTMLTextAreaElement).value).toContain('\n');
expect(postCalled).toBe(false);
});
it('FE-COMP-CHAT-015: deleted message shows fallback text', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'some text',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: true,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
});
});
it('FE-COMP-CHAT-017: reaction badge renders for a message with reactions', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'React to me',
created_at: new Date().toISOString(),
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('React to me');
// ReactionBadge renders a button containing a TwemojiImg with alt=emoji
const img = screen.getByAltText('❤️');
expect(img).toBeInTheDocument();
});
it('FE-COMP-CHAT-018: WebSocket collab:message:created event adds message to list', async () => {
vi.clearAllMocks();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({
type: 'collab:message:created',
tripId: 1,
message: {
id: 99,
trip_id: 1,
user_id: 2,
username: 'alice',
text: 'WS message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
});
});
expect(await screen.findByText('WS message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-019: WebSocket collab:message:deleted event marks message as deleted', async () => {
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'To remove',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('To remove');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({ type: 'collab:message:deleted', tripId: 1, messageId: 1 });
});
await waitFor(() => {
expect(screen.queryByText('To remove')).not.toBeInTheDocument();
});
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
});
it('FE-COMP-CHAT-020: send button is disabled when input is empty', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const buttons = screen.getAllByRole('button');
// The send button is the ArrowUp button — it has disabled attr when text is empty
const sendButton = buttons.find((b) => b.hasAttribute('disabled'));
expect(sendButton).toBeTruthy();
expect(sendButton).toBeDisabled();
});
it('FE-COMP-CHAT-021: reply-to banner shows quoted author and text', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Reply here',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
reply_text: 'Original message',
reply_username: 'alice',
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reply here');
expect(screen.getByText(/Original message/i)).toBeInTheDocument();
});
it('FE-COMP-CHAT-022: own messages are displayed with blue bubble', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: currentUser.id,
username: 'testuser',
avatar_url: null,
text: 'My own message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('My own message');
// Own messages don't show a username label above the bubble (only other users get it)
// The component renders {!own && isNewGroup && <span>{msg.username}</span>}
// so 'testuser' should NOT appear as a username label
const usernameLabels = screen.queryAllByText('testuser');
expect(usernameLabels.length).toBe(0);
// And own message bubble uses row-reverse flex direction
const messageEl = screen.getByText('My own message');
let parent = messageEl.parentElement;
let foundRowReverse = false;
while (parent) {
const styleAttr = parent.getAttribute('style');
if (styleAttr && styleAttr.includes('row-reverse')) {
foundRowReverse = true;
break;
}
parent = parent.parentElement;
}
expect(foundRowReverse).toBe(true);
});
it('FE-COMP-CHAT-023: sending a message clears the input field', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/messages', async () =>
HttpResponse.json({
message: {
id: 2,
trip_id: 1,
user_id: 1,
username: 'testuser',
avatar_url: null,
text: 'Sent message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.type(input, 'Sent message');
expect((input as HTMLTextAreaElement).value).toBe('Sent message');
await user.keyboard('{Enter}');
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('');
});
});
it('FE-COMP-CHAT-024: load earlier messages button appears when 100+ messages exist', async () => {
const messages = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: `Message ${i + 1}`,
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
}));
server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json({ messages, total: 100 })));
render(<CollabChat {...defaultProps} />);
await screen.findByText('Message 1');
const loadMoreBtn = await screen.findByRole('button', { name: /load/i });
expect(loadMoreBtn).toBeInTheDocument();
});
it('FE-COMP-CHAT-025: clicking reply button on a message sets reply-to preview', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Reply to me',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reply to me');
// Hover action buttons are always in DOM but hidden via pointer-events: none
// Use fireEvent to bypass CSS pointer-events restrictions
const replyBtn = screen.getByTitle('Reply');
fireEvent.click(replyBtn);
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some((el) => el.tagName === 'STRONG')).toBe(true);
});
});
it('FE-COMP-CHAT-026: clicking X in reply preview cancels reply', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Cancel reply test',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Cancel reply test');
// Click reply button to show preview (bypassing pointer-events: none)
fireEvent.click(screen.getByTitle('Reply'));
// Wait for reply preview <strong> to appear
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some((el) => el.tagName === 'STRONG')).toBe(true);
});
// Find the X button inside the reply preview — the <strong> is inside a <span> inside the preview div
const strongEl = screen.getAllByText('alice').find((el) => el.tagName === 'STRONG')!;
const previewDiv = strongEl.closest('div[style]');
const xBtn = previewDiv?.querySelector('button');
expect(xBtn).toBeTruthy();
fireEvent.click(xBtn!);
await waitFor(() => {
// After cancel, no <strong>alice</strong> in reply preview
const remaining = screen.queryAllByText('alice');
expect(remaining.every((el) => el.tagName !== 'STRONG')).toBe(true);
});
});
it('FE-COMP-CHAT-027: clicking emoji button opens the emoji picker', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Smile button is the only non-disabled button when input is empty
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find((b) => !b.hasAttribute('disabled'));
expect(smileBtn).toBeTruthy();
await user.click(smileBtn!);
// EmojiPicker renders category tabs
await screen.findByText('Smileys');
expect(screen.getByText('Reactions')).toBeInTheDocument();
});
it('FE-COMP-CHAT-028: selecting emoji from picker appends it to the input', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find((b) => !b.hasAttribute('disabled'));
await user.click(smileBtn!);
// Wait for picker to open
await screen.findByText('Smileys');
// Click the first emoji in the grid (😀 is the first in Smileys)
const emojiImg = screen.getAllByRole('img').find((img) => img.getAttribute('alt') === '😀');
expect(emojiImg).toBeTruthy();
await user.click(emojiImg!.closest('button')!);
// Emoji should be appended to textarea
const textarea = screen.getByPlaceholderText('Type a message...');
expect((textarea as HTMLTextAreaElement).value).toContain('😀');
});
it('FE-COMP-CHAT-029: right-clicking a message opens the reaction menu', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Right click me',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Right click me');
const messageBubble = screen.getByText('Right click me').closest('div[style]');
fireEvent.contextMenu(messageBubble!);
// ReactionMenu renders quick reactions (❤️ is the first)
await waitFor(() => {
const reactionImgs = screen
.getAllByRole('img')
.filter((img) => ['❤️', '😂', '👍'].includes(img.getAttribute('alt') || ''));
expect(reactionImgs.length).toBeGreaterThan(0);
});
});
it('FE-COMP-CHAT-030: clicking a reaction in the menu calls reactMessage API', async () => {
let reactCalled = false;
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'React to this',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
),
http.post('/api/trips/1/collab/messages/1/react', async () => {
reactCalled = true;
return HttpResponse.json({
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }],
});
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('React to this');
// Open reaction context menu
const messageBubble = screen.getByText('React to this').closest('div[style]');
fireEvent.contextMenu(messageBubble!);
// Wait for menu and click first reaction (❤️)
const heartImg = await screen.findByAltText('❤️');
fireEvent.click(heartImg.closest('button')!);
await waitFor(() => expect(reactCalled).toBe(true));
});
it('FE-COMP-CHAT-031: WebSocket collab:message:reacted event updates reactions', async () => {
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Reacted message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reacted message');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({
type: 'collab:message:reacted',
tripId: 1,
messageId: 1,
reactions: [{ emoji: '🔥', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
});
});
await screen.findByAltText('🔥');
});
it('FE-COMP-CHAT-032: clicking "Load older messages" loads paginated results', async () => {
const initialMessages = Array.from({ length: 100 }, (_, i) => ({
id: i + 100,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: `New ${i + 100}`,
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
}));
let callCount = 0;
server.use(
http.get('/api/trips/1/collab/messages', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ messages: initialMessages, total: 120 });
}
return HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Older message',
created_at: '2020-01-01T10:00:00.000Z',
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 120,
});
})
);
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('New 100');
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
await user.click(loadMoreBtn);
await screen.findByText('Older message');
});
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: currentUser.id,
username: 'testuser',
avatar_url: null,
text: 'Delete me',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
),
http.delete('/api/trips/1/collab/messages/1', () => HttpResponse.json({ success: true }))
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Delete me');
// Delete button is in a hover-actions div with pointer-events: none — use fireEvent
const deleteBtn = screen.getByTitle('Delete');
fireEvent.click(deleteBtn);
// handleDelete uses a 400ms setTimeout before calling the API
await waitFor(() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(), { timeout: 1500 });
});
it('FE-COMP-CHAT-034: single-emoji message renders as big emoji', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: '👍',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('👍');
// Big emoji renders in a div with fontSize: 40px — include emojiEl itself in search
const emojiEl = screen.getByText('👍');
let el: HTMLElement | null = emojiEl as HTMLElement;
let foundBigEmoji = false;
while (el) {
const styleAttr = el.getAttribute('style');
if (styleAttr && styleAttr.includes('font-size: 40px')) {
foundBigEmoji = true;
break;
}
el = el.parentElement;
}
expect(foundBigEmoji).toBe(true);
});
it('FE-COMP-CHAT-035: 24h time format renders timestamp without AM/PM', async () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } as any });
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Time format test',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Time format test');
// 24h format: timestamp like "HH:MM" — no AM/PM suffix
expect(screen.queryByText(/AM|PM/)).not.toBeInTheDocument();
// There should be a timestamp element matching HH:MM
const timestamp = screen.getByText((text) => /^\d{1,2}:\d{2}$/.test(text));
expect(timestamp).toBeInTheDocument();
});
it('FE-COMP-CHAT-036: message with URL shows link preview when API returns data', async () => {
const uniqueUrl = 'https://preview-test-unique-url-9999.example.com/page';
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: `Check this out ${uniqueUrl}`,
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
),
http.get('/api/trips/1/collab/link-preview', () =>
HttpResponse.json({ title: 'Preview Title', description: 'Preview Desc', image: null, site_name: 'Example' })
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Check this out/);
await waitFor(() => expect(screen.getByText('Preview Title')).toBeInTheDocument(), { timeout: 3000 });
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,145 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }));
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }));
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }));
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }));
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import CollabPanel from './CollabPanel';
let originalInnerWidth: number;
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true });
window.dispatchEvent(new Event('resize'));
}
describe('CollabPanel', () => {
beforeEach(() => {
originalInnerWidth = window.innerWidth;
resetAllStores();
seedStore(useAuthStore, { user: buildUser() });
});
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true });
});
// FE-COMP-COLLABPANEL-001
it('desktop layout renders all four panels', () => {
setViewport(1280);
render(<CollabPanel tripId={1} />);
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-002
it('mobile layout renders tab bar, not all panels at once', () => {
setViewport(375);
render(<CollabPanel tripId={1} />);
// Tab buttons exist
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument();
// Only chat visible by default
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument();
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument();
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-003
it('mobile: clicking Notes tab switches to CollabNotes', () => {
setViewport(375);
render(<CollabPanel tripId={1} />);
fireEvent.click(screen.getByRole('button', { name: /notes/i }));
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-004
it('mobile: clicking Polls tab switches to CollabPolls', () => {
setViewport(375);
render(<CollabPanel tripId={1} />);
fireEvent.click(screen.getByRole('button', { name: /polls/i }));
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-005
it("mobile: clicking What's Next tab shows WhatsNextWidget", () => {
setViewport(375);
render(<CollabPanel tripId={1} />);
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }));
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-006
it('mobile: active tab button has accent background style', () => {
setViewport(375);
render(<CollabPanel tripId={1} />);
const chatButton = screen.getByRole('button', { name: /chat/i });
expect(chatButton.style.background).toBe('var(--accent)');
const notesButton = screen.getByRole('button', { name: /notes/i });
expect(notesButton.style.background).toBe('transparent');
});
// FE-COMP-COLLABPANEL-007
it('mobile: default active tab is Chat', () => {
setViewport(375);
render(<CollabPanel tripId={1} />);
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-008
it('tripMembers prop is forwarded to WhatsNextWidget', () => {
setViewport(1280);
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />);
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-009
it('tripId prop is forwarded to child components', () => {
setViewport(1280);
render(<CollabPanel tripId={1} />);
// All children render without errors, confirming props were forwarded
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-010
it('resize from desktop to mobile hides side-by-side layout', () => {
setViewport(1280);
const { rerender } = render(<CollabPanel tripId={1} />);
// All four panels visible on desktop
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
// Switch to mobile
setViewport(375);
rerender(<CollabPanel tripId={1} />);
// Tab bar appears, only chat visible
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument();
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument();
});
});
@@ -1,238 +0,0 @@
import { BarChart3, MessageCircle, Sparkles, StickyNote } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import CollabChat from './CollabChat';
import CollabNotes from './CollabNotes';
import CollabPolls from './CollabPolls';
import WhatsNextWidget from './WhatsNextWidget';
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint);
useEffect(() => {
const check = () => setIsDesktop(window.innerWidth >= breakpoint);
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, [breakpoint]);
return isDesktop;
}
const card = {
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-card)',
borderRadius: 16,
border: '1px solid var(--border-faint)',
overflow: 'hidden',
minHeight: 0,
};
interface TripMember {
id: number;
username: string;
avatar_url?: string | null;
}
interface CollabFeatures {
chat: boolean;
notes: boolean;
polls: boolean;
whatsnext: boolean;
}
interface CollabPanelProps {
tripId: number;
tripMembers?: TripMember[];
collabFeatures?: CollabFeatures;
}
const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
{
id: 'next',
featureKey: 'whatsnext' as const,
labelKey: 'collab.whatsNext.title',
fallback: "What's Next",
icon: Sparkles,
},
];
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
const { user } = useAuthStore();
const { t } = useTranslation();
const isDesktop = useIsDesktop();
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true };
const tabs = useMemo(
() =>
ALL_TABS.filter((tab) => features[tab.featureKey]).map((tab) => ({
...tab,
label: t(tab.labelKey) || tab.fallback,
})),
[features, t]
);
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat');
// If active tab gets disabled, switch to first available
useEffect(() => {
if (tabs.length > 0 && !tabs.some((t) => t.id === mobileTab)) {
setMobileTab(tabs[0].id);
}
}, [tabs, mobileTab]);
const chatOn = features.chat;
const rightPanels = [features.notes && 'notes', features.polls && 'polls', features.whatsnext && 'whatsnext'].filter(
Boolean
) as string[];
if (tabs.length === 0) return null;
if (isDesktop) {
// Chat always 380px fixed when on. Right panels share remaining space.
// If chat off, all panels share full width equally.
if (chatOn && rightPanels.length === 0) {
// Only chat
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
</div>
);
}
if (chatOn) {
// Chat left (380px) + right panels
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: '0 0 380px' }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{rightPanels.length === 1 && (
<div style={{ ...card, flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 &&
rightPanels.map((p) => (
<div key={p} style={{ ...card, flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
{rightPanels.length === 3 && (
<>
<div style={{ ...card, flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div style={{ ...card, flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
</>
)}
</div>
</div>
);
}
// Chat off — remaining panels share full width
const panels = rightPanels;
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
);
}
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{panels.map((p) => (
<div key={p} style={{ ...card, flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
</div>
);
}
// Mobile: tab bar + single panel (only enabled tabs)
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
position: 'absolute',
inset: 0,
}}
>
<div
style={{
display: 'flex',
gap: 2,
padding: '8px 12px',
borderBottom: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
flexShrink: 0,
}}
>
{tabs.map((tab) => {
const active = mobileTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setMobileTab(tab.id)}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
padding: '8px 0',
borderRadius: 10,
border: 'none',
cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 11,
fontWeight: 600,
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
>
{tab.label}
</button>
);
})}
</div>
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
);
}
@@ -1,248 +0,0 @@
// FE-COMP-POLLS-001 to FE-COMP-POLLS-015
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { addListener } from '../../api/websocket';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import CollabPolls from './CollabPolls';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const buildPoll = (overrides: Record<string, unknown> = {}) => ({
id: 1,
question: 'Best destination?',
options: [
{ id: 1, text: 'Paris', label: 'Paris', voters: [] },
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
],
multi_choice: false,
is_closed: false,
deadline: null,
created_by: 1,
created_at: new Date().toISOString(),
...overrides,
});
const defaultProps = { tripId: 1, currentUser };
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [] })));
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
});
describe('CollabPolls', () => {
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
});
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
server.use(
http.get('/api/trips/1/collab/polls', async () => {
await new Promise((r) => setTimeout(r, 200));
return HttpResponse.json({ polls: [] });
})
);
render(<CollabPolls {...defaultProps} />);
// The spinner is a div with animation style
expect(document.querySelector('[style*="animation"]')).toBeInTheDocument();
});
it('FE-COMP-POLLS-003: renders poll question from API', async () => {
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })));
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
});
it('FE-COMP-POLLS-004: renders poll options', async () => {
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })));
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
expect(screen.getByText('Rome')).toBeInTheDocument();
});
it('FE-COMP-POLLS-005: New Poll button is visible when user can edit', async () => {
render(<CollabPolls {...defaultProps} />);
// Wait for loading to finish
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
});
it('FE-COMP-POLLS-006: clicking New Poll button opens the create modal', async () => {
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Modal has a question placeholder input
await screen.findByPlaceholderText(/what should we do/i);
});
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Find submit button - it's the form submit with the create label
const submitBtn = screen.getByRole('button', { name: /create|collab\.polls\.create/i });
expect(submitBtn).toBeDisabled();
// Fill in question
const questionInput = screen.getByPlaceholderText(/what should we do/i);
await user.type(questionInput, 'Where to go?');
// Still disabled — no options filled
expect(submitBtn).toBeDisabled();
// Fill in 2 options
const optionInputs = screen.getAllByPlaceholderText(/option/i);
await user.type(optionInputs[0], 'Beach');
await user.type(optionInputs[1], 'Mountain');
expect(submitBtn).toBeEnabled();
});
it('FE-COMP-POLLS-008: creating a poll calls POST API and adds it to the list', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/polls', () =>
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) })
)
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Where to eat?');
const optionInputs = screen.getAllByPlaceholderText(/option/i);
await user.type(optionInputs[0], 'Italian');
await user.type(optionInputs[1], 'Japanese');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await screen.findByText('Where to eat?');
});
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
let voteCalled = false;
server.use(
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })),
http.post('/api/trips/1/collab/polls/1/vote', () => {
voteCalled = true;
return HttpResponse.json({
poll: buildPoll({
options: [
{
id: 1,
text: 'Paris',
label: 'Paris',
voters: [{ user_id: 1, username: 'testuser', avatar_url: null }],
},
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
],
}),
});
})
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
await user.click(screen.getByText('Paris'));
await waitFor(() => expect(voteCalled).toBe(true));
});
it('FE-COMP-POLLS-010: closed poll shows "Closed" badge', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }))
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/closed/i);
});
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }))
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
const parisBtn = screen.getByText('Paris').closest('button');
expect(parisBtn).toBeDisabled();
});
it('FE-COMP-POLLS-012: delete button calls DELETE API and removes poll', async () => {
let deleteCalled = false;
server.use(
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ id: 5 })] })),
http.delete('/api/trips/1/collab/polls/5', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
// Delete button has a title with "delete"
const deleteBtn = screen.getByTitle(/delete/i);
await user.click(deleteBtn);
await waitFor(() => expect(deleteCalled).toBe(true));
await waitFor(() => expect(screen.queryByText('Best destination?')).not.toBeInTheDocument());
});
it('FE-COMP-POLLS-013: WebSocket collab:poll:created event adds poll', async () => {
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
// Get the WS listener that was registered
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
await screen.findByText('Live poll?');
});
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ id: 3 })] })));
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:deleted', pollId: 3 });
await waitFor(() => expect(screen.queryByText('Best destination?')).not.toBeInTheDocument());
});
it('FE-COMP-POLLS-015: adding a third option in create modal', async () => {
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Initially 2 option inputs
let optionInputs = screen.getAllByPlaceholderText(/option/i);
expect(optionInputs).toHaveLength(2);
// Click "Add option"
await user.click(screen.getByText(/add option/i));
optionInputs = screen.getAllByPlaceholderText(/option/i);
expect(optionInputs).toHaveLength(3);
});
});
@@ -1,897 +0,0 @@
import { BarChart3, Check, Clock, Lock, Plus, Trash2, X } from 'lucide-react';
import React, { useCallback, useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { collabApi } from '../../api/client';
import { addListener, removeListener } from '../../api/websocket';
import { useTranslation } from '../../i18n';
import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
import type { User } from '../../types';
interface PollVoter {
user_id: number;
username: string;
avatar_url: string | null;
}
interface PollOption {
id: number;
text: string;
voters: PollVoter[];
}
interface Poll {
id: number;
question: string;
options: PollOption[];
multi_choice: boolean;
is_closed: boolean;
deadline: string | null;
created_by: number;
created_at: string;
}
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif";
function timeRemaining(deadline) {
if (!deadline) return null;
const diff = new Date(deadline).getTime() - Date.now();
if (diff <= 0) return null;
const mins = Math.floor(diff / 60000);
const hrs = Math.floor(mins / 60);
const days = Math.floor(hrs / 24);
if (days > 0) return `${days}d ${hrs % 24}h`;
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
}
function isExpired(deadline) {
if (!deadline) return false;
return new Date(deadline).getTime() <= Date.now();
}
function totalVotes(poll) {
return (poll.options || []).reduce((s, o) => s + (o.voters?.length || 0), 0);
}
// ── Create Poll Modal ────────────────────────────────────────────────────────
interface CreatePollModalProps {
onClose: () => void;
onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise<void>;
t: (key: string) => string;
}
function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
const [question, setQuestion] = useState('');
const [options, setOptions] = useState(['', '']);
const [multiChoice, setMultiChoice] = useState(false);
const [submitting, setSubmitting] = useState(false);
const addOption = () => setOptions((prev) => [...prev, '']);
const removeOption = (i) => setOptions((prev) => prev.filter((_, j) => j !== i));
const updateOption = (i, v) => setOptions((prev) => prev.map((o, j) => (j === i ? v : o)));
const canSubmit = question.trim() && options.filter((o) => o.trim()).length >= 2 && !submitting;
const handleSubmit = async (e) => {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
try {
await onCreate({
question: question.trim(),
options: options.filter((o) => o.trim()),
multiple_choice: multiChoice,
});
onClose();
} catch {
} finally {
setSubmitting(false);
}
};
return ReactDOM.createPortal(
<div
style={{
position: 'fixed',
inset: 0,
background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
padding: 16,
fontFamily: FONT,
}}
onClick={onClose}
>
<form
style={{
background: 'var(--bg-card)',
borderRadius: 16,
width: '100%',
maxWidth: 400,
maxHeight: '90vh',
overflow: 'auto',
border: '1px solid var(--border-faint)',
}}
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px 12px',
borderBottom: '1px solid var(--border-faint)',
}}
>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
{t('collab.polls.new')}
</h3>
<button
type="button"
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
padding: 2,
display: 'flex',
}}
>
<X size={16} />
</button>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Question */}
<div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
}}
>
{t('collab.polls.question')}
</div>
<input
autoFocus
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'}
style={{
width: '100%',
border: '1px solid var(--border-primary)',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
fontFamily: 'inherit',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{/* Options */}
<div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
}}
>
{t('collab.polls.options')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
value={opt}
onChange={(e) => updateOption(i, e.target.value)}
placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{
flex: 1,
border: '1px solid var(--border-primary)',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
fontFamily: 'inherit',
outline: 'none',
}}
/>
{options.length > 2 && (
<button
type="button"
onClick={() => removeOption(i)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
display: 'flex',
padding: 2,
}}
>
<X size={14} />
</button>
)}
</div>
))}
<button
type="button"
onClick={addOption}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '6px 12px',
borderRadius: 10,
border: '1px dashed var(--border-faint)',
background: 'transparent',
cursor: 'pointer',
color: 'var(--text-faint)',
fontSize: 12,
fontFamily: FONT,
}}
>
<Plus size={12} /> {t('collab.polls.addOption')}
</button>
</div>
</div>
{/* Multi choice toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<div
onClick={() => setMultiChoice(!multiChoice)}
style={{
width: 36,
height: 20,
borderRadius: 10,
padding: 2,
cursor: 'pointer',
background: multiChoice ? '#007AFF' : 'var(--border-primary)',
transition: 'background 0.2s',
display: 'flex',
alignItems: 'center',
}}
>
<div
style={{
width: 16,
height: 16,
borderRadius: '50%',
background: '#fff',
transition: 'transform 0.2s',
transform: multiChoice ? 'translateX(16px)' : 'translateX(0)',
}}
/>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>
{t('collab.polls.multiChoice')}
</span>
</label>
{/* Submit */}
<button
type="submit"
disabled={!canSubmit}
style={{
width: '100%',
borderRadius: 99,
padding: '9px 14px',
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 13,
fontWeight: 600,
border: 'none',
cursor: canSubmit ? 'pointer' : 'default',
fontFamily: FONT,
}}
>
{submitting ? '...' : t('collab.polls.create')}
</button>
</div>
</form>
</div>,
document.body
);
}
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
interface VoterChipProps {
voter: PollVoter;
offset: boolean;
}
function VoterChip({ voter, offset }: VoterChipProps) {
const [hover, setHover] = useState(false);
const ref = React.useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
return (
<>
<div
ref={ref}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 });
}
setHover(true);
}}
onMouseLeave={() => setHover(false)}
style={{
width: 18,
height: 18,
borderRadius: '50%',
background: 'var(--bg-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 7,
fontWeight: 700,
color: 'var(--text-muted)',
overflow: 'hidden',
border: '1.5px solid var(--bg-card)',
marginLeft: offset ? -5 : 0,
flexShrink: 0,
}}
>
{voter.avatar_url ? (
<img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
(voter.username || '?')[0].toUpperCase()
)}
</div>
{hover &&
ReactDOM.createPortal(
<div
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
transform: 'translate(-50%, -100%)',
pointerEvents: 'none',
zIndex: 10000,
whiteSpace: 'nowrap',
background: 'var(--bg-card)',
color: 'var(--text-primary)',
fontSize: 11,
fontWeight: 500,
padding: '5px 10px',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint)',
}}
>
{voter.username}
</div>,
document.body
)}
</>
);
}
// ── Poll Card ────────────────────────────────────────────────────────────────
interface PollCardProps {
poll: Poll;
currentUser: User;
canEdit: boolean;
onVote: (pollId: number, optionId: number) => Promise<void>;
onClose: (pollId: number) => Promise<void>;
onDelete: (pollId: number) => Promise<void>;
t: (key: string) => string;
}
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
const total = totalVotes(poll);
const isClosed = poll.is_closed || isExpired(poll.deadline);
const remaining = timeRemaining(poll.deadline);
const hasVoted = (poll.options || []).some((o) =>
(o.voters || []).some((v) => String(v.user_id) === String(currentUser.id))
);
return (
<div
style={{
borderRadius: 14,
border: '1px solid var(--border-faint)',
overflow: 'hidden',
background: 'var(--bg-card)',
fontFamily: FONT,
}}
>
{/* Header */}
<div
style={{
padding: '10px 12px',
display: 'flex',
alignItems: 'flex-start',
gap: 8,
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: 'var(--text-primary)',
lineHeight: 1.35,
wordBreak: 'break-word',
}}
>
{poll.question}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
background: 'var(--bg-tertiary)',
padding: '2px 7px',
borderRadius: 99,
}}
>
<Lock size={8} /> {t('collab.polls.closed')}
</span>
)}
{remaining && !isClosed && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
fontSize: 9,
fontWeight: 600,
color: '#f59e0b',
background: '#f59e0b18',
padding: '2px 7px',
borderRadius: 99,
}}
>
<Clock size={8} /> {remaining}
</span>
)}
{poll.multiple_choice && (
<span
style={{
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
background: 'var(--bg-tertiary)',
padding: '2px 7px',
borderRadius: 99,
}}
>
{t('collab.polls.multiChoice')}
</span>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
{total} {total === 1 ? 'vote' : 'votes'}
</span>
</div>
</div>
{/* Actions */}
{canEdit && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!isClosed && (
<button
onClick={() => onClose(poll.id)}
title={t('collab.polls.close')}
style={{
padding: 4,
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
display: 'flex',
borderRadius: 6,
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Lock size={12} />
</button>
)}
<button
onClick={() => onDelete(poll.id)}
title={t('collab.polls.delete')}
style={{
padding: 4,
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
display: 'flex',
borderRadius: 6,
}}
onMouseEnter={(e) => (e.currentTarget.style.color = '#ef4444')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Trash2 size={12} />
</button>
</div>
)}
</div>
{/* Options */}
<div style={{ padding: '4px 12px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{(poll.options || []).map((opt, idx) => {
const count = opt.voters?.length || 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const myVote = (opt.voters || []).some((v) => String(v.user_id) === String(currentUser.id));
const isWinner =
isClosed && count === Math.max(...(poll.options || []).map((o) => o.voters?.length || 0)) && count > 0;
return (
<button
key={idx}
onClick={() => !isClosed && onVote(poll.id, idx)}
disabled={isClosed}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 12px',
borderRadius: 10,
border: 'none',
cursor: isClosed ? 'default' : 'pointer',
background: 'var(--bg-secondary)',
fontFamily: FONT,
textAlign: 'left',
width: '100%',
overflow: 'hidden',
transition: 'transform 0.1s',
}}
onMouseEnter={(e) => {
if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)';
}}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{/* Progress bar background */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${pct}%`,
borderRadius: 10,
background: myVote ? '#007AFF20' : isWinner ? '#10b98118' : 'var(--bg-tertiary)',
transition: 'width 0.4s ease',
}}
/>
{/* Check circle */}
<div
style={{
width: 20,
height: 20,
borderRadius: '50%',
flexShrink: 0,
position: 'relative',
border: myVote ? '2px solid #007AFF' : '2px solid var(--border-primary)',
background: myVote ? '#007AFF' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
}}
>
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
</div>
{/* Label */}
<span
style={{
flex: 1,
fontSize: 13,
fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)',
position: 'relative',
zIndex: 1,
}}
>
{typeof opt === 'string' ? opt : opt.label || opt}
</span>
{/* Voter avatars */}
{(opt.voters || []).length > 0 && (hasVoted || isClosed) && (
<div style={{ display: 'flex', position: 'relative', zIndex: 1 }}>
{(opt.voters || []).slice(0, 3).map((v, vi) => (
<VoterChip key={v.user_id || vi} voter={v} offset={vi > 0} />
))}
</div>
)}
{/* Percentage */}
{(hasVoted || isClosed) && (
<span
style={{
fontSize: 12,
fontWeight: 700,
color: myVote ? '#007AFF' : 'var(--text-muted)',
position: 'relative',
zIndex: 1,
minWidth: 32,
textAlign: 'right',
}}
>
{pct}%
</span>
)}
</button>
);
})}
</div>
</div>
);
}
// ── Main Component ───────────────────────────────────────────────────────────
interface CollabPollsProps {
tripId: number;
currentUser: User;
}
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation();
const can = useCanDo();
const trip = useTripStore((s) => s.trip);
const canEdit = can('collab_edit', trip);
const [polls, setPolls] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
useEffect(() => {
collabApi
.getPolls(tripId)
.then((data) => {
setPolls(Array.isArray(data) ? data : data.polls || []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [tripId]);
// WebSocket
useEffect(() => {
const handler = (msg) => {
if (!msg?.type) return;
if (msg.type === 'collab:poll:created' && msg.poll) {
setPolls((prev) => (prev.some((p) => p.id === msg.poll.id) ? prev : [msg.poll, ...prev]));
}
if (msg.type === 'collab:poll:voted' && msg.poll) {
setPolls((prev) => prev.map((p) => (p.id === msg.poll.id ? msg.poll : p)));
}
if (msg.type === 'collab:poll:closed' && msg.poll) {
setPolls((prev) => prev.map((p) => (p.id === msg.poll.id ? { ...p, ...msg.poll, is_closed: true } : p)));
}
if (msg.type === 'collab:poll:deleted') {
const id = msg.pollId || msg.poll?.id;
if (id) setPolls((prev) => prev.filter((p) => p.id !== id));
}
};
addListener(handler);
return () => removeListener(handler);
}, []);
const handleCreate = useCallback(
async (data) => {
const result = await collabApi.createPoll(tripId, data);
const created = result.poll || result;
setPolls((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev]));
setShowForm(false);
},
[tripId]
);
const handleVote = useCallback(
async (pollId, optionIndex) => {
try {
const result = await collabApi.votePoll(tripId, pollId, optionIndex);
const updated = result.poll || result;
setPolls((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
} catch {}
},
[tripId]
);
const handleClose = useCallback(
async (pollId) => {
try {
await collabApi.closePoll(tripId, pollId);
setPolls((prev) => prev.map((p) => (p.id === pollId ? { ...p, is_closed: true } : p)));
} catch {}
},
[tripId]
);
const handleDelete = useCallback(
async (pollId) => {
try {
await collabApi.deletePoll(tripId, pollId);
setPolls((prev) => prev.filter((p) => p.id !== pollId));
} catch {}
},
[tripId]
);
const activePolls = polls.filter((p) => !p.is_closed && !isExpired(p.deadline));
const closedPolls = polls.filter((p) => p.is_closed || isExpired(p.deadline));
// Deadline ticker
const [, setTick] = useState(0);
useEffect(() => {
if (!polls.some((p) => p.deadline && !p.is_closed)) return;
const iv = setInterval(() => setTick((t) => t + 1), 30000);
return () => clearInterval(iv);
}, [polls]);
if (loading) {
return (
<div
style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: FONT }}
>
<div
style={{
width: 20,
height: 20,
border: '2px solid var(--border-primary)',
borderTopColor: 'var(--text-primary)',
borderRadius: '50%',
animation: 'collab-poll-spin 0.7s linear infinite',
}}
/>
<style>{`@keyframes collab-poll-spin { to { transform: rotate(360deg) } }`}</style>
</div>
);
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
flexShrink: 0,
}}
>
<h3
style={{
margin: 0,
fontSize: 12,
fontWeight: 600,
color: 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
gap: 7,
letterSpacing: 0.3,
textTransform: 'uppercase',
}}
>
<BarChart3 size={14} color="var(--text-faint)" />
{t('collab.polls.title')}
</h3>
{canEdit && (
<button
onClick={() => setShowForm(true)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
borderRadius: 99,
padding: '6px 12px',
background: 'var(--accent)',
color: 'var(--accent-text)',
fontSize: 11,
fontWeight: 600,
fontFamily: FONT,
border: 'none',
cursor: 'pointer',
}}
>
<Plus size={12} /> {t('collab.polls.new')}
</button>
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{polls.length === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 20px',
textAlign: 'center',
height: '100%',
}}
>
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('collab.polls.empty')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{activePolls.length > 0 &&
activePolls.map((poll) => (
<PollCard
key={poll.id}
poll={poll}
currentUser={currentUser}
canEdit={canEdit}
onVote={handleVote}
onClose={handleClose}
onDelete={handleDelete}
t={t}
/>
))}
{closedPolls.length > 0 && (
<>
{activePolls.length > 0 && (
<div
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: 0.3,
padding: '8px 0 2px',
}}
>
{t('collab.polls.closedSection') || 'Closed'}
</div>
)}
{closedPolls.map((poll) => (
<PollCard
key={poll.id}
poll={poll}
currentUser={currentUser}
canEdit={canEdit}
onVote={handleVote}
onClose={handleClose}
onDelete={handleDelete}
t={t}
/>
))}
</>
)}
</div>
)}
</div>
{/* Create Modal */}
{showForm && <CreatePollModal onClose={() => setShowForm(false)} onCreate={handleCreate} t={t} />}
</div>
);
}
@@ -1,311 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import WhatsNextWidget from './WhatsNextWidget';
// Dynamic date helpers
const today = new Date().toISOString().split('T')[0];
function getFutureDate(daysAhead: number): string {
const d = new Date();
d.setDate(d.getDate() + daysAhead);
return d.toISOString().split('T')[0];
}
function getPastDate(daysBack: number): string {
const d = new Date();
d.setDate(d.getDate() - daysBack);
return d.toISOString().split('T')[0];
}
const tomorrow = getFutureDate(1);
const yesterday = getPastDate(1);
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []) {
return {
id,
day_id: 1,
place_id: id,
order_index: 0,
notes: null,
place: {
id,
trip_id: 1,
name: `Place ${id}`,
description: null,
lat: 0,
lng: 0,
address: null,
category_id: null,
icon: null,
price: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
created_at: '2025-01-01T00:00:00.000Z',
...placeOverrides,
},
participants,
};
}
describe('WhatsNextWidget', () => {
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
});
afterEach(() => {
resetAllStores();
});
it('FE-COMP-WHATSNEXT-001: renders empty state when no days exist', () => {
seedStore(useTripStore, { days: [], assignments: {} });
render(<WhatsNextWidget />);
// Translation resolves to "No upcoming activities"
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument();
expect(screen.queryByText('Place 1')).toBeNull();
});
it('FE-COMP-WHATSNEXT-001b: empty state element is rendered', () => {
seedStore(useTripStore, { days: [], assignments: {} });
render(<WhatsNextWidget />);
// collab.whatsNext.empty key is rendered as text in test env
const allText = document.body.textContent || '';
// No assignment time/name visible — just the header and empty hint
expect(allText).not.toContain('14:30');
});
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
seedStore(useTripStore, {
days: [
{
id: 1,
trip_id: 1,
date: yesterday,
title: 'Old Day',
order: 0,
assignments: [],
notes_items: [],
notes: null,
},
],
assignments: {
'1': [makeAssignment(10, { place_time: '08:00' })],
},
});
render(<WhatsNextWidget />);
expect(screen.queryByText('08:00')).toBeNull();
expect(screen.queryByText('Place 10')).toBeNull();
});
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(21, { name: 'Museum' })],
},
});
render(<WhatsNextWidget />);
// The label text comes from t('collab.whatsNext.tomorrow') which falls back to 'Tomorrow'
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText(/today/i)).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText('14:30')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText('2:30 PM')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText('TBD')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-010: caps list at 8 items', () => {
const days = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
trip_id: 1,
date: getFutureDate(i + 1),
title: null,
order: i,
assignments: [],
notes_items: [],
notes: null,
}));
const assignments: Record<string, unknown[]> = {};
let placeId = 100;
for (const day of days) {
assignments[String(day.id)] = [
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '10:00' }),
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '11:00' }),
];
}
seedStore(useTripStore, { days, assignments });
render(<WhatsNextWidget />);
// 10 items seeded, only 8 should appear — count "TBD" or time occurrences
const timeElements = screen.getAllByText('10:00');
// At most 4 days * 1 morning slot = up to 4 "10:00" entries, but capped at 8 total items
// We verify total rendered items is at most 8 by counting both time slots
const allTimes = screen.getAllByText(/10:00|11:00/);
expect(allTimes.length).toBeLessThanOrEqual(8);
});
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText('alice')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(41, { name: 'Park' }, [])],
},
});
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />);
expect(screen.getByText('bob')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
},
});
render(<WhatsNextWidget />);
expect(screen.getByText('19:00')).toBeInTheDocument();
expect(screen.getByText('21:30')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
makeAssignment(61, { name: 'Lunch', place_time: '12:00' }),
],
},
});
render(<WhatsNextWidget />);
const tomorrowHeaders = screen.getAllByText(/tomorrow/i);
// Only one day header for tomorrow
expect(tomorrowHeaders).toHaveLength(1);
expect(screen.getByText('Breakfast')).toBeInTheDocument();
expect(screen.getByText('Lunch')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
// If it's not midnight, a past-time event today should not appear
const now = new Date();
if (now.getHours() > 0) {
const pastTime = '00:01'; // Very early — will be past for most of the day
seedStore(useTripStore, {
days: [
{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
},
});
render(<WhatsNextWidget />);
// If current time > 00:01, the item should not appear
if (now.getHours() > 0 || now.getMinutes() > 1) {
expect(screen.queryByText('Early Bird')).toBeNull();
}
}
});
});
@@ -1,326 +0,0 @@
import { Calendar, MapPin, Sparkles } from 'lucide-react';
import React, { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
function formatTime(timeStr, is12h) {
if (!timeStr) return '';
const [h, m] = timeStr.split(':').map(Number);
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${String(m).padStart(2, '0')} ${period}`;
}
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function formatDayLabel(date, t, locale) {
const now = new Date();
const nowDate = now.toISOString().split('T')[0];
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0];
if (date === nowDate) return t('collab.whatsNext.today') || 'Today';
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow';
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
interface TripMember {
id: number;
username: string;
avatar_url?: string | null;
}
interface WhatsNextWidgetProps {
tripMembers?: TripMember[];
}
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
const { days, assignments } = useTripStore();
const { t, locale } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const upcoming = useMemo(() => {
const now = new Date();
const nowDate = now.toISOString().split('T')[0];
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const items = [];
for (const day of days || []) {
if (!day.date) continue;
const dayAssignments = assignments[String(day.id)] || [];
for (const a of dayAssignments) {
if (!a.place) continue;
// Include: today (future times) + all future days
const isFutureDay = day.date > nowDate;
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime);
if (isFutureDay || isTodayFuture) {
items.push({
id: a.id,
name: a.place.name,
time: a.place.place_time,
endTime: a.place.end_time,
date: day.date,
dayTitle: day.title,
category: a.place.category,
participants:
a.participants && a.participants.length > 0
? a.participants
: tripMembers.map((m) => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
address: a.place.address,
});
}
}
}
items.sort((a, b) => {
const da = a.date + (a.time || '99:99');
const db = b.date + (b.time || '99:99');
return da.localeCompare(db);
});
return items.slice(0, 8);
}, [days, assignments, tripMembers]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div
style={{
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: 7,
flexShrink: 0,
}}
>
<Sparkles size={14} color="var(--text-faint)" />
<span
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-muted)',
letterSpacing: 0.3,
textTransform: 'uppercase',
}}
>
{t('collab.whatsNext.title') || "What's Next"}
</span>
</div>
{/* List */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
{upcoming.length === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '48px 20px',
textAlign: 'center',
}}
>
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('collab.whatsNext.empty')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{upcoming.map((item, idx) => {
const prevItem = upcoming[idx - 1];
const showDayHeader = !prevItem || prevItem.date !== item.date;
return (
<React.Fragment key={item.id}>
{showDayHeader && (
<div
style={{
fontSize: 10,
fontWeight: 500,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: 0.5,
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
}}
>
{formatDayLabel(item.date, t, locale)}
{item.dayTitle ? `${item.dayTitle}` : ''}
</div>
)}
<div
style={{
display: 'flex',
gap: 10,
padding: '8px 10px',
borderRadius: 10,
background: 'var(--bg-secondary)',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-secondary)')}
>
{/* Time column */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minWidth: 44,
flexShrink: 0,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
lineHeight: 1,
}}
>
{item.time ? formatTime(item.time, is12h) : 'TBD'}
</span>
{item.endTime && (
<>
<span
style={{
fontSize: 7,
color: 'var(--text-faint)',
fontWeight: 600,
letterSpacing: 0.3,
margin: '2px 0',
textTransform: 'uppercase',
}}
>
{t('collab.whatsNext.until') || 'bis'}
</span>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
lineHeight: 1,
}}
>
{formatTime(item.endTime, is12h)}
</span>
</>
)}
</div>
{/* Divider */}
<div
style={{
width: 1,
alignSelf: 'stretch',
background: 'var(--border-faint)',
flexShrink: 0,
margin: '2px 0',
}}
/>
{/* Details */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name}
</div>
{item.address && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span
style={{
fontSize: 10,
color: 'var(--text-faint)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.address}
</span>
</div>
)}
{/* Participants */}
{item.participants.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
{item.participants.map((p) => (
<div
key={p.user_id}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px 2px 3px',
borderRadius: 99,
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-faint)',
}}
>
<div
style={{
width: 16,
height: 16,
borderRadius: '50%',
background: 'var(--bg-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 7,
fontWeight: 700,
color: 'var(--text-muted)',
overflow: 'hidden',
flexShrink: 0,
}}
>
{p.avatar ? (
<img
src={`/uploads/avatars/${p.avatar}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
p.username?.[0]?.toUpperCase()
)}
</div>
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>
{p.username}
</span>
</div>
))}
</div>
)}
</div>
</div>
</React.Fragment>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -1,280 +0,0 @@
import { ArrowRightLeft, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import CustomSelect from '../shared/CustomSelect';
const CURRENCIES = [
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'FOK',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KID',
'KMF',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRU',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLE',
'SOS',
'SRD',
'SSP',
'STN',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TVD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XCD',
'XDR',
'XOF',
'XPF',
'YER',
'ZAR',
'ZMW',
'ZWL',
];
const CURRENCY_OPTIONS = CURRENCIES.map((c) => ({ value: c, label: c }));
export default function CurrencyWidget() {
const { t, locale } = useTranslation();
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR');
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD');
const [amount, setAmount] = useState('100');
const [rate, setRate] = useState(null);
const [loading, setLoading] = useState(false);
const fetchRate = useCallback(async () => {
if (from === to) {
setRate(1);
return;
}
setLoading(true);
try {
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`);
const data = await resp.json();
setRate(data.rates?.[to] || null);
} catch {
setRate(null);
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => {
fetchRate();
}, [fetchRate]);
useEffect(() => {
localStorage.setItem('currency_from', from);
}, [from]);
useEffect(() => {
localStorage.setItem('currency_to', to);
}, [to]);
const swap = () => {
setFrom(to);
setTo(from);
};
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null;
const formatNumber = (num) => {
if (!num || num === '—') return '—';
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const result = rawResult;
return (
<div
className="rounded-2xl border p-4"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>
{t('dashboard.currency')}
</span>
<button onClick={fetchRate} className="rounded-md p-1 transition-colors" style={{ color: 'var(--text-faint)' }}>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Amount */}
<div
className="mb-3 rounded-xl px-4 py-3"
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}
>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
/>
</div>
{/* From / Swap / To */}
<div className="mb-3 flex items-center gap-2">
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
<button
onClick={swap}
className="shrink-0 rounded-lg p-1.5 transition-colors"
style={{ color: 'var(--text-muted)' }}
>
<ArrowRightLeft size={13} />
</button>
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
</div>
{/* Result */}
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{formatNumber(result)}{' '}
<span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>
{to}
</span>
</p>
{rate && (
<p className="mt-0.5 text-[10px]" style={{ color: 'var(--text-faint)' }}>
1 {from} = {rate.toFixed(4)} {to}
</p>
)}
</div>
</div>
);
}
@@ -1,149 +0,0 @@
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import TimezoneWidget from './TimezoneWidget';
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
localStorage.clear();
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any);
});
describe('TimezoneWidget', () => {
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
render(<TimezoneWidget />);
expect(document.body).toBeInTheDocument();
expect(screen.getByText('New York')).toBeInTheDocument();
expect(screen.getByText('Tokyo')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
render(<TimezoneWidget />);
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/);
expect(timeElements.length).toBeGreaterThan(0);
});
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
render(<TimezoneWidget />);
expect(screen.getByText(/timezones/i)).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
localStorage.clear();
render(<TimezoneWidget />);
expect(screen.getByText('New York')).toBeInTheDocument();
expect(screen.getByText('Tokyo')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]));
render(<TimezoneWidget />);
expect(screen.getByText('Berlin')).toBeInTheDocument();
expect(screen.queryByText('New York')).toBeNull();
});
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
// Open add panel
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
// Find and click Berlin in the popular zones list
const berlinButton = await screen.findByRole('button', { name: /Berlin/i });
await user.click(berlinButton);
expect(screen.getByText('Berlin')).toBeInTheDocument();
// Panel should be closed
expect(screen.queryByText('Custom Timezone')).toBeNull();
});
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
// Open add panel
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
// Type label and timezone
const labelInput = screen.getByPlaceholderText('Label (optional)');
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(labelInput, 'My City');
await user.type(tzInput, 'Europe/Paris');
// Click Add
const addButton = screen.getByRole('button', { name: 'Add' });
await user.click(addButton);
expect(await screen.findByText('My City')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(tzInput, 'Invalid/Timezone');
const addButton = screen.getByRole('button', { name: 'Add' });
await user.click(addButton);
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
// Default zones include New York (America/New_York)
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(tzInput, 'America/New_York');
const addButton = screen.getByRole('button', { name: 'Add' });
await user.click(addButton);
expect(await screen.findByText(/already added/i)).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
expect(screen.getByText('New York')).toBeInTheDocument();
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
// Remove buttons for New York and Tokyo come after the Plus button
const allButtons = screen.getAllByRole('button');
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
await user.click(allButtons[1]);
expect(screen.queryByText('New York')).toBeNull();
expect(screen.getByText('Tokyo')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const berlinButton = await screen.findByRole('button', { name: /Berlin/i });
await user.click(berlinButton);
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]');
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true);
});
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const labelInput = screen.getByPlaceholderText('Label (optional)');
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(labelInput, 'Singapore');
await user.type(tzInput, 'Asia/Singapore');
await user.keyboard('{Enter}');
expect(await screen.findByText('Singapore')).toBeInTheDocument();
});
});

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